Package io.confluent.kafka.schemaregistry.type.logical.constraint


package io.confluent.kafka.schemaregistry.type.logical.constraint
SQL CHECK → CEL translator. Takes a parsed SQL CHECK expression (postgres-derived subset) plus a ConstraintValidationContext and emits a CEL expression string suitable for evaluation by any cel-go / cel-java engine. The translator is purely text-emitting — it does not bundle a CEL evaluator.

Layered architecture

The translator runs three layers in order, designed so each layer catches a distinct class of error:

  1. Pre-emit hand-coded validators (ConstraintValidator) — SQL-semantic checks that CEL itself can't see (e.g. col IS NULL on a non-nullable column is dead code, col + 1 where col is a string is meant as concat in SQL even though CEL accepts string+string). These checks enforce SQL intent before emit.
  2. Emit (ConstraintEmitter, ConstraintFunctions, ConstraintPatterns) — cascade walkers turn the parse tree into CEL fragments. Type resolution lives in ConstraintResolver and is shared between validators and emitters.
  3. Post-emit strict cel-java type-check (ConstraintCelChecker, ConstraintTypeProvider) — every emitted CEL is run through cel-java's strict type-checker with the schema's actual column types declared (not dyn). Any type/overload mismatch the hand-coded validators missed becomes a parse-time ValidationException instead of a runtime CEL error downstream.

What each layer is responsible for

ConstraintValidator covers checks strict can't subsume:

  • Column-ref resolution (typos vs unknown bindings)
  • Literal NULL operands to NULL-rejecting funcs (GREATEST/LEAST/ COALESCE/NULLIF) — strict's NULL-branch unification is fuzzier
  • BETWEEN with NULL or IS NULL as a bound — emits valid CEL but flips semantics
  • Literal index bounds (negative indices, out-of-range)
  • Macro arity / lambda var name shape
  • Date/time literal format and numeric overflow
  • SQL intent on +/-/*/% (numeric only — strict accepts string+string as concat)
  • Cross-type equality (CEL accepts int == string as always-false; SQL rejects)
  • Ordering ops on non-orderable types (cel-java has less_bool, cel-go does not — restored for cross-engine portability)
  • IN-list element-type homogeneity (CEL accepts list<dyn>)

ConstraintCelChecker covers everything else — every type and overload error in the emitted CEL. Catches the long tail of bugs that hand-coded validators previously had to enumerate one-by-one (compound type errors through arithmetic, function-result types, has() argument shape, missing CEL overloads, etc.).

Cross-engine concerns

The strict checker uses Google cel-java (dev.cel) but the emitted CEL may be evaluated by cel-go in production. A few overloads differ between the two implementations:

  • cel-java has less_bool, cel-go does not — handled by the hand-coded orderable-type check
  • Format validators (isEmail, isHostname, etc.) and the cel-go StringsExt methods (upperAscii, substring, etc.) are declared in ConstraintCelChecker#commonDeclarations — production cel-go runtimes must register equivalents

Validation context

ConstraintValidationContext carries the column/field bindings and signals placement (column-level vs table-level CHECK). Placement determines emit shape:

  • Column-level: this is the field's value; col in SQL collapses to this in CEL
  • Table-level: this is the surrounding struct; col in SQL emits as this.col

The strict checker reads the placement from vctx and declares this accordingly: a primitive type for column-level, a synthetic struct type (ConstraintTypeProvider) for table-level.

Column-level skip-on-null contract

Column-level CHECKs are evaluated under a runtime contract: the CEL is not invoked when the column value is null/missing/unset. This rule applies uniformly across all formats (Avro nullable union, proto3 unset field, JSON missing property), giving column-level constraints format-portable semantics matching SQL CHECK's "TRUE or UNKNOWN passes" spec.

Two consequences for users:

  • No null-guarding required. Built-in functions and operators on the column (LENGTH(name) > 0, name.matches(...), name + 'x' = ..., etc.) are safe at column level — the runtime won't pass null and the operations can't crash on null inputs.
  • Null-aware tests on the bare column are dead code. <col> IS NULL / <col> IS NOT NULL and COALESCE(<col>, ...) at column level are rejected by the validator. Use NOT NULL at the schema level for nullability declarations, or move the test to a table-level CHECK where has(this.x) provides format-aware presence checks.

The rule applies only to the bare column reference at column level. Nested struct fields (<col>.field IS NULL), array indexing (<col>[0] IS NULL), and macro iter-vars (EVERY(<col>, t, t IS NULL)) keep their existing null-awareness — only the outermost this is guaranteed non-null.