intent: a requiredWhen guard takes a typeless field, any spelling of its type, and a to-one of any key width (#7237) - #7301
Merged
Conversation
…its type, and a to-one of any key width (#7237) The `when:` condition of `checks: requiredWhen` had three defects, all in how the guarded property's TYPE was resolved: 1. `/parse` answered 500 for a legal intent. A field declared without a `type:` is a string everywhere else in the parser, but its null type reached `CheckSupport.GUARD_TYPES.contains(...)` raw, and an immutable `Set.of(...)` throws on `contains(null)`. The crash replaced both the issue the author should have seen and the guard they were entitled to. 2. The type was compared raw, so `type: Integer` - a spelling `sqlType` and `javaLiteral` accept and lower-case - was refused with "which is a [Integer] field". 3. A guard on a to-one was rendered as a boxed equality against an int literal, `Objects.equals(entity.Supplier, 1)`. The FK column is typed from the TARGET's key, and a cross-model target's key is only readable from the owner's `.model`, where a `long` is as legal as an `integer`. `Objects.equals(Long, Integer)` never holds, so such a guard switched the rule off while looking authored - the exact failure this check was built to refuse for local fields. The type is now normalised once, in `CheckSupport.guardType` (null -> the string a typeless field means, and lower-cased), before the parser's guardable-type lookup and the generator's rendering; the refusal message still names the type as authored. A guard on a to-one is rendered by the new `CheckSupport.numericComparison` as `(fk != null && fk.longValue() == <n>L)`, which holds whatever width the key turns out to have - a field's own width is declared, so a field guard keeps its exact boxed equality. Verified: engine-intent's suite (1201 tests) with new parser cases for the typeless field, the `Integer` spelling and a still-refused `decimal`, and a new generator case asserting the numeric rendering; and IntentEmissionCoverageIT, whose emission-test app already guards a requiredWhen on a to-one (`Status == POSTED`) - it now asserts the numeric form in the generated controller AND, over REST, that the guard fires: the move to POSTED without a counterparty is refused 400 and the same move with one is accepted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.
Cause
Three defects in the
when:condition ofchecks: requiredWhen, all in how the guarded property's TYPE was resolved./parseanswered 500 for a legal intent. A field declared without atype:is a string everywhere else in the parser (getType() == null ? "string",IntentEntities.sqlType(null)->VARCHAR), but its null type reachedCheckSupport.GUARD_TYPES.contains(...)raw, and an immutableSet.of(...)throwsNullPointerExceptiononcontains(null). The crash replaced both the issue an author should have seen and the guard they were entitled to.The type was compared raw, so
type: Integer— a spellingsqlTypeandCheckSupport.javaLiteralaccept and lower-case — was refused with "which is a [Integer] field".A guard on a to-one was rendered as a boxed equality against an int literal,
java.util.Objects.equals(entity.Supplier, 1). The FK column is typed from the TARGET's key (EdmIntentGeneratorp.put("dataType", info.fkType())), and a cross-model target's key is only readable from the owner's.model, where alongis as legal as aninteger.Objects.equals(Long, Integer)never holds, so such a guard switched the rule off while looking authored — precisely the failure this check was built to refuse for local fields.Change
CheckSupport.guardType(String)normalises the type once —null/blank to thestringa typeless field means, then lower-cased — and both the parser's guardable-type lookup and the generator's rendering go through it. The refusal message still names the type as authored, sodecimalis still refused as[decimal].CheckSupport.numericComparison(...)renders a guard on a to-one as(entity.Fk != null && entity.Fk.longValue() == <n>L), which holds whatever width the key turns out to have. A field's own width is declared, so a field guard keeps its exact boxed equality.Verified
mvn -pl components/engine/engine-intent test— 1201 tests, green. New parser cases: the typeless field (parses, no NPE), theIntegerspelling (accepted), and adecimalfield (still refused, named as authored). New generator case asserting(entity.Status != null && entity.Status.longValue() == 4L) && !java.util.Objects.equals(entity.SentMethod, 1)— the to-one numeric, the own field boxed.IntentEmissionCoverageIT— green. Its emission-test app already guards arequiredWhenon a to-one (when: "Status == POSTED"), so the IT now asserts the numeric form in the generatedDocController(and the absence of the boxed one) and, over REST, that the guard actually fires: moving a document to POSTED without a counterparty is refused 400, the same move with one is accepted 200. That runtime half is what a generated-source assertion alone cannot tell apart from an always-false guard.mvn -T 1C formatter:validatewith the formatter caches wiped — green.Not verified: no cross-model fixture with a
long-keyed target exists in the ITs; the width-independence is covered by construction (the comparison no longer depends on the width) rather than by along-keyed run.Fixes #7237
🤖 Generated with Claude Code