ID |
|
|---|---|
Status |
Spec |
Created |
2026-05-26 |
Updated |
2026-05-26 |
DML payload positional input/output alignment
Payload-returning bulk DML carriers (DELETE / INSERT / UPDATE / UPSERT) must
emit data-field lists that are positionally aligned with the mutation’s input
list: input index i maps to output index i, and positions where no row
was produced must be representable as null (DELETE: the row didn’t exist /
wasn’t deleted; INSERT/UPDATE/UPSERT: the corresponding "no result for this
input" case, exact taxonomy to be settled in Spec). Today the DELETE Id-arm
emitter (FetcherEmitter.buildSingleRecordIdFromReturningFetcherValue, lines
561-610) iterates the DELETE … RETURNING Result<Record> directly and
appends one entry per RETURNING row, so a miss simply shortens the output
list, with no positional correspondence to the input. The classifier
compounds the mismatch by rejecting the [ID] (list-of-nullable) wrapper
that this contract requires and admitting only [ID!] / [ID!]!
(BuildContext.java:680-699); the diagnostic that pins the wrong contract
("every element of a successful DELETE response is the encoded PK of an
actually-deleted row, so the slot cannot be null") is the surface symptom
that originally surfaced this bug. The same gap exists on the DELETE
Table-arm synthesized-Record path and across all four DML verbs.
RLS reframes the design: even on verbs where the SQL succeeds for every input
row, a row-level-security policy can filter the row out of RETURNING. So
positional null is not just the "DELETE PK didn’t exist" case; it can
happen on any verb whenever the policy hides the produced row from the
caller’s role. The data-field wrapper rule has to admit this for all four
verbs.
Contract
Across DELETE / INSERT / UPDATE / UPSERT, on both payload-carrier arms
(Id and Table) and both producer kinds (DML and @service):
-
Positional 1:1 with the input list. Output index
icorresponds to input indexi. The output list size equals the input list size; no silent shortening. -
Nullable elements only. List wrappers admit
[Type]and[ID]only.[Type!]and[ID!]reject at classify time with a diagnostic pointing at the nullable form. SingletonType/Type!andID/ID!continue to admit on the singleton verbs; an RLS-filtered single row surfaces asnull, andType!bubbles to the field per standard GraphQL semantics (intended, not a footgun). -
Missing slot semantics. A
nullat positionimeans "the database did not produce a row for this input." That can be: the targeted row did not exist (DELETE-by-PK, UPDATE-by-PK), or the produced row was filtered fromRETURNINGby RLS (any verb). The contract is binary, not reason-coded; callers who need reasons compose with the R12 errors channel (out of scope for R242).
Per-verb failure-model summary
| Verb | Without RLS | With RLS | Wrapper admitted |
|---|---|---|---|
DELETE-by-PK |
miss when PK absent |
+ RLS filter |
|
UPDATE-by-PK |
miss when PK absent |
+ RLS filter |
|
INSERT |
all-or-nothing (txn rollback) |
RLS filter only |
|
UPSERT |
all-or-nothing |
RLS filter only |
|
UPDATE-by-PK against a missing row is symmetric to DELETE-by-PK: the
position renders null, not an error. Per-row failure reasons (RLS vs.
missing-row vs. constraint) belong on the R12 errors channel; R242 only
guarantees the slot is representable.
Identity-match strategy
Preferred mechanism for every shape: VALUES (idx, key1, key2, …) derived
table joined against the verb’s RETURNING result, with ORDER BY idx on
the outer select. This is the federation _entities dispatch pattern
adapted to DML; idx is the positional scatter key, the key* columns are
whatever identifies an input row to its produced row.
Per-verb identity column(s):
-
DELETE-by-PK: input carries PK →
LEFT JOIN del ON del.<pk> = input.<pk>. -
UPDATE-by-PK: input carries PK →
LEFT JOIN upd ON upd.<pk> = input.<pk>. -
UPSERT: input necessarily carries the conflict UK (otherwise
ON CONFLICThas no target) →LEFT JOIN ups ON ups.<uk> = input.<uk>. -
INSERT, input carries PK or UK:
LEFT JOIN ins ON ins.<key> = input.<key>. -
INSERT, server-generated PK with no client-side identity:
VALUES-JOIN does not work because SQL has no way to carryidxfromINSERT … SELECTsource rows into theRETURNINGoutput. Fall back to per-row INSERT executed as a single JDBC batch (PreparedStatement.addBatch
executeBatchwithRETURN_GENERATED_KEYS), inside one transaction. The i-thaddBatchslot scatters to output positioniby construction.
The dispatch ("does this INSERT have client-side identity?") is a
classify-time decision against the @input resolution: if the input record
carries any column that is a PK or part of a UNIQUE index on the target
table, take the single-statement + VALUES-JOIN path; otherwise per-row
batched. Multi-row INSERT order-preservation is not assumed even in the
non-RLS happy path; PostgreSQL preserves it in practice, but the SQL spec
does not promise it, and the carrier contract is too load-bearing to rest
on de-facto behavior.
@service producer alignment
A @service-backed mutation returns List<XRecord> by its own
implementation. Graphitron cannot reach inside to enforce positional
alignment, so the emitted fetcher wraps the returned list with a runtime
size-check against the input list size; mismatch throws an
IllegalStateException naming the producer method, the expected size, and
the actual size. Documented in the directive reference as "the service must
return one record per input element in input order; positions where no row
was produced must be null."
Implementation phases
-
Model. Lift the wrapper-shape admission rule into a shared
DmlPayloadListWrapperpredicate inBuildContext, used by every element-arm classifier. Replace the[ID!]admit /[ID]reject pair atBuildContext.java:680-699with the inverted rule; mirror the same admission on theTablearm (DataElement.Table) and on non-DELETE DML kind classifiers. Diagnostic wording converges to one helper. -
DELETE Id arm emit rewrite. Replace
FetcherEmitter.buildSingleRecordIdFromReturningFetcherValue(lines 561-610) with theVALUES-JOIN scatter emission. Source becomes the pre-built result of the join (an orderedResult<Record>where each row carries idx + encoded PK columns or all-nulls for misses); the fetcher reads PK columns nullable and emitsnullat any all-null slot. Encoder is invoked only on non-null slots. -
DELETE Table arm emit rewrite. Same
VALUES-JOIN substrate forbuildSingleRecordTableFromReturningFetcherValue; the synthesized per-rowRecordisnullat miss positions; per-fieldColumnFetcher`s already null-propagate, so no per-field changes are needed beyond admitting `nullsource rows. -
UPDATE / UPSERT verb-arm admission. Lift the carrier-shape permits from
MutationUpdateTableField/MutationUpsertTableField(and their bulk siblings) to admit payload-returning carriers symmetric to the DELETE permits R156 introduced. Identity column is the input PK (UPDATE) or the conflict UK (UPSERT) discovered from@upsertWith/ the input-resolution machinery. -
INSERT verb-arm admission and dispatch. Lift carrier-shape permits for
MutationInsertTableField/MutationBulkInsertTableField. At classify time, examine the input’s resolved column set: if any PK/UNIQUE column is supplied, classify asInsertIdentityKind.ClientSupplied(keyColumns); otherwiseInsertIdentityKind.ServerGenerated. The two kinds dispatch to two sibling emitter helpers:buildBulkInsertScatterFetcher(single statement +VALUES-JOIN) andbuildBulkInsertPerRowBatchedFetcher(JDBCaddBatch+ scatter by batch slot). -
@servicesize-check wrapper. NewFetcherEmitter.wrapServiceProducerForPositionalhelper emitted at the service-producer call site; takes the producer’sList<XRecord>(orResult<…>) result and the input size, validates equality, propagatesnullentries unchanged. -
Tests (every tier; rules of
rewrite-design-principles.adoc):-
Unit: wrapper-shape admission/rejection across all four verbs and both arms;
VALUES-JOIN SQL emission shape pinned;idx-keyed scatter result shape pinned;@servicesize-check exception payload pinned. -
Pipeline: per-verb classifier admission cells with
[Type]/[Type!]/Type!/ wrong-element-type matrices;InsertIdentityKinddispatch coverage (PK-in-input, UK-in-input, neither);@serviceproducer registration with the new wrapper. -
Compilation:
graphitron-sakila-exampleadds one payload-carrier mutation per verb (Film for DELETE / UPDATE / INSERT-with-PK, Actor for UPSERT, and one INSERT case on a table with a SERIAL PK and no other identity to exercise the per-row batched path). -
Execution: native-Postgres end-to-end per verb:
-
input-order preservation through
VALUES-JOIN. -
positional
nullat the miss slot (DELETE/UPDATE missing PK). -
RLS-filtered row renders
nullat its slot (one execution test creates an RLS policy that hides a specific row from the test role and asserts the slot isnull, others are populated). -
per-row batched INSERT preserves order; failure on any row rolls back the whole batch and surfaces a clear exception.
-
@servicereturning wrong-size list throws the documentedIllegalStateExceptionwith the producer method name in the message.
-
Out of scope (called out, not regressed)
-
[Type!]/[ID!]opt-in admission (e.g. on tables with no RLS policy). May be revisited as a separate Backlog item if a concrete user case emerges; until then the wrapper rule stays uniformly nullable. -
ON CONFLICT DO NOTHINGsemantics on INSERT (currently not expressible on the model; will be a separate Backlog item when added). When it lands, the conflict UK is in the input by construction and the existingVALUES-JOIN path covers it; R242 leaves the door open. -
The R12 errors-channel composition for per-row reason codes. R242 makes the slot
null-representable; R12 already composes structurally with the carrier types. -
Dialect-capability gating for
RETURNING. The existingdml-dialect-requirement-on-model(R63) work owns that surface; the per-row INSERT fallback in step 5 is dialect-neutral. -
Cross-arm consolidation of the
SingleRecordTableFieldFromReturningandSingleRecordIdFieldFromReturningpermits. R242 reshapes both emitters but does not collapse them; that’s a separable cleanup.
Cross-references
-
R156 introduced the DELETE payload-returning carrier and the PK-echo-of-actually-deleted-rows semantics that R242 revises. Both emitter methods this Spec rewrites (
buildSingleRecordIdFromReturningFetcherValue,buildSingleRecordTableFromReturningFetcherValue) were introduced there. -
R141 is the closest existing input-order-preservation pattern (PK-keyed-map walk); R242 generalizes to
VALUES-JOIN so all four verbs can share one mechanism, and soidxcan be the scatter key on INSERT where the input has no PK. -
R158 admitted
@service-backed producers on the single-record carrier data field; R242 adds the runtime size-check at every@serviceproducer call site emitted on a payload-returning carrier. -
R12 is the errors-channel producer; R242 keeps it orthogonal, ensuring the wrapper-shape rules do not preclude composition.
-
R63 (
dml-dialect-requirement-on-model) owns dialect-capability gating; R242 stays on Postgres-only execution-tier coverage today and does not duplicate that surface.