ID |
|
|---|---|
Status |
Spec |
Bucket |
bug |
Priority |
5 |
Theme |
diagnostics |
Created |
2026-06-08 |
Updated |
2026-07-22 |
Scope and type the FK candidate hint on the record-FK and synthesis-miss surfaces
Residual follow-up carved out of R259 (fk-key-hint-scope-and-namespace, shipped). R259 made the FK-key "did you mean" candidate hint both scoped (to the FKs touching the path source table) and namespace-aware (rendered in the SQL-constraint or jOOQ Java-constant TABLE__CONSTRAINT namespace the author typed) on the primary surface, BuildContext.parsePathElement via fkCandidateNames (BuildContext.java:897). The sibling surface, BuildContext.unknownForeignKeyRejection (BuildContext.java:1009), reached from the @reference(key:) / @nodeId synthesis miss path (call sites :1115, :1347, :1376, :1889), got the namespace half in the R259 close (mirrors __ in the attempt) but is still global: its candidate set is the whole catalog (allForeignKeySqlNames() / allForeignKeyConstantNames()), not scoped to the structurally relevant FKs.
The asymmetry is why it was split off: fkCandidateNames had currentSourceSqlName in scope at the call site, whereas unknownForeignKeyRejection(String fkName) receives only the FK name. Scoping it means threading a source table (the enclosing @reference path-origin or FK-owning table) through those four-plus call sites so the candidate set can be narrowed via JooqCatalog.foreignKeysTouchingTable(...) (the helper R259 already added), falling back to the global list when no source table is in scope. Out of scope, as in R259: the LSP completion/hover arms and the FK-resolution logic itself (only the failure message’s candidate list is at issue).
Spec findings: the item’s premise is inverted
Two facts surfaced while reading the current code (line anchors are as of this spec; symbols are the stable reference):
-
The sibling surface is defensive-only, not an author-typo surface. Every call site of
unknownForeignKeyRejection(four inBuildContext: the no-directive single-FK synthesis arm inparsePath, the{key:}and{table:}arms ofparsePathElement, the IdReference synthesis shim; plusNodeIdLeafResolver.resolveFkJoinPath) switches overFkJoinResolution.UnknownForeignKey, whose sole producer issynthesizeFkJoin. That variant fires only whencatalog.findForeignKeyRef(f)misses for an FK object that came from the catalog: a catalog-vs-jar mismatch, documented defensive-only onsynthesizeFkJoin’s javadoc. The `fkNameit carries isf.getName(), a real FK’s SQL constraint name, never something the author typed. A Levenshtein "did you mean" over catalog FKs is noise on that path whether scoped or not, and the__-namespace detection R259 added there is a no-op by construction (a real FK object’sgetName()is always the SQL form). -
The genuine residual author-typo surface is
resolveRecordFkTargetColumns. Its explicit@reference(key:)NotInCatalog arm builds a hint fromcatalog.allForeignKeySqlNames()inline: still global and, unlike the sibling, also still namespace-blind, even thoughrecordTable.tableName()is in scope right there. Worse, the result flattens to prose:RecordFkTargets.Rejectedcarries a bareString, whichInputBeanResolverwraps intoRejection.structural(...), so the typo never reaches the typedRejection.AuthorError.UnknownNamepayload (attempt + candidates +AttemptKind.FOREIGN_KEY) that the typed-rejection contract (docs/architecture/explanation/typed-rejection.adoc) promises for name-against-closed-set failures.
So the value of this item lives at the record-FK surface, and the spec below makes that the spine. The defensive sibling gets scoped too, but as cheap symmetry, not as the point.
Design
Slice 1 (the spine): lift the record-FK @reference(key:) miss onto the typed, scoped, namespaced path.
-
In
resolveRecordFkTargetColumns, replace the inlinecandidateHint(explicitFkKey.get(), catalog.allForeignKeySqlNames())construction with the existingfkCandidateNames(recordTable.tableName(), explicitFkKey.get())helper. That buys scope (viaJooqCatalog.foreignKeysTouchingTable, global fallback when nothing touches) and namespace mirroring (__detection on the author’s attempt) in one move. -
Route the result through
Rejection.unknownForeignKey(summary, attempt, candidates)instead of a hand-built string: changeRecordFkTargets.Rejectedto carry aRejectionrather than aString(its other producers,fkCountMessageand the ambiguity arm, already have typed or message-shaped rejections to wrap), and haveInputBeanResolverpass the typed rejection through instead of re-wrapping prose inRejection.structural. This makes the candidates ride asUnknownNamestructured data, the shape an LSP fix-it can consume without parsing prose, and makes the new tests structural instead of substring-matching. -
fkCandidateNamesis private toBuildContext;resolveRecordFkTargetColumnsis in the same class, so no visibility change.
Slice 2 (symmetry): single-source the sibling’s source table on the variant.
-
Add the source table to the variant:
record UnknownForeignKey(String fkName, String sourceSqlName).synthesizeFkJoinalready receivessourceSqlName(non-null by its documented precondition) and is the sole producer, so the pair (FK name, the table it was oriented against) is bound once at the point that knows both, instead of five call sites each re-picking "the source" from local scope. This mirrors the siblingUnknownTable(requestedName, failure), which already carries its diagnostic data on the variant. -
Change
unknownForeignKeyRejection(String fkName)tounknownForeignKeyRejection(String fkName, String sourceSqlTable)and delegate candidate construction tofkCandidateNames(sourceSqlTable, fkName), deleting the duplicated inline namespace detection. Call sites mechanically passuf.sourceSqlName(). -
Update the builder’s javadoc: drop the "still the whole catalog / tracked as a follow-up" paragraph, and state plainly that this rejection is reached only from the defensive catalog-vs-jar mismatch, so the hint is best-effort. (A mismatch-specific message that says "rebuild the catalog jar" instead of offering candidates was considered and is out of scope; if that path ever fires in practice, file a separate item.)
Tests
-
Pipeline tier (slice 1, the wiring): a fixture with a misspelled record-FK
@reference(key:)throughInputBeanResolverasserts the typedRejection.AuthorError.UnknownNamevariant withAttemptKind.FOREIGN_KEY, the attempt string, and a candidate list that is a subset of the FKs touching the record table (and, with a__-form attempt, constant-namespace candidates). Asserting the typed variant’s fields, not a prose substring, is the reason slice 1 routes ontoUnknownNameat all. -
Unit tier (slice 2, the derivation): extend the existing
unknownForeignKeyRejectionsection ofJooqCatalogMultiSchemaTest: with a source table supplied, candidates are limited to FKs touching that table; with a source table that has no touching FKs, the global namespace-matched fallback applies (this is the fallback state actually reachable from the producer;sourceSqlNameis never null on that path). The two existing namespace tests update for the new parameter.
Out of scope
Unchanged from the original carve-out: the LSP completion/hover arms and the FK-resolution logic itself. Additionally out of scope: rewriting the defensive path’s message semantics (see slice 2), and any change to the Rejection.unknownForeignKey factory shape (both slices only change who calls it and with what candidate list).