ID |
|
|---|---|
Status |
Spec |
Bucket |
architecture |
Priority |
7 |
Theme |
mutations-errors |
Type UPSERT dialect requirement on the model
UPSERT’s "PostgreSQL-only" requirement is currently expressed as a free-form
CodeBlockthreaded through a 9-argbuildDmlFetcheroverload. Lift it onto the model as typed data so the constraint is discoverable onMutationUpsertTableField, the verb-neutral skeleton stays verb-neutral, and the validator can eventually reject it at validate time once the consumer’s target dialect is known at codegen time.
Background lives in commit 181c28f ("R22: append architectural follow-ups
from post-shipping review"); this item carries that lift forward as standalone
work after R22’s stub-lift phases shipped.
Motivation
TypeFetcherGenerator.buildMutationUpsertFetcher builds a postDslGuard
CodeBlock inline (TypeFetcherGenerator.java:1577-1584):
var postDslGuard = CodeBlock.builder()
.beginControlFlow("if (dsl.dialect().name().startsWith($S))", "ORACLE")
.addStatement("throw new $T($S)", UnsupportedOperationException.class,
"@mutation(typeName: UPSERT) is not supported on Oracle: ...")
.endControlFlow()
.build();
The shared buildDmlFetcher was given a 9-arg overload
(TypeFetcherGenerator.java:1642) to accept this CodeBlock as a generic
pre-DSL guard. UPSERT is the only caller; INSERT, UPDATE, and DELETE pass
through the 8-arg overload and supply nothing.
Two smells:
-
Discoverability. "UPSERT only emits valid SQL on PostgreSQL" is an architectural fact about
MutationUpsertTableField. Today it is expressed only as runtime-emitted Java that performs a string-prefix check ondsl.dialect().name(). A reader of the model cannot see the constraint; the shared skeleton grew an open-ended escape hatch that any future verb can fill with arbitrary code, blunting the verb-neutral promise. -
Validator can’t see it. Per Validator mirrors classifier invariants, classifier decisions that imply runtime failure modes should surface at validate time. The current shape can’t: a free-form
CodeBlockcarries no semantic information the validator can read.
Design
Two new model types under no.sikt.graphitron.rewrite.model:
public sealed interface DialectRequirement
permits DialectRequirement.None, DialectRequirement.RequiresFamily {
record None() implements DialectRequirement {
public static final None INSTANCE = new None();
}
record RequiresFamily(SqlDialectFamily family, String reason)
implements DialectRequirement {}
}
public enum SqlDialectFamily {
POSTGRES, ORACLE, MYSQL, MSSQL, H2, SQLITE, OTHER;
/**
* Maps a jOOQ {@code SQLDialect.name()} to a graphitron dialect family.
* Name-prefix-based to cover commercial-only dialect enum values that
* aren't present in the OSS jOOQ distribution (e.g. ORACLE12C,
* ORACLE19C). Generated code consults this at request time via the
* dispatched {@code dsl.dialect().name()} string.
*/
public static SqlDialectFamily fromDialectName(String name) { ... }
}
Sealed-with-None (rather than Optional<DialectRequirement>) because:
-
The principle "Sealed hierarchies over enums for typed information" prefers named arms over presence-or-absence.
-
Future arms (
RejectsFamily,RequiresAnyOf(Set<SqlDialectFamily>)) can land without touching every consumer. -
Pattern-matching reads tighter than a chain of
Optional.ifPresent.
SqlDialectFamily is a graphitron-internal enum (not jOOQ’s SQLDialect)
because the OSS jOOQ distribution omits commercial-only values like
ORACLE19C. The mapping function lives at the boundary, same shape as the
inline name-prefix check today.
MutationField.DmlTableField gains the requirement on the sealed supertype:
sealed interface DmlTableField extends MutationField {
DmlReturnExpression returnExpression();
ArgumentRef.InputTypeArg.TableInputArg tableInputArg();
DialectRequirement dialectRequirement(); // never null
SourceLocation location();
}
Each of the four DML records gains a DialectRequirement dialectRequirement
component. The classifier (FieldBuilder.buildDmlField) populates:
-
INSERT/DELETE:
DialectRequirement.None.INSTANCE. -
UPDATE:
DialectRequirement.None.INSTANCEfor single-row; if R77 (bulk DML) has shipped, the bulk arm carriesRequiresFamily(SqlDialectFamily.POSTGRES, "…UPDATE…FROM (VALUES) …")because jOOQ silently emulatesUPDATE…FROMon non-Postgres dialects with semantics drift. The classifier readstia.list()to pick. -
UPSERT:
new DialectRequirement.RequiresFamily(SqlDialectFamily.POSTGRES, "…UPSERT…MERGE INTO…"). The reason string carries the actionable message that today is hardcoded in the inlineCodeBlock.
If R77 ships first, the inline postDslGuard CodeBlock lives on both
UPSERT and bulk-UPDATE call sites until R63 lifts both at once. The
9-arg buildDmlFetcher overload deletion in this plan covers both.
Emitter rewrite
buildDmlFetcher consults f.dialectRequirement() and renders the runtime
guard via a single helper. The 9-arg postDslGuard overload deletes:
private static MethodSpec buildDmlFetcher(
String fetcherName,
DmlReturnExpression rex,
Optional<ErrorChannel> errorChannel,
String inputArgName,
TableRef tableRef,
ResolvedTableNames tablesOnly,
String tableLocal,
String outputPackage,
CodeBlock dmlChain,
DialectRequirement dialectRequirement) {
// ...
builder.addStatement("$T dsl = graphitronContext(env).getDslContext(env)", DSL);
emitDialectGuard(builder, dialectRequirement);
// ...
}
private static void emitDialectGuard(MethodSpec.Builder b, DialectRequirement req) {
switch (req) {
case DialectRequirement.None ignored -> { /* no-op */ }
case DialectRequirement.RequiresFamily r -> {
b.beginControlFlow("if ($T.fromDialectName(dsl.dialect().name()) != $T.$L)",
SqlDialectFamily.class, SqlDialectFamily.class, r.family().name())
.addStatement("throw new $T($S)",
UnsupportedOperationException.class, r.reason())
.endControlFlow();
}
}
}
Verb-neutral skeleton stays verb-neutral; UPSERT no longer needs special-case
handling at the call site. INSERT/UPDATE/DELETE pass DialectRequirement.None
and the helper emits nothing.
The runtime check on the consumer side runs through `SqlDialectFamily’s mapping, not a string-prefix check, which is the same logic but lives once at the boundary instead of being inlined into emitted Java.
Tests
Pure model refactor, not a behaviour change. Acceptance gates:
-
Existing execution-tier PostgreSQL tests against UPSERT pass unchanged (
upsertFilm_updateBranch_writesAndReturnsProjectedFilm,upsertFilm_insertBranch_writesAndReturnsProjectedFilm). The emitted guard body changes (now consultsSqlDialectFamily.fromDialectName), but the PostgreSQL path passes through the no-op arm exactly as it does today. -
New unit-tier assertion:
MutationUpsertTableField.dialectRequirement()returnsRequiresFamily(POSTGRES, …); the other three DML records returnNone.INSTANCE. -
New unit-tier assertion on
SqlDialectFamily.fromDialectName: coversPOSTGRESfor anyPOSTGRES*name,ORACLEforORACLE,ORACLE12C,ORACLE19C,ORACLE23AI, etc.,OTHERfor unrecognised names. -
New compilation-tier test: a generated UPSERT fetcher’s body throws
UnsupportedOperationExceptionwith the message from the model’sreason()slot when invoked under an Oracle-flavouredDSLContext. Either via a Testcontainers Oracle instance, which is heavy, or via a stubDSLContextthat reportsdialect().name() == "ORACLE19C"; the latter is cheaper and the existing fixture path doesn’t include Oracle.
Implementation sites
-
New file
model/DialectRequirement.java: sealed interface, two arms. -
New file
model/SqlDialectFamily.java: enum +fromDialectNamemapping. -
model/MutationField.java:DmlTableFieldinterface gainsdialectRequirement(); each of the four DML records gains the component. -
FieldBuilder.buildDmlField: populateDialectRequirement.None.INSTANCEby default,RequiresFamily(POSTGRES, …)for UPSERT and (if R77 has shipped) for bulk UPDATE. -
TypeFetcherGenerator: -
Delete the 9-arg
buildDmlFetcheroverload. -
Add
emitDialectGuardprivate helper. -
buildDmlFetcher(single overload now) callsemitDialectGuard. -
buildMutationUpsertFetcherdeletes the inlinepostDslGuardCodeBlock; the call site shrinks to the same shape asbuildMutationInsertFetcheretc. -
If R77 has shipped,
buildMutationUpdateFetcher’s bulk arm deletes its inline `postDslGuardsymmetrically. -
MappingsConstantNameDedupand any other site that rebuilds DML records: thread the new component through.
Future evolution (out of scope)
-
Validator-time rejection. When the consumer’s configured target dialect becomes known at codegen time (a separate plan; today the dialect is a request-time fact), the validator can fail the build per Validator mirrors classifier invariants. Until that lands, the runtime guard remains, but it is rendered from typed model data rather than from a hand-built
CodeBlock. -
Lifting the Oracle restriction. A separate plan could hand-emit a PostgreSQL-equivalent
MERGE INTOstatement on Oracle (rather than relying on jOOQ’s silent translation) when the dialect family is Oracle. The model shape introduced here (DialectRequirement) doesn’t gate that work; it just typifies today’s contract.
Non-goals
-
Cross-dialect abstraction over
RETURNINGandON CONFLICT. Graphitron targets PostgreSQL; the rewrite does not introduce a dialect translation layer. -
Reusing
DialectRequirementoutsideDmlTableField. The slot is added toDmlTableFieldonly; promote it to a wider field-set if a secondDialectRequirement-bearing field type appears.