Skip to content

Validate source/sink types and inject assignment casts on write - #250

Merged
jogrogan merged 7 commits into
mainfrom
jogrogan/typeCoercion
Sep 4, 2026
Merged

jogrogan merged 7 commits into
mainfrom
jogrogan/typeCoercion

Conversation

@jogrogan

@jogrogan jogrogan commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

Summary

When a materialized view or INSERT writes into a sink with a declared schema, Hoptimator previously deferred all query-vs-sink type checking to the Flink job, so a mismatch — e.g. a raw Kafka primitive key exposed as STRING projected onto a typed BIGINT key column — only failed at job submission with a cryptic Flink error. This adds plan-time reconciliation in PipelineRel: Hoptimator now compares each projected column to its sink column and either injects an assignment CAST or raises a clear error up front.

Behavior

A new castMode hint (fail-closed, defaults to strict) controls how aggressively casts are injected. It maps directly onto Calcite's SqlTypeUtil.canCastFrom:

Mode Injects a cast for Notes
strict (default) implicitly-assignable (lossless) conversions, e.g. widening INTEGER -> BIGINT rejects STRING -> BIGINT and lossy narrowing BIGINT -> INTEGER
assign strict + a character-to-scalar carve-out (covers the raw key STRING -> BIGINT)
explicit any explicitly-castable scalar pair the deliberate opt-in, incl. lossy narrowing

Rules that hold at every mode:

  • Same-family differences (VARCHAR length, DECIMAL precision, nullability) are assigned as-is and enforced by the engine — only genuine cross-family mismatches are cast.
  • A nullable source into a NOT NULL sink column that also needs a cast is rejected (a cast can't add a null guard); matching types pass through regardless of nullability.
  • Complex ROW/ARRAY/MAP/MULTISET shape mismatches are always rejected.
  • An explicit user CAST/SAFE_CAST is respected and never double-wrapped; if its result type isn't assignable to the sink, we fail early with a targeted message.

Implementation

  • TypeCoercion (new): CastMode + decide(source, target, mode) classifier over Calcite's assignment/cast rules.
  • PipelineRel.Implementor.sql(): resolveCasts() validates the query against the sink and returns per-column cast targets, throwing SQLNonTransientException early on incompatibility.
  • ScriptImplementor: threads per-column cast targets into the sink-aligned forced projection; all prior insert() overloads are preserved (backward compatible).

Note on scope: this governs the CREATE MATERIALIZED VIEW path (where Hoptimator resolves the sink separately, so Calcite does not coerce — the original failure). Plain INSERT INTO <existing table> is already coerced by Calcite's validator, so castMode is effectively a no-op there.

Docs

  • docs/user-guide/hints.md — the castMode planner hint.
  • docs/user-guide/ddl-reference.md — "Type checking and casts on write".

Testing

  • UnitTypeCoercionTest (single test file per source file): exact scalar matrix (numeric widening/narrowing, character coercions, temporal, binary, boolean), complex-type matrix, and cartesian property tests (mode monotonicity strict subset-of assign subset-of explicit, reflexivity, complex-vs-scalar, nullability). ~1400 cases.
  • Integration — Quidem .id fixtures verified end-to-end against a live environment:
    • hoptimator-venice: venice-ddl-cast.id (assign injects CAST into a Venice keyed sink).
    • hoptimator-k8s: strict reject, user-CAST respected (no double-wrap), user-CAST-not-assignable error, matching-type/nullable passthrough, assign auto-cast + VARBINARY/nullable rejections, explicit-only casts.

No golden-fixture changes for existing pipelines; matching-type passthroughs are unaffected.

@github-actions

github-actions Bot commented Sep 4, 2026

Copy link
Copy Markdown

Code Coverage

Overall Project 84.89% -0.07% 🟢
Files changed 94.94% 🟢

File Coverage
TypeCoercion.java 98.76% -1.24% 🟢
ScriptImplementor.java 96.54% -1.66% 🟢
PipelineRel.java 96.47% -0.34% 🟢

jogrogan and others added 4 commits September 4, 2026 12:02
When a materialized view or INSERT writes into a sink with a declared
schema, Hoptimator previously deferred all query-vs-sink type checking to
the Flink job, so a mismatch (e.g. a raw Kafka primitive key exposed as
STRING projected onto a typed BIGINT key column) only failed at job
submission. This adds plan-time reconciliation in PipelineRel:

- New TypeCoercion classifier with a graded `castMode` hint
  (strict/assign/explicit, fail-closed). strict casts only cross-family
  assignment-compatible conversions; assign adds a character->scalar
  carve-out (covers STRING->BIGINT keys); explicit allows any
  explicitly-castable scalar pair.
- PipelineRel.resolveCasts compares each projected column to the sink
  column, injects an assignment CAST where allowed, respects an existing
  user CAST/SAFE_CAST (never double-wrapped), and raises a clear
  SQLNonTransientException up front for incompatible columns.
- Same-family differences (VARCHAR length, DECIMAL precision, nullability)
  are assigned as-is and enforced by the engine, matching a native INSERT;
  only genuine cross-family mismatches are cast. Complex ROW/ARRAY/MAP
  shape mismatches are always rejected.
- ScriptImplementor threads per-column cast targets into the sink-aligned
  forced projection; all prior insert() overloads are preserved.

Tests: TypeCoercionTest + PipelineRelCastTest cover the full matrix
(safe/unsafe per mode, user cast, nullability, precision, complex types).
Integration Quidem fixtures for Venice and k8s assert both the injected
CAST and the early error. Docs updated (hints, ddl-reference).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The hoptimator-k8s intTest runtime only has the demodb and mysql drivers,
so the earlier k8s cast fixtures (which referenced VENICE stores) failed
with "Cannot create JDBC driver" for jdbc:venice. Rebase them onto the
in-memory demodb ADS/PROFILE schema:

- k8s-cast.id: strict mode rejects a VARCHAR (profile.members.member_urn)
  projected onto a BIGINT sink column (a created ads table), asserted via
  !error.
- k8s-cast-assign.id: castMode=assign injects CAST(member_urn AS BIGINT)
  into the generated Flink INSERT, asserted via !specify.

Also fix a corrupted venice-ddl-cast.id that had duplicated/garbled blocks
(backticks in the input SQL caused a lexical parse error); reduce it to the
single verified assign-cast case.

All three fixtures verified end-to-end against a live integration
environment (hoptimator-k8s:intTest and hoptimator-venice:intTest green).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Calcite's non-coercive canCastFrom implements implicit-assignment
semantics: it permits lossless widening (INTEGER->BIGINT, INTEGER->DOUBLE)
but rejects lossy narrowing (BIGINT->INTEGER, DOUBLE->INTEGER,
DECIMAL->INTEGER). So under strict and assign a narrowing is an early
error; only explicit opts into it.

Add TypeCoercionTest cases locking this in, and correct the hints /
ddl-reference docs (and the EXPLICIT javadoc) which had implied strict was
purely same-family.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Unit: fold a comprehensive parametrized matrix into TypeCoercionTest
(single test file per source file). Covers an exact scalar matrix
(numeric widening/narrowing, character coercions, temporal, binary,
boolean), a complex-type matrix (ARRAY/MAP/MULTISET/ROW), and property
tests over the full cartesian product of a rich type list: mode
monotonicity (strict subset of assign subset of explicit), reflexivity,
complex-vs-scalar incompatibility, and nullability.

Integration (hoptimator-k8s, demodb + created typed sinks): strict rejects
a VARCHAR->BIGINT projection; a user CAST is respected with no double-wrap;
a user CAST whose result is not assignable to the sink errors; matching
types (incl. nullable source into a NOT NULL sink) pass through with no
cast; assign injects the cast and still rejects VARCHAR->VARBINARY and
nullable->NOT NULL; explicit performs the VARBINARY cast assign refuses and
reports the explicit-cast-not-assignable case. All verified end-to-end
against a live environment.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@jogrogan
jogrogan force-pushed the jogrogan/typeCoercion branch from dea3a93 to f4a0993 Compare September 4, 2026 16:02
jogrogan and others added 3 commits September 4, 2026 12:07
Test 9's guard MV projected KAFKA VALUE (BINARY) onto the BIGINT memberId
sink column purely to establish a pipeline dependency. That mapping was a
latent bug — it would have failed at Flink — and is now caught up front by
the new plan-time type validation. Map into type-compatible sink columns
(KEY -> KEY, KEY -> pageKey, both VARCHAR) instead; the dependency the test
exercises is on the sink store and is unaffected by which columns are
projected.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace the shallow equalSansNullability check with a recursive
structurallyEqualSansNullability comparator so nested structs,
arrays, multisets, and maps are compared for structural equality at
every level while ignoring nullability throughout. A nested field
that differs only in nullability is now a pass-through (matching the
scalar rule and how the engine enforces NOT NULL at runtime), while
nested shape, field-name, or base-type mismatches remain errors.

Add 17 structural unit cases to TypeCoercionTest and 12 end-to-end
pipeline cases to PipelineRelCastTest covering array-of-struct,
map struct values/keys, deeply nested structs, recursive nested
nullability, and Avro-union-collapsed struct shapes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
!specify cast_asgn

create or replace materialized view ads."cast_asgn$to-binary" ("vb") as select member_urn AS "vb" from profile.members;
Incompatible types for sink column 'vb': query produces VARCHAR but sink expects VARBINARY (no safe conversion is available under castMode=assign).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

neat!

@jogrogan
jogrogan merged commit 864e23d into main Sep 4, 2026
1 check passed
@jogrogan
jogrogan deleted the jogrogan/typeCoercion branch September 4, 2026 18:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants