ID |
|
|---|---|
Status |
Spec |
Theme |
mutation-write |
Created |
2026-05-26 |
Updated |
2026-06-15 |
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,
FetcherEmitter.java:944) 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:617-625, now scoped to CarrierFamily.DML); 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.
Substrate correction (R305). This item was originally drafted around a
single mechanism, a VALUES-join against each verb’s RETURNING result,
for all four verbs. That premise is wrong for the three verbs whose payload
is a @table: INSERT / UPDATE / UPSERT do not read their payload off
RETURNING, they re-fetch it with a follow-up SELECT outside the DML
transaction (SingleRecordTableField, ChildField.java:105-113). R305
(Ready) reclassifies that re-fetch as a source-keyed Lookup that already
emits the idx-ordered VALUES(idx, key)-join with ORDER BY input.idx, so
the input/output ordering half is delivered by R305. R242 therefore no
longer owns a RETURNING mechanism for those verbs; it owns the
positional-null layer on top of R305’s re-fetch Lookup, plus the two
arms that genuinely cannot re-fetch and so stay RETURNING-native: the
DELETE Id arm (the row is gone) and the server-generated-PK INSERT ID echo
(no client key exists to re-fetch on).
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 the result the
carrier reads (the re-fetch SELECT for INSERT / UPDATE / UPSERT, or
RETURNING for DELETE). 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 by RLS from the result the carrier reads (the re-fetch SELECT, orRETURNINGon DELETE; 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
The positional mechanism is an idx-ordered VALUES (idx, key1, key2, …)
derived table, ORDER BY input.idx on the outer select. This is the
federation _entities dispatch pattern: idx is the positional scatter
key, the key* columns identify an input row to its produced row. What
the VALUES table joins against differs by verb, and R305 already owns
one of the two substrates:
-
INSERT / UPDATE / UPSERT (
@tablepayload), join against the re-fetch SELECT. R305 already emits thisVALUES(idx, key)-join for the re-fetch Lookup (LookupValuesJoinEmitter.java:378-383;ORDER BY input.idxat:422), so R242 authors no fresh join here. The catch: R305’s join is an inner join driven by the catalog table (.from(table).join(input)), which preserves "same rows" but drops any input row whose produced row is missing (UPDATE-by-PK against an absent PK) or RLS-filtered. R242’s delta is to make the correspondence total: drive the join frominput(LEFT JOIN), read the projected columns nullable, emitnullat all-null slots, and assert output size equals input size. -
DELETE (
Idarm), join againstRETURNING. The row is gone, so there is no re-fetch SELECT to join against; the substrate staysRETURNING.LEFT JOINthe input-PKVALUEStable against theDELETE … RETURNINGresult,ORDER BY idx. This is the one arm where the original “VALUES`-join againstRETURNING” framing survives intact, replacing the current append-per-row iteration at `FetcherEmitter.java:944. -
INSERT, server-generated PK with no client-side identity. Neither path works: there is no client key to re-fetch on, and SQL cannot carry
idxfromINSERT … SELECTsource rows intoRETURNING. Fall back to per-row INSERT as a single JDBC batch (PreparedStatement.addBatch
executeBatchwithRETURN_GENERATED_KEYS), inside one transaction; the i-thaddBatchslot scatters to output positioniby construction.
Per-verb join key: DELETE-by-PK and UPDATE-by-PK key on the input <pk>;
UPSERT keys on the conflict <uk> (the input necessarily carries it, else
ON CONFLICT has no target); client-identity INSERT keys on the supplied
<pk> / <uk>.
The INSERT 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 re-fetch VALUES-join path; otherwise per-row batched.
Multi-row 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.
Design fork: where the LEFT JOIN null-padding lives
R305’s lookup emitter inner-joins, so every inline lookup currently
shortens on a missing key, not just DML payloads. Two ways to reach the
positional null, to settle with R305’s owner before implementing because
it decides whether R242 edits LookupValuesJoinEmitter or forks it:
-
Null-pad in the Lookup family. Flip
LookupValuesJoinEmitterto drive frominputwith aLEFT JOINand nullable reads, so a missing key rendersnullfor every lookup. Cleanest if a missing@lookupKeyresult should be a positionalnull(the federation_entitiescontract arguably already wants this), but it changes lookup behavior well beyond DML payloads. -
Null-pad only on the DML-payload carrier. Keep the shared inner-join emitter and add a payload-carrier-specific left-join variant. Narrower blast radius, at the cost of a second join shape in the lookup family.
Recommendation: option 1 if R305’s owner agrees the inner-join shortening is a latent lookup bug rather than intended; option 2 otherwise.
@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 (wrapper-shape admission). Lift the wrapper-shape admission rule into a shared
DmlPayloadListWrapperpredicate inBuildContext, used by every element-arm classifier. Invert the[ID]reject atBuildContext.java:617-625(today scoped toCarrierFamily.DML) so nullable wrappers admit; mirror the same admission on the@table-element arm and on the non-DELETE DML kind classifiers. Diagnostic wording converges to one helper. This is the classify-time half and is substrate-independent: the same whether the payload arrives via re-fetch (R305) orRETURNING(DELETE). -
DELETE Id arm emit rewrite (
RETURNING-native). Replace the append-per-row iteration inFetcherEmitter.buildSingleRecordIdFromReturningFetcherValue(FetcherEmitter.java:944) with aLEFT JOINof the input-PKVALUEStable against theDELETE … RETURNINGresult. Source becomes the pre-built orderedResult<Record>(each row carries idx + encoded PK columns, or all-nulls for a PK that matched no deleted row); the fetcher reads PK columns nullable and emitsnullat any all-null slot. Encoder is invoked only on non-null slots. This arm cannot route through R305’s re-fetch Lookup: the deleted row is gone, soRETURNINGis the only post-image. -
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.~~ Obsolete (R287): the DELETE →@tablecarrier andbuildSingleRecordTableFromReturningFetcherValuewere removed (DELETE cannot project a@table, the row is gone); only the Id arm (step 2) remains for DELETE. -
UPDATE / UPSERT null-padding on R305’s re-fetch Lookup. These verbs'
@tablepayloads already classify as re-fetch Lookups under R305, so theVALUES-join and ordering exist. R242’s work is the null-padding delta of the design fork above (left-join + nullable reads + size-equality assertion) plus the wrapper admission from step 1. Join key is the input PK (UPDATE) or the conflict UK (UPSERT), discovered from@upsertWith/ the input-resolution machinery; both are the same keys R305 already feeds the Lookup. -
INSERT admission and identity dispatch. At classify time, examine the input’s resolved column set: if any PK/UNIQUE column is supplied, classify as
InsertIdentityKind.ClientSupplied(keyColumns); otherwiseInsertIdentityKind.ServerGenerated.ClientSuppliedroutes through R305’s re-fetch Lookup keyed on the supplied identity, null-padded per the design fork (as UPDATE / UPSERT).ServerGeneratedcannot re-fetch (no client key) and cannot carryidxthroughINSERT … SELECT, so it routes tobuildBulkInsertPerRowBatchedFetcher(JDBCaddBatch
RETURN_GENERATED_KEYS, scatter by batch slot) for the ID echo. -
@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
development-principles.adoc):-
Unit: wrapper-shape admission/rejection across all four verbs and both element arms (
ID-element and@table-element); the DELETE-IdRETURNINGLEFT JOINemission shape pinned; the left-join null-pad delta over R305’s re-fetch join 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 the
VALUES-join (R305’s join for the re-fetch verbs; the DELETE-IdRETURNINGjoin for DELETE). -
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 R305’s re-fetchVALUES-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
SingleRecordIdFieldFromReturningpermit. (R287 removed the siblingSingleRecordTableFieldFromReturning: DELETE cannot project a@table, the row is gone after the statement.)
Cross-references
-
R305 (
collapse-singlerecordtablefield-into-recordtablefield, Ready) is the substrate this item now builds on. R305 reclassifies the INSERT / UPDATE / UPSERT@tablere-fetch as a source-keyed Lookup that emits the idx-orderedVALUES(idx, key)-join (LookupValuesJoinEmitter), delivering input/output ordering and preserving "same rows". R242 layers the missing-slotnull/ size-equality contract on top (the design fork above) and depends on R305 landing first; it does not re-emit aRETURNINGjoin for those three verbs. -
R156 introduced the DELETE payload-returning carrier and the PK-echo-of-actually-deleted-rows semantics that R242 revises.
buildSingleRecordIdFromReturningFetcherValuewas introduced there. (R287 removed the siblingbuildSingleRecordTableFromReturningFetcherValueand the DELETE →@tablecarrier; only the Id arm survives for DELETE.) -
R141 was the original input-order-preservation pattern (the PK-keyed-map Java re-walk); R305 supersedes it with the SQL
VALUES-join, and R242 inherits that substrate rather than the Java re-walk. The DELETE-Id arm still needs its ownRETURNINGVALUES-join (no re-fetch is possible once the row is deleted). -
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. -
R308 (
service-list-payload-arrival) builds the list-arrival (Source{Many})@servicecarrier arm on R305’s framework. R242’s@servicesize-check rides whichever carrier arm the producer lands on; the two items share R305’s substrate and do not otherwise overlap. -
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.