ID |
|
|---|---|
Status |
Spec |
Bucket |
structural |
Priority |
4 |
Theme |
structural-refactor |
Blocked by |
methodcall-walker-carrier, simplify-update-mutations-drop-value |
Created |
2026-05-27 |
Updated |
2026-05-27 |
UpdateRows walker carrier (R222 UPDATE slice) with PK-or-UK identification
The slice lands R222’s walker-carrier pattern on @mutation(typeName: UPDATE). One permit migrates: MutationUpdateTableField. It loses no record components in this slice (the existing tableInputArg stays for now as the substrate the walker reads from) and gains UpdateRows updateRows, populated by a producer (UpdateRowsWalker) that reads the field’s SDL definition plus the input @table’s jOOQ catalog metadata directly. The fetcher emitter `buildMutationUpdateFetcher reads the carrier through a new UpdateRowsEmitter for its WHERE clause and SET map; the bulk arm (buildBulkUpdateFetcher) reads the carrier through the same emitter. The PK-or-UK identification is the slice’s load-bearing claim: the walker queries org.jooq.Table.getPrimaryKey() and getKeys(), finds the narrowest unique key the input’s column set covers, and writes that key’s identity into the carrier. No PK and no UK is covered ā WalkerResult.Err with a typed Structural.NoUniqueKeyCoverage arm.
The slice covers only the non-multiRow UPDATE path. multiRow: true UPDATE fields stay on the legacy TableInputArg.lookupKeyFields() / setFields() path until a follow-up slice adds the Broadcast arm to UpdateRows. The walker only fires when @mutation(multiRow:) is absent or false; the carrier’s slot on MutationUpdateTableField is Optional<UpdateRows> (present iff the walker fired) for the duration of this slice, collapsing to non-Optional once the Broadcast arm lands and the legacy path retires.
The slice inherits the foundation-slice plumbing R238 ships: WalkerResult<C>, the AuthorError.Structural sealed sub-arm pattern, the LSP Diagnostic wire conventions (code namespace graphitron.update-rows.<leaf>, source: "graphitron", severity Error, primary SourceLocation on the field’s own SDL location), and the orchestrator’s collect-Err-exclude-field flow that writes typed walker errors into ValidationReport.walkerDiagnostics.
Target emitted code
The reducer backtracks from this shape. The slice’s emitted code is structurally identical to today’s buildMutationUpdateFetcher output; the rewrite is in where the WHERE / SET partition and the matched-key identity come from (carrier instead of tia.lookupKeyFields() / tia.setFields()).
// MutationUpdateTableField example, post-slice (single-row arm)
DSLContext dsl = graphitronContext(env).getDslContext(env);
// decode locals for NodeId-bound key columns (unchanged shape)
// ...
LinkedHashMap<Field<?>, Object> sets = new LinkedHashMap<>();
// SET puts driven by carrier.setFields()
sets.put(FILM.TITLE, in.get("title"));
sets.put(FILM.DESCRIPTION, in.get("description"));
if (sets.isEmpty()) {
throw new IllegalArgumentException("@mutation(typeName: UPDATE) ...");
}
Record1<Long> result = dsl
.update(FILM)
.set(sets)
.where(FILM.FILM_ID.eq(__filmIdDecoded)) // WHERE from carrier.matchedKey
.returningResult(FILM.FILM_ID)
.fetchOne();
The bulk-arm shape (UPDATE t SET c = v.c FROM (VALUES ā¦) AS v(k, cā¦) WHERE t.k = v.k) is unchanged in emit, with the same carrier-driven substitution: per-row v cells and the join-on-key predicate read the carrier’s matchedKey columns instead of tia.fieldBindings() projected by @lookupKey presence.
Carrier shape
One sealed family lands in graphitron/src/main/java/no/sikt/graphitron/rewrite/model/: UpdateRows.
// model/UpdateRows.java
public sealed interface UpdateRows permits Identified {
MatchedKey matchedKey();
List<InputField.SetField> setFields();
List<InputColumnBindingGroup> keyBindings();
}
public record Identified(
MatchedKey matchedKey,
List<InputField.SetField> setFields,
List<InputColumnBindingGroup> keyBindings
) implements UpdateRows {
public Identified {
if (matchedKey == null) throw new IllegalArgumentException("matchedKey required");
setFields = List.copyOf(setFields);
keyBindings = List.copyOf(keyBindings);
if (setFields.isEmpty()) {
throw new IllegalArgumentException(
"Identified.setFields cannot be empty; the walker rejects empty-SET inputs "
+ "with Structural.NoSetFields before constructing the carrier");
}
}
}
The Identified arm is the only valid one in this slice. Broadcast is a future-slice addition (gated by multiRow: true), kept off the sealed permits clause until then so consumers' exhaustive switches don’t need a placeholder arm. The compact-ctor invariants make the slot’s promises load-bearing on the type system, not on classifier conventions.
MatchedKey carries the key identity from jOOQ:
// model/MatchedKey.java
public sealed interface MatchedKey permits PrimaryKey, UniqueKey {
List<ColumnRef> columns(); // ordered as declared on the key
String keyName(); // jOOQ Key.getName(), for diagnostics
}
public record PrimaryKey(List<ColumnRef> columns, String keyName) implements MatchedKey {}
public record UniqueKey(List<ColumnRef> columns, String keyName) implements MatchedKey {}
The split between PrimaryKey and UniqueKey is cosmetic for the WHERE-clause emitter (both produce the same equality conjunction), but the discriminator is load-bearing for the LSP diagnostic surface and for any future per-key-identity decision (e.g. RETURNING-column choice). MatchedKey.keyName() echoes jOOQ’s Key.getName() so an AmbiguousCoverage diagnostic can name the candidates by their catalog identity.
keyBindings is the existing InputColumnBindingGroup shape lifted onto the carrier from today’s tia.fieldBindings() ; the slice does not redesign the bindings; it just relocates them. InputField.SetField likewise carries over from R144’s partition. The walker’s job is to compute the partition (which input fields go into setFields, which contribute to keyBindings) by checking each field’s target columns against the catalog’s PK / UK column sets, replacing today’s TableInputArg.of(…) partition logic for the non-multiRow UPDATE case.
Slot landing on UpdateRowsField (narrow interface)
A new interface declares the slot:
// graphitron/src/main/java/no/sikt/graphitron/rewrite/model/UpdateRowsField.java
public interface UpdateRowsField {
/**
* Present iff the walker fired on this field ā i.e. {@code @mutation(typeName: UPDATE)} with
* {@code multiRow: false} (the default). {@code multiRow: true} UPDATE fields stay on the
* legacy {@link ArgumentRef.InputTypeArg.TableInputArg#lookupKeyFields()} /
* {@link ArgumentRef.InputTypeArg.TableInputArg#setFields()} path until a follow-up slice
* adds the {@code Broadcast} arm to {@link UpdateRows}. Slot collapses to non-Optional then.
*/
Optional<UpdateRows> updateRows();
}
MutationUpdateTableField implements UpdateRowsField and gains an Optional<UpdateRows> updateRows record component; the auto-generated accessor satisfies the interface contract:
record MutationUpdateTableField(
String parentTypeName,
String name,
SourceLocation location,
DmlReturnExpression returnExpression,
ArgumentRef.InputTypeArg.TableInputArg tableInputArg,
Optional<UpdateRows> updateRows,
Optional<ErrorChannel> errorChannel
) implements DmlTableField, UpdateRowsField { ... }
The slot is interface-required but Optional for the slice’s lifetime. Consumers reading the carrier go through UpdateRowsField.updateRows() and branch on the Optional; the populated case routes through UpdateRowsEmitter, the empty case routes through today’s tia.lookupKeyFields() / tia.setFields() walk.
Producer (UpdateRowsWalker)
// graphitron/src/main/java/no/sikt/graphitron/rewrite/walker/UpdateRowsWalker.java
public final class UpdateRowsWalker {
public WalkerResult<UpdateRows> walk(
GraphQLFieldDefinition field,
ArgumentRef.InputTypeArg.TableInputArg tableInputArg,
JooqCatalog catalog
);
}
Substrate: the field’s SDL definition (GraphQLFieldDefinition), the resolved TableInputArg (for the per-field carrier list), and the jOOQ catalog (for PK / UK metadata). The walker does not re-resolve the input’s @table ; the existing classifier already produced tableInputArg.inputTable().
Walker stages (one pass per non-multiRow UPDATE field):
-
Collect candidate keys from the catalog. Read
Table.getPrimaryKey()andTable.getKeys()(jOOQ’sgetKeys()returns the table’s unique keys, PK included). Project each to aMatchedKeycandidate, deduplicated on column set (aUniqueKeywhose columns equal the PK’s is the PK itself; jOOQ returns the PK ingetKeys()already). The slice walks them in jOOQ declaration order, preferring PK first, then unique keys. -
Compute each input field’s target columns on the input’s own table. Mirrors R188’s four-carrier rule (per field, collect
.column()/.columns()/.liftedSourceColumns()perColumnField/CompositeColumnField/ColumnReferenceField/CompositeColumnReferenceField). The result is a flatSet<ColumnRef>of input-covered columns. -
Find the narrowest matched key. Iterate the candidate keys; the first whose column set is a subset of the input-covered columns is the match. "Narrowest" is the deterministic tiebreaker for the diagnostic case where two unique keys both match (e.g. PK + an alternate UK that’s a subset of the input). PK wins ties by step 1’s ordering. The selection rule is stable and total under that ordering.
-
Partition the input fields into WHERE-contributing and SET. For each input field, if its target columns are all members of the matched key’s column set, it contributes to
keyBindings(via the existingEnumMappingResolver.buildLookupBindingsshape, restricted to the matched-key columns). Otherwise it lands insetFields. Mixed-membership fields (some columns in the matched key, some not) reject withStructural.MixedCarrierKeyMembershipnaming the field and per-column membership ; only possible onCompositeColumnReferenceFieldper R188’s analysis. -
Reject empty SET. If
setFieldsends up empty after step 4, reject withStructural.NoSetFields(table, matchedKey); UPDATE with nothing to set is structurally ill-formed regardless of whether the WHERE clause is well-pinned. (Today’s runtime check atTypeFetcherGenerator:2131-2135lifts to classify-time.) -
Return the result.
Ok(Identified(matchedKey, setFields, keyBindings), diagnostics)on success;Err(authorErrors, diagnostics)when any stage produced typed errors. The walker collects across stages.
The walker is invoked from FieldBuilder at the MutationUpdateTableField construction site, gated on tableInputArg.multiRow() == false. The producer doesn’t see fields where multiRow is true; those skip the walker and the slot remains Optional.empty().
Consumer migration
TypeFetcherGenerator.buildMutationUpdateFetcher branches on field.updateRows():
return field.updateRows()
.map(carrier -> UpdateRowsEmitter.emit(carrier, ctx, field, ...))
.orElseGet(() -> legacyBuildMutationUpdateFetcher(ctx, field, outputPackage));
Same shape in buildBulkUpdateFetcher. The legacy paths stay alive for multiRow: true UPDATE fields until a follow-up slice.
// graphitron/src/main/java/no/sikt/graphitron/rewrite/generators/UpdateRowsEmitter.java
public final class UpdateRowsEmitter {
public static MethodSpec emit(UpdateRows carrier, /* ctx, field, ... */);
}
The emitter dispatches on the UpdateRows arm. For Identified, it produces today’s single-row or bulk shape (gated on tia.list()), reading the WHERE column list from carrier.matchedKey().columns() instead of tia.fieldBindings()-projected-by-@lookupKey. The SET map walks carrier.setFields(). Decode locals (for NodeId-bound key columns) emit from carrier.keyBindings() via the existing emitLookupKeyDecodeLocals helper (the helper takes List<InputColumnBindingGroup>, which is exactly the carrier’s keyBindings shape).
buildLookupWhereSingleRow and buildUniformShapeGuard("UPDATE") stay in TypeFetcherGenerator as shared utilities both the carrier path and the legacy path consume.
Plumbing inherited from R238
WalkerResult<C>, AuthorError.Structural sealed sub-family, Diagnostic wire format, orchestrator collect-Err-exclude-field flow, ValidationReport.walkerDiagnostics ; all reused without modification. The slice adds new Structural.* arms to the sealed sub-family:
AuthorError arm |
LSP code |
|---|---|
|
|
|
|
|
|
Arm payloads:
-
NoUniqueKeyCoverage(table, inputColumns, candidateKeys);tableis the input’s@tablename,inputColumnsis the set of target columns the input contributes,candidateKeysis the list ofMatchedKeyrecords the walker considered. Message names the table and lists the candidate keys with their column shortfall (e.g. "PK requires {film_id} but input contributes {title, description}; UK 'film_uk_title' requires {title, language_id} but input contributes {title, description}"). -
NoSetFields(table, matchedKey);tableis the input’s@tablename,matchedKeyis the key the walker matched. Message: "UPDATE input has nothing to set; every input field contributes to matched key '<keyName>'." -
MixedCarrierKeyMembership(field, fieldColumnsInKey, fieldColumnsOutsideKey);fieldis the SDL field name, plus the per-column split.
Producer-side failure modes
| Source | Arm |
|---|---|
No PK and no UK has its column set covered by the input |
|
Every input field contributes only to the matched key (empty SET) |
|
A |
|
Input |
|
R188’s mutation-input.table-has-no-pk diagnostic shape collapses into the degenerate NoUniqueKeyCoverage case here.
Tests
Three tiers.
Unit (@UnitTier), one test class.
UpdateRowsWalkerTest parses a small SDL fragment, configures a fixture JooqCatalog with PK / UK metadata, calls walk, and asserts on the sealed result. Coverage: PK-only match (input covers PK exactly); PK match with extra columns (input covers PK plus non-key columns landing in SET); UK-only match (table has no PK or the PK isn’t input-covered, but a UK is); narrowest-key tiebreaker (input covers both PK and a UK that’s a subset; PK wins by declaration order); NoUniqueKeyCoverage rejection (input covers neither PK nor any UK); NoSetFields rejection (input covers PK exactly, no extra columns); MixedCarrierKeyMembership rejection (a CompositeColumnReferenceField with diagonal PK overlap); table-with-no-keys degenerate case (getPrimaryKey() == null and getKeys().isEmpty()); composite-PK match through CompositeColumnField (R130 NodeId decode); FK-reference carrier on a non-PK column lands in SET; FK-reference carrier on a PK column lands in keyBindings.
UpdateRowsEmitterTest is deferred to pipeline tier ; emitter output is structural code, and pipeline-tier carrier assertions already pin the carrier shape.
Pipeline (@PipelineTier): extend GraphitronSchemaBuilderTest with R246_* rows asserting the slot is populated for non-multiRow UPDATE fields and empty for multiRow: true UPDATE fields. Extend the existing UPDATE-classification cases with typed Structural.* arm assertions for the rejection cases (replacing today’s stringly-typed rejection-prose assertions on the equivalent shapes from R144 / R188). The @LoadBearingClassifierCheck audit keys carry over: mutation-input.where-columns-cover-pk extends to "covers-pk-or-uk" with R246’s slot as the structural witness.
Compilation / Execution (@CompilationTier / @ExecutionTier): graphitron-sakila-example provides the regression net. The migration is structurally invariant on observable behaviour for the non-multiRow UPDATE path; existing DmlMutationsExecutionTest / DmlBulkMutationsExecutionTest round-trips are the safety net. A new execution case lands for the UK-driven UPDATE shape: a sakila fixture input keyed on a unique column other than the PK (candidate: customer.email, which has a unique index in canonical sakila), exercising the MatchedKey.UniqueKey arm end-to-end.
What this absorbs
| Item | Absorption mode |
|---|---|
R146 ( |
Subsumed. The PK-or-UK coverage check this slice files for is R146’s design content, applied at the walker layer instead of bolted onto |
Dependencies and sequencing
-
R238 (foundation slice): provides
WalkerResult<C>,AuthorError.Structuralsealed sub-family, LSP wire conventions, orchestrator collect-Err flow. Hard prerequisite. -
R188 (PK-default partition): drops
@valueand partitions by PK membership in the legacy carrier shape. Hard prerequisite ; this slice’s walker reads target columns through the same four-carrier rule R188 establishes, and the legacy path the slice leaves intact formultiRow: truemust already be PK-driven so the two paths don’t disagree on partition semantics. If R188’s status changes after this slice ships, the changes thread through `UpdateRowsWalker’s step 2. -
R145 (UPSERT): untouched. UPSERT continues through `MutationInputResolver’s R144 deferral until R145 ships.
Out of scope
-
multiRow: trueUPDATE. The legacyTableInputArg.lookupKeyFields()/setFields()path handles these. A follow-up slice adds theBroadcastarm toUpdateRowsand retires the legacy path. -
DELETE. Same PK-or-UK question applies, but DELETE has its own walker carrier slice (TBD) ; keeping the slices vertical means one verb per slice.
-
INSERT / UPSERT. Different walker carriers per R222’s table (
InsertRows, future UPSERT slot). -
Per-row error correlation on the bulk arm. R12’s flat-error contract carries through unchanged.
-
tableInputArgretirement onMutationUpdateTableField. The slice leaves the record component in place so the legacy path stays available; retirement is the follow-up slice’s responsibility after theBroadcastarm lands.