fix(mapper): validate join machinery instead of silently mis-resolving - #211
Open
shihyuho wants to merge 1 commit into
Open
fix(mapper): validate join machinery instead of silently mis-resolving#211shihyuho wants to merge 1 commit into
shihyuho wants to merge 1 commit into
Conversation
The join resolvers assumed well-formed, well-ordered input and silently mis-handled anything else. Validate or fail loud in one pass: - Join.toPredicate accumulates the distinct flag rather than overwriting it, so a later join can no longer undo an earlier distinct=true. - Registering an alias that already exists now compares the stored parent, attribute and joinType, and throws when they conflict instead of keeping the first definition. - Join paths deeper than two segments throw instead of silently joining only the first two. - A dotted path whose first segment resolves to neither a registered join/fetch alias nor a root attribute throws a message naming the alias and the declaration-order requirement, rather than falling back to a same-named attribute on the root. - A missing JoinContext (direct construction outside SpecMapper) throws a message explaining the mapper-pipeline requirement instead of a bare NoSuchElementException. - Fetch aliases resolve through the fetch node itself, which carries the declared joinType, so a non-INNER @JoinFetch no longer restricts the content query differently from the count query. - Join/fetch bookkeeping is keyed weakly by Root so a reused Specification no longer accumulates one entry per execution. Several silent-wrong-result paths now throw. Co-authored-by: Claude Opus 4.8 <[email protected]>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #198
WP3 — Join machinery. The cluster's root cause was shared: the join resolvers assumed well-formed, well-ordered input and silently mis-handled anything else. One pass, validate-or-fail-loud.
Findings
query.distinct()last-writer-winsJoin.toPredicatenow accumulates:query.distinct(query.isDistinct() || distinct), so a join declareddistinct=falsecan no longer undo an earlierdistinct=true.distinctShouldBeAccumulatedAcrossJoins— two class-level joins with opposing flags; assertsquery.isDistinct(). Fails onjakarta(flag ends upfalse).Joincompares the stored join's parent, attribute name and joinType against the new definition and throwsIllegalArgumentExceptionnaming the alias, the existing joinType + attribute, and the conflicting joinType + path. Identical redefinitions still reuse the existing join (existing dedup behaviour preserved).conflictingPathOnSameAliasShouldThrow(ordersvsbadgeson aliasshared) andconflictingJoinTypeOnSameAliasShouldThrow(INNERvsLEFTon aliasorder). Both fail onjakarta(no exception).JoinvalidatesbyDot.length == 2and throws, citing the actual segment count and the two-segment'<parent-alias>.<association>'limit, with the advice to define an intermediate join.joinPathWithMoreThanTwoSegmentsShouldThrow(o.tags.name). Fails onjakarta— it silently joinedo.tags.root.get(alias)SimpleSpecification.getExprroutes the fallback throughgetAttribute(..), which wraps any provider failure in anIllegalArgumentExceptionnaming the segment, the full path, the entity, and the declaration-order requirement (including that a join on a null-valued field is never applied).unregisteredJoinAliasShouldThrow— reproduces the finding exactly: the@Joinsits on a null-valued field so aliasois never registered, while a second field referenceso.itemName. Fails onjakarta(opaque HibernatePathElementException).@JoinFetchalias with different join semanticsgetFetchPathresolves through the fetch node itself (aPathin Hibernate), which carries the declaredjoinType, instead of re-navigatingroot.get(..)— which emitted an implicit inner join. Content and count now agree. Falls back to the old path-walk if a provider'sFetchis not aPath.nonInnerJoinFetchShouldKeepCountAndContentInSync—LEFT@JoinFetch+IsNullon the joined column, with a customer that has no orders. Onjakartathe content query returned[]whilecountreturned1; now both find him, and the page assertion usesPageRequest.of(0, 1)so Spring Data cannot skip the count query.NoSuchElementExceptionon direct constructionSimpleSpecificationresolvesCTX_JOINthroughjoinContext(), which translates the missing key into anIllegalStateExceptionexplaining that multi-segment paths need the registrySpecMapperpopulates, with the original exception as cause.multiSegmentPathWithoutJoinContextShouldExplainTheMapperPipeline— directly-constructed spec with a dotted path over an emptySpecContext.joined/fetchedgrow per execution of a reused SpecificationSpecJoinContextnow keys both maps weakly byRoot, so bookkeeping dies with the criteria tree that owns it. The criteria nodes stored as values reference theirRoot, so they are held weakly too — a strong value would keep its own key reachable and defeat the weak key entirely. That is safe because Hibernate'sAbstractSqmFromowns every join and fetch built from it (addSqmJoin), keeping the referents alive for as long as the execution can reach them. No size cap.shouldNotRetainBookkeepingOfCollectedRoot— registers a join and a fetch, drops the criteria tree, and asserts underawait()that both maps empty out once theRootis collected. Fails onjakarta(times out; entries never clear). Ran 3× for flakiness. PlusreusedSpecificationShouldStayConsistentAcrossExecutionsas a functional regression guard.JoinSpecificationResolverTest,JoinFetchSpecificationResolverTest,SpecJoinContextTestandConstructSimpleSpecificationTest.mapper/src/mainreverted tojakartaand only the new tests applied, all 8 fail. The 9th is a deliberate regression guard that passes on both.Release note
Tightened validation — several silent-wrong-result paths now throw. Configurations that previously produced a quietly incorrect query now fail fast:
@Joindeclarations sharing an alias but differing in path orjoinType→IllegalArgumentExceptionpathwith more than two dot-separated segments →IllegalArgumentException@Specpath whose first segment is neither a registered join alias nor an entity attribute →IllegalArgumentException(previously fell back to a same-named attribute on the root)SimpleSpecificationwith a multi-segment path constructed outsideSpecMapper→IllegalStateException(previously a bareNoSuchElementException)Also behavioural:
distinctnow accumulates across joins rather than being decided by the last join executed, and predicates on a non-INNER@JoinFetchalias now use the declared join type in the content query, so page totals match page content.Verification
make testgreen — 107 mapper tests + 15 starter tests, 0 failures. Run on Temurin 17.0.6 (the project's declaredjava.version; the machine default resolves to JDK 25, switched via SDKMAN for the build only — no pom changes).Deliberately not done
JoinFetch.javais outside this work package's file allowlist, so two things were left alone even though they touch the same findings:JoinFetch.toPredicatestill callsquery.distinct(distinct)unconditionally — the COR-05 last-writer-wins hazard survives for@JoinFetch, and for criteria that mix@Joinwith@JoinFetch. TheJoinside is fixed, and count queries inherit the fix through the delegation, but the content-query path inJoinFetchneeds the same one-line change. Worth a follow-up issue — same finding, sibling class.JoinFetch.fetch(..)has the COR-07 twin (nobyDot.lengthvalidation) and the COR-06 twin (early return without comparing the stored definition).shouldDelegateToJoin/delegatePredicateToJoinwere not touched. COR-11 was fixed entirely on the resolution side inSimpleSpecification, so the count query's semantics are unchanged and nothing here collides with WP2's redesign of the delegation.root.get(field)fallback rather than throwing on every unregistered alias. Throwing unconditionally would break legitimate nested attribute paths (@Spec(path = "address.city")), which are indistinguishable from aliases at this layer — the registry records aliases only attoPredicatetime and carries no record of which aliases were declared. Making the fallback's failure loud and descriptive is the strongest check available without changing theJoinContextinterface (also outside the allowlist). One residual case stays silent: an alias that collides with a real attribute name (e.g. the default alias of@Join(path = "orders")isorders) still resolves to that attribute when the join was not applied.🤖 Generated with Claude Code