Skip to content

intent: a requiredWhen guard takes a typeless field, any spelling of its type, and a to-one of any key width (#7237) - #7301

Merged
delchev merged 2 commits into
masterfrom
issue-7237-requiredwhen-guard-types
Sep 10, 2026
Merged

intent: a requiredWhen guard takes a typeless field, any spelling of its type, and a to-one of any key width (#7237)#7301
delchev merged 2 commits into
masterfrom
issue-7237-requiredwhen-guard-types

Conversation

@delchev

@delchev delchev commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Cause

Three defects in the when: condition of checks: requiredWhen, 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 (getType() == null ? "string", IntentEntities.sqlType(null) -> VARCHAR), but its null type reached CheckSupport.GUARD_TYPES.contains(...) raw, and an immutable Set.of(...) throws NullPointerException on contains(null). The crash replaced both the issue an author should have seen and the guard they were entitled to.

  2. The type was compared raw, so type: Integer — a spelling sqlType and CheckSupport.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, java.util.Objects.equals(entity.Supplier, 1). The FK column is typed from the TARGET's key (EdmIntentGenerator p.put("dataType", info.fkType())), 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 — precisely the failure this check was built to refuse for local fields.

Change

  • CheckSupport.guardType(String) normalises the type once — null/blank to the string a 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, so decimal is 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), the Integer spelling (accepted), and a decimal field (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 a requiredWhen on a to-one (when: "Status == POSTED"), so the IT now asserts the numeric form in the generated DocController (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:validate with 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 a long-keyed run.

Fixes #7237

🤖 Generated with Claude Code

delchev and others added 2 commits September 10, 2026 13:24
…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>
@delchev
delchev merged commit 52e7ce8 into master Sep 10, 2026
10 checks passed
@delchev
delchev deleted the issue-7237-requiredwhen-guard-types branch September 10, 2026 13:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant