ID |
|
|---|---|
Status |
Backlog |
Bucket |
architecture |
Priority |
7 |
Theme |
mutations-errors |
Blocked by |
Multi-source input validation: SDL directives + DB CHECK + Jakarta on a unified rendered schema
R94 emits an internal Java record per SDL input type. R92 phase 3
attaches programmatic Jakarta ConstraintMapping entries to those
records derived from PostgreSQL CHECK constraints. R12 §5’s
pre-execution validator step runs against each input at the fetcher
boundary. Three pipes today; three different sources of truth for
"what does this input need to look like to be valid"; only one of
them (DB CHECK) is currently surfaced to consumers anywhere outside
of the runtime violation report.
This item lifts the validation contract to a first-class wire shape.
Three constraint sources merge into one per-(input-type, field)
constraint set. Two emit consumers walk the merged set: the
schema-class emitter applies SDL directives on the rendered
GraphQLInputObjectType, and R12 §5’s pre-step continues to use
programmatic ConstraintMapping (now populated from the merged set,
not just CHECK-derived). The frontend introspects the rendered schema
and reads the same constraints the runtime enforces.
Constraint sources
Three:
-
DB CHECK constraints ; per-column, recognized by R92’s
CheckRecognizerintoRecognized.{StringOneOf, NumericRange, LengthBound, RegexMatch, NotNullCheck}. Reachable from an SDL input field that maps to a column (via@field(name: …)on the input field; the table comes from the consuming field’s return type per R97). -
SDL validation directives on input fields ; directly declared by the schema author. We adopt a curated subset of
graphql-java-extended-validation's directive set, scoped to those that map 1:1 to Jakarta annotations. See Adopted directive set below. -
Jakarta annotations on consumer Layer 2 record components ; when a service signature has a parameter typed as a consumer’s own record (e.g.
record SubmissionMetadata(@NotNull Integer filmId, @Min(1) @Max(10) Integer rating)) and R94’s classifier produces aConstructedbinding from graphitron’s input components into that consumer record’s ctor params, the consumer’s Jakarta annotations on those ctor params flow back to the SDL input field that supplies them.
Per-input-type, no per-consumer override. If two services consuming the same input type need different validation, the schema author splits into two input types.
Architecture
DB CHECK (R92) ───────────┐
│
SDL @Size etc. (Phase 1) ─┼──> per-(InputType, field)
│ ConstraintSet (model carrier)
Jakarta on Layer 2 │ │
record components ────────┘ │
├──> rendered GraphQLInputObjectType
│ .field.appliedDirective(...)
│ (frontend introspection)
│
└──> programmatic ConstraintMapping
(R12 §5 pre-step at runtime)
The model carrier is a single ConstraintSet per input field, holding
a list of constraints from all three sources. The set is order-stable
(each constraint records its source ; Source.CHECK | Source.SDL |
Source.JAKARTA ; for LSP-side display; not part of the wire format).
Both emit consumers walk the same set: the schema emitter rendering
each constraint as a GraphQLAppliedDirective, the runtime emitter
producing one ConstraintMapping.type(…).field(…).constraint(…)
chain per constraint.
Conflict resolution: there is none
Each source contributes constraints; all of them apply at runtime and
all of them surface on the rendered schema. The runtime’s
validator.validate(…) accumulates violations across every
constraint that fires. A field carrying both @Range(1, 10) from
SDL and a stricter DB CHECK on rating ⇐ 5 produces violations from
both sources when rating = 7, both producing typed errors via R12 §5.
The frontend introspecting the rendered schema sees both directives
and may dedupe / intersect at form-builder time if it cares ; that’s
frontend concern, not graphitron’s.
This is intentionally simpler than detecting conflict and picking a winner. The strictest constraint always wins because it’s the one that produces a violation; no merging logic is needed. The cost is some redundancy at the wire layer (two directives expressing the same or overlapping rule) which we accept.
Adopted directive set
Adopt the subset of graphql-java-extended-validation’s directives
that map 1:1 to Jakarta annotations. The 1:1 mapping makes the
SDL→runtime translation mechanical; non-1:1 directives (e.g.
`@Email, whose validator semantics vary across implementations) are
deferred. Initial set:
| SDL directive | Jakarta annotation | Notes |
|---|---|---|
|
|
Strings, collections, arrays |
|
composed: |
Convenience over emitting two directives |
|
|
|
|
|
|
|
|
|
|
|
Strings only |
|
|
|
|
|
|
|
|
|
|
|
Booleans |
|
|
Deferred (case-by-case as production schemas surface a need):
@Email, @Past, @Future, @Digits, custom validators.
The directive declarations land in
graphitron/src/main/resources/no/sikt/graphitron/rewrite/schema/directives.graphqls
alongside the existing graphitron directives, scoped to
INPUT_FIELD_DEFINITION.
Layer 2 Jakarta-annotation flow
When R94’s classifier produces a ParamBinding.Constructed from
graphitron’s input components into a consumer record’s ctor params,
the classifier walks the consumer record’s components and surfaces
each Jakarta annotation onto the corresponding SDL input field’s
ConstraintSet. This is the rule: a consumer record participating
in a Layer 2 binding contributes its annotations to the SDL input
field’s constraint set, period. There’s no per-consumer override and
no opt-out.
The implication for schema authors: if you want different validation
for two consumers of the same SDL input type, declare two SDL input
types. R94’s seam cleanly supports this ; each input type produces
its own emitted graphitron record, and each has its own
ConstraintSet. Reuse-with-divergence is what the SDL author gives
up; they reuse without divergence, or they split.
What R92 phase 3 inherits
R92 phase 3 is the producer of the DB-CHECK source. Today its plan
emits programmatic ConstraintMapping directly. R98’s restructuring
inserts the merge step: R92 phase 3 contributes to the
per-(InputType, field) ConstraintSet rather than emitting
ConstraintMapping directly. The runtime emit fans out from the
ConstraintSet (one path producing ConstraintMapping, one path
producing rendered SDL directives). R92 phase 3’s existing plan body
should reference R98 once R98 is in Spec; the change to R92 is small
(swap the direct emit for a contribution-to-ConstraintSet).
Phasing
Three phases, each independently shippable.
Phase 1: SDL validation directives (parse + classify)
-
Declare the curated directive set in
directives.graphqls. -
Classifier reads applied directives on each SDL input field at classify time, producing a
Constraintper directive application withSource.SDL. -
New model carrier:
record ConstraintSet(List<Constraint>), attached per-(InputRecordShape, InputComponent) ; a sibling to R94’sInputComponent. -
Sealed
Constrainttaxonomy mirroring the adopted directive set (Constraint.Size,Constraint.Range,Constraint.Min, …, each carrying its parameters and itsSourceenum value). -
Pipeline-tier coverage: SDL with
@Size/@Range/ etc. → the classifiedInputTypecarries the matchingConstraintarms. -
No emit changes yet.
Acceptance: applied directives are parsed and classified into a
typed Constraint per arm; round-trip property-based tests confirm
parse correctness.
Phase 2: emit on rendered schema and runtime ConstraintMapping
-
Schema-class emitter (
InputTypeGenerator+<InputName>InputTypeemit) walks each input field’sConstraintSetand addswithAppliedDirective(GraphQLAppliedDirective.newDirective().name("Range") .argument(…))calls perConstraint. -
Runtime emitter (
GeneratedConstraintMappingGeneratorfrom R92, if it has shipped; otherwise its planned location) walks the sameConstraintSetand produces onemapping.type(InputRecord.class).field(componentName).constraint(…)chain perConstraint. The R92-derived constraints already contribute to the same set (after R92 phase 3 is restructured per the inheritance note above). -
Sealed-switch exhaustiveness on the
Constraintcarrier covers both consumers (rendered + runtime); adding a new arm forces a compile error in both emit sites. -
Pipeline-tier: SDL with
@Size→ emittedInputTyperegistration carries the directive application AND the runtimeConstraintMappingcarries the corresponding constraint. -
Execution-tier: a sakila mutation with an SDL-declared
@Rangeon its input field ; invalid input surfaces as a typedConstraintViolationvia R12 §5; valid input passes through unchanged.
Acceptance: end-to-end SDL @Range validation works; introspection
returns the directive on the rendered field.
Phase 3: Layer 2 Jakarta annotation flow
-
R94’s classifier extension: when producing a
ParamBinding.Constructed, walk the consumer record’s ctor params, read each parameter’s Jakarta annotations, and surface each as aConstraintwithSource.JAKARTAon the SDL input field’sConstraintSet. -
Mapping table: each Jakarta annotation type maps to its
Constraintsealed-arm equivalent (mirroring the adopted directive set’s reverse direction). Annotations outside the adopted set are surfaced asConstraint.UnsupportedJakarta(annotationType)with a build warning recommending the schema author either declare the equivalent on the SDL input or remove the unsupported annotation; no runtime behavior change beyond the warning. -
Pipeline-tier: SDL input + service signature with a
Constructedparameter whose ctor carries@Min(1)→ the SDL input field’sConstraintSetincludes aConstraint.Min(1, Source.JAKARTA), the rendered schema applies@Min(1), and the runtime validator enforces it. -
Execution-tier: a sakila fixture exercising the consumer-record-Jakarta path end-to-end.
Acceptance: a Jakarta annotation on a Layer 2 consumer record component "infects" the SDL input field’s directives and runtime validation; the rendered schema shows it.
Out of scope (deferred or separate roadmap items)
-
Conflict detection between sources. Both apply, the strictest wins at runtime by definition (any failing constraint produces a violation). No merger logic.
-
Source attribution on the wire. Whether a directive came from SDL, CHECK, or Jakarta is not part of the rendered directive application. LSP can surface the source on hover / IDE tooltip (separate, smaller item).
-
Per-consumer override. One
ConstraintSetper input type, full stop. Reuse-with-divergence is the schema author’s problem; they declare two input types. -
@Email,@Past,@Future, custom validators. Case-by-case as production schemas surface a need. -
Surfacing the union on the legacy generator’s rendered schema. The legacy generator is out of scope for AI work (per
CLAUDE.md); this item targets the rewrite only. -
Frontend tooling. What the frontend does with the rendered directives (form-builder, validation UI, etc.) is consumer concern. We render the directives; consumers introspect.
Tests
-
Unit-tier (Phase 1): parse-result tests over each adopted directive ; each SDL applied directive produces the expected
Constraintarm with the expected parameters. -
Pipeline-tier (Phases 2-3, primary signal): SDL → classified
ConstraintSet→ emittedInputTyperegistration with directive applications + emittedConstraintMappingchain. One pipeline test per source (SDL-only, CHECK-only, Jakarta-only) plus one combined. -
Compilation-tier (Phase 2-3): sakila compile picks up the new directive declarations and the emitted
ConstraintMappingcalls. -
Execution-tier (Phase 2): invalid input via SDL
@Rangesurfaces as a typedConstraintViolationend-to-end. -
Execution-tier (Phase 3): invalid input via Jakarta-on-Layer-2 surfaces as a typed
ConstraintViolationwith the same wire shape as the SDL-source path. -
Introspection-tier (new): a query against the rendered schema’s introspection endpoint returns the expected directive applications per input field. Pins the frontend contract.
Risk
-
Directive-name collisions with existing
graphql-java-extended-validationusers on the same schema. Mitigation: namespace check at directive registration; if a consumer already declares@Sizefor a different purpose, surface a build error naming both. -
Bean Validation provider drift. The Jakarta spec is stable, but Hibernate Validator’s interpretation of
@Patternetc. may differ subtly from other providers. Mitigation: pin Hibernate Validator 9.0.1 (already done); test the directive→annotation mapping against that version specifically. -
Layer 2 Jakarta inflow surprises schema authors ; a service-side annotation appearing on the rendered SDL input is a non-obvious flow. Mitigation: LSP hover surfaces the source per constraint (
Constraint.Sourceenum is in the model for this reason); doc the rule prominently in the@servicedocumentation.