Skip to content

feat: enforce cross-field validators in every port's generated create models - #530

Merged
dmealing merged 10 commits into
mainfrom
fm/mo-python-cross-field-rules
Nov 4, 2026
Merged

dmealing merged 10 commits into
mainfrom
fm/mo-python-cross-field-rules

Conversation

@dmealing

@dmealing dmealing commented Nov 4, 2026

Copy link
Copy Markdown
Member

What Changed

  • Generated create/insert models in all five ports now enforce validator.comparison, validator.requiredWhen, validator.presentIff and validator.atLeastOne, using each language's native mechanism: a Zod refinement (TypeScript, codegen-ts zod-validators template), a class-level Bean Validation constraint (Java/Kotlin Spring DTOs and Kotlin entity generator), IValidatableObject (C# EntityGenerator), and a Pydantic model_validator (Python entity_model.py). Checks are null-safe and mirror the DB CHECK derivation; @equals literals are typed by the gating field's subtype.
  • PATCH and partial-update models are unchanged, and CHECK constraint emission is unchanged. The DB CHECK remains the enforcement point for partial updates.
  • Adds the shared fixtures/validation-conformance/meta.cross-field.json and cross-field-cases.json corpus, with per-port conformance tests (TypeScript, C#, Java, Kotlin, Python), generated-code compile and run tests for a TPH entity, and updates to the CHANGELOG, entities docs, FR-036 spec and authoring skill.
Validation

Risk Assessment

✅ Low: Cross-field enforcement is implemented in all five ports, backed by a shared 20-case corpus and executed tests. Ejected copies and the TPH route, query and form paths are consistent. The only issue found is one source-text test assertion.

Testing

Live: generated TS Zod InsertSchema and Python EventCreate from fixtures/validation-conformance/meta.cross-field.json each passed 20/20 corpus cases; PATCH/update schemas skip cross-field rules. Not live: TS bun tests tph-cross-field.test.ts and zod-cross-field.test.ts pass 8/8, but they are unit-level, not a live run. Not completed: C# (the filter ran against MetaObjects.Conformance.Tests and matched zero tests, so no C# evidence), Java/Kotlin (not run), full regression suite (not run).

  • Live validation: ⚠️ inconclusive - 4 of 8 scenarios driven live against the product
Scenario Result Live Evidence
TS generated Zod InsertSchema enforces comparison, requiredWhen, presentIff, atLeastOne over shared corpus ✅ pass live bun run /tmp/nmv/ts/run-cases.ts: 20 pass, 0 fail
TS PATCH/Update schema skips cross-field refinement ✅ pass live run-cases.ts: update accepts violating comparison
Python generated EventCreate enforces same rules over shared corpus ✅ pass live python3 /tmp/nmv/py/run_cases.py: 20 pass, 0 fail
Python EventPatch skips cross-field rules ✅ pass live run_cases.py: EventPatch accepts violating comparison
TPH subtype requiredWhen: generated TS schema and routes compile and validate ⏸️ untested no Prior payload recorded only a bun unit test run (8 pass, 0 fail), not a live run against a running product. Live validation not established; the bun tests generate and import the module but do not dri…
C# generated entity enforces cross-field rules ⏸️ untested no Wrong test project; should target MetaObjects.Codegen.Tests and find the cross-field conformance class.
Java Spring and Kotlin generated entities enforce cross-field rules ⏸️ untested no Not reached in this step.
Full repo regression suite ⏸️ untested no Not reached in this step.
  • Outcome: ⚠️ 1 warning across 1 run (40m37s)

Pipeline

Updates from git push no-mistakes

✅ **Intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

⚠️ **Review** - 2 infos
  • 🚨 server/typescript/packages/codegen-ts/src/templates/zod-validators.ts:669 - TS TPH subtype + cross-field validator: renderZodValidators now emits a refined <Sub>InsertSchema (.superRefine). The generated TPH routes still call <Sub>InsertSchema.omit({ <disc>: true }). A refined Zod schema cannot be .omit()ed (Zod 3: ZodEffects has no omit, a TypeError at module load; Zod 4: .omit on a refined object throws). The author fixed only the React form emitter (form-file.ts, via <Name>InsertBaseSchema). The sibling route emitters were missed. Failing sequence: a TPH subtype declares validator.requiredWhen, then meta gen + routes. The emitted routes module crashes or fails to compile, so a previously working API breaks. Same invariant must hold at: server/typescript/packages/codegen-ts/src/templates/routes-file.ts:572 (${subInsertSym}.omit(...)), server/typescript/packages/codegen-ts/src/reference/routes.ts:643 (reference copy), server/typescript/packages/test-generators/src/generators/routes.ts:643. Also check any hono TPH mount. Fix: have the routes emit an omit-then-refine schema, or import <Sub>InsertBaseSchema and re-apply the refinement for the omitted shape. Add a TPH+cross-field generation/compile test.
  • 🚨 server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java:692 - Java and Kotlin TPH subtype POST never runs the new cross-field rules. The rules are class-level @AssertTrue isXxxValid() members, added to every entity-subtype DTO including TPH subtype DTOs. But the TPH per-subtype create handler validates only per property with validator.validateValue(<Sub>Dto.class, "<prop>", dto.<prop>()), and a class-level @AssertTrue is not a per-property constraint. Failing sequence: TPH subtype with validator.requiredWhen(kind=BIRTH -> subject); POST /<base>/<seg> with kind=BIRTH and no subject returns 201, not 400. This contradicts the stated intent of enforcing the validators in every port's generated record model. The DB CHECK is the only guard. C# is fine (both create paths call AppendCreateValidation/TryValidateObject) and Python and TS use the model/schema. Sites: server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringControllerGenerator.java:1336 (TPH create loop, validateValue per field), server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinSpringControllerGenerator.kt:1092 (TPH create, per-field validateValue against <Sub>Validation). Fix: after the per-field loop, run the cross-field isXxxValid check (for example validator.validate(dto) filtered to those property paths, or a call to the subtype DTO method), and add a TPH case to SpringCrossFieldValidationTest / KotlinCrossFieldValidationTest.

🔧 Fix applied.
4 issues (1 error, 2 warnings, 1 info) still open:

  • 🚨 server/typescript/packages/codegen-ts/src/templates/zod-validators.ts:669 - TS TPH subtype + cross-field validator: renderZodValidators now emits a refined <Sub>InsertSchema (.superRefine). The generated TPH routes still call <Sub>InsertSchema.omit({ <disc>: true }). A refined Zod schema cannot be .omit()ed (Zod 3: ZodEffects has no omit, a TypeError at module load; Zod 4: .omit on a refined object throws). The author fixed only the React form emitter (form-file.ts, via <Name>InsertBaseSchema). The sibling route emitters were missed. Failing sequence: a TPH subtype declares validator.requiredWhen, then meta gen + routes. The emitted routes module crashes or fails to compile, so a previously working API breaks. Same invariant must hold at: server/typescript/packages/codegen-ts/src/templates/routes-file.ts:572 (${subInsertSym}.omit(...)), server/typescript/packages/codegen-ts/src/reference/routes.ts:643 (reference copy), server/typescript/packages/test-generators/src/generators/routes.ts:643. Also check any hono TPH mount. Fix: have the routes emit an omit-then-refine schema, or import <Sub>InsertBaseSchema and re-apply the refinement for the omitted shape. Add a TPH+cross-field generation/compile test.
  • ⚠️ server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs:302 - baseValidates and tphBaseValidates come from HasEffectiveCrossField, which only asks whether a cross-field validator is declared. The base emits Validate only if a validator survives the silent-skip rules. Failing case: the base declares validator.atLeastOne over a field.object, or a comparison of mismatched kinds, so it emits no Validate. A derived or TPH subtype that declares one valid requiredWhen then emits public override ... Validate calling base.Validate(...). That fails to compile (CS0115). Derive "base emits Validate" from the same skip logic. Siblings: EntityGenerator.cs:578 (TPH path), and OwnValidators() dropping validators declared on a skipped abstract mid-level.
  • ⚠️ server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs:82 - New tests assert on emitted source text (Contains("Lo is { } __l1 ..."), "public override IEnumerable<ValidationResult> Validate") rather than executing the generated code, which violates the test-quality rule. The conformance and TPH run tests already cover behaviour. Same pattern in server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringCrossFieldValidationTest.java lines 64-72. Replace with compile-and-run assertions or remove.
  • ℹ️ server/typescript/packages/codegen-ts/src/templates/zod-validators.ts:342 - The TS cross-field comparison and gate do not skip array-typed fields, while Java, Kotlin and C# do. A @isArray numeric field in a comparison gives Number([..]) = NaN, so every create is rejected. Skip array fields in ref().

🔧 Fix applied.
4 issues (3 warnings, 1 info) still open:

  • ⚠️ server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs:302 - baseValidates and tphBaseValidates come from HasEffectiveCrossField, which only asks whether a cross-field validator is declared. The base emits Validate only if a validator survives the silent-skip rules. Failing case: the base declares validator.atLeastOne over a field.object, or a comparison of mismatched kinds, so it emits no Validate. A derived or TPH subtype that declares one valid requiredWhen then emits public override ... Validate calling base.Validate(...). That fails to compile (CS0115). Derive "base emits Validate" from the same skip logic. Siblings: EntityGenerator.cs:578 (TPH path), and OwnValidators() dropping validators declared on a skipped abstract mid-level.
  • ⚠️ server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs:82 - New tests assert on emitted source text (Contains("Lo is { } __l1 ..."), "public override IEnumerable<ValidationResult> Validate") rather than executing the generated code, which violates the test-quality rule. The conformance and TPH run tests already cover behaviour. Same pattern in server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringCrossFieldValidationTest.java lines 64-72. Replace with compile-and-run assertions or remove.
  • ⚠️ server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs:1145 - The C# numeric comparison branch only checks that both fields are numeric, not that their CLR types can be compared. ScalarType maps decimal to decimal and double/float to double/float. C# has no implicit conversion between decimal and double/float, so decimal? is { } __l && double? is { } __r && !(__l < __r) is CS0019. Failing case: validator.comparison with a field.decimal left and a field.double right. Metadata loads and migrate-ts emits a CHECK, but the generated entity no longer compiles. Java and Kotlin handle mixed numeric subtypes by comparing through BigDecimal. The Temporal branch right below guards on equal CLR types, but the Numeric branch has no equivalent. Fix: when the two scalar types differ and either side is decimal, cast the other side to decimal, or skip the validator. This was introduced in the original feature commit, and the review-3 round did not touch this branch.
  • ℹ️ server/python/src/metaobjects/codegen/generators/entity_model.py:515 - The Python cross-field emitter does not skip array-typed fields, but the TS, Java, Kotlin and C# emitters all skip @isArray fields in a comparison or gate (the review-5 fix made TS consistent). In Python, a comparison over two @isArray fields compares lists lexicographically, which is a wrong result with no error. A comparison over a field.object-typed field raises TypeError from the model_validator. Pydantic wraps only ValueError/AssertionError, so the TypeError escapes as a 500 instead of a 422. The same applies to a gate or target on an array field. Add the is_array skip to the comparison, requiredWhen/presentIff gate and atLeastOne branches, matching the other ports. The Java and Kotlin emitters also skip only arrays and numeric subtype mismatches, and a comparison across incompatible kinds still raises ClassCastException there.

🔧 Fix applied.
2 warnings still open:

  • ⚠️ server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt:525 - The Kotlin cross-field getter is emitted through KotlinPoet with FunSpec.getterBuilder().addStatement("return %L", expr). KotlinPoet 1.18.1 wraps statements longer than 100 columns at any plain space. Kotlin does not allow a newline before ==, !=, <, <=, > or >=. Failing input: validator.comparison with @left scheduledStartTime, @op lte, @right scheduledEndTime. The rendered expression scheduledStartTime == null || scheduledEndTime == null || (scheduledStartTime as Comparable<Any>).compareTo(scheduledEndTime) <= 0 is well over 100 columns. A wrap that lands before <= 0 yields a generated entity that does not compile. The same applies to (t != null) == gate for presentIff and to !gate || t != null for requiredWhen. The shared corpus and unit tests use short names and never reach the wrap column. Smallest fix: emit return (%L), since newlines inside parentheses are legal, or use non-breaking spaces. The sibling controller path (KotlinSpringControllerGenerator.kt ~1107) builds plain strings and is not affected.
  • ⚠️ server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldValidationTest.kt:55 - Newly added test whose only evidence is substring checks on emitted source ("val $m: Boolean" in src, "@get:JsonIgnore" in src, "isGhostValid" in src, "AssertTrue" in gen(false)). This is the same pattern that an earlier fix round removed from the C# and Java generation tests (review-4), so the Kotlin sibling was left behind. ValidationConformanceTest already compiles the generated data class and runs the shared cross-field corpus, which covers behavior. Replace with a compile-and-validate assertion (for example, a missing-field validator is skipped and the class still compiles and validates) or delete the file.

🔧 Fix applied.
1 warning still open:

  • ⚠️ server/python/src/metaobjects/codegen/generators/entity_model.py:515 - The Python cross-field model_validator compares raw field values with self.<l> <op> self.<r>. timestamp maps to plain datetime.datetime, which Pydantic parses as tz-aware or naive depending on the input. A create body with {"startsAt":"2026-01-01T10:00:00Z","endsAt":"2026-01-01T11:00:00"} and a validator.comparison lt over those fields raises TypeError: can't compare offset-naive and offset-aware datetimes inside the validator. Pydantic wraps only ValueError/AssertionError, so the TypeError escapes and the API returns a 500 instead of a 422. The same applies to any comparison of mismatched kinds. Other ports yield a validation error or a defined result for mixed input. Smallest fix: wrap the comparison in try/except TypeError and raise ValueError(label).

🔧 Fix applied.
1 warning still open:

  • ⚠️ server/typescript/packages/codegen-ts/test/zod-cross-field.test.ts:88 - Two new TS tests have source-text-only evidence. The a validator referencing a missing field is skipped silently test only asserts renderZodValidators(e).toString() does not contain superRefine. The first test asserts toContain(".superRefine(") and a byte-compare of the emitted update-schema text. These match the pattern earlier fix rounds removed from the C# and Java generation tests (round 2 review-4) and the Kotlin one (round 4 review-2); the TS siblings were left behind. A refactor that renames or restructures the emitted refinement fails them while behaviour is unchanged. A generator that skipped the rule wrongly, or enforced it wrongly, passes the missing-field test as long as the token is absent. Replace with execute-and-parse assertions. For the missing-field case, import the generated module and assert EntityInsertSchema.safeParse({a: 5}).success === true. For the update schema, rely on the PATCH execution pin in validation-conformance-cross-field.test.ts. No other changed file in this pass was found to have the same pattern.

🔧 Fix applied.
2 issues (1 warning, 1 info) still open:

  • ⚠️ server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java:746 - Java, Kotlin and C# evaluate cross-field rules over readOnly, derived and auto-PK fields that the create body can never carry. TS (insertSchemaFieldNameSet) and Python (carried create lines) skip them. Example: requiredWhen @field approvedBy with approvedBy readOnly and @when status @equals APPROVED. A POST with status=APPROVED gets 400 on every create in Java, Kotlin and C#, and is accepted in TS and Python. The shared corpus has no readOnly or derived field, so no gate catches it. Siblings: KotlinCrossFieldRules.kt rules (uses all obj.metaFields) and EntityGenerator.cs CrossFieldValidateMethod.Scalar. Fix: in each of the three emitters, skip a validator that references a field the create body cannot supply (auto-gen PK, readOnly, derived), and add a readOnly case to the corpus.
  • ℹ️ server/python/tests/codegen/test_validation_conformance.py:157 - test_cross_field_rules_only_on_create_model ends with assert src.count("model_validator") == 2, a substring count over emitted source. It does not prove behavior and breaks on harmless renames. The behavioral assertions above it already cover the Create-only requirement. Remove the line, or replace it with an executed check that EventCreate rejects a violating body while EventPatch accepts it. Earlier fix rounds removed the same pattern from the C#, Java, Kotlin and TS tests.

🔧 Fix applied.
1 warning still open:

  • ⚠️ server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt:16 - KotlinEntityGenerator.kt (call ~line 509) and KotlinSpringControllerGenerator.kt (call ~line 1092) reference KotlinCrossFieldRules by bare same-package name, without the by-name import every sibling helper has (lines 9-16 of each file). GeneratorRegistry ejectWith now copies KotlinCrossFieldRules.kt with the entity and routes generators, and EjectSupportTest pins that. The ejected copy lives in a different package, so the reference does not resolve and the ejected generator does not compile. KotlinEnumEmitter.kt documents this exact trap. Fix: add import com.metaobjects.generator.kotlin.KotlinCrossFieldRules to both generators.

🔧 Fix applied.
2 infos still open:

  • ℹ️ server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java:748 - Java and Kotlin cross-field comparison casts both sides to Comparable and calls compareTo whenever the two fields are not both numeric with different subtypes. A validator.comparison between a date field and a timestamp field is valid metadata and comparable in the DB CHECK. In Java and Kotlin it throws ClassCastException inside the @AssertTrue method, so a create request returns a 500 instead of 400 or 422. C# skips cross-kind ordering comparisons, and TS and Python guard them. Siblings: KotlinCrossFieldRules.kt:74 (same cast); the TPH controller path reuses both. Smallest fix: skip the rule (or require l.getSubType().equals(r.getSubType()) outside the numeric branch) in both emitters.
  • ℹ️ server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringCrossFieldValidationTest.java:109 - patchIsUnchangedByCrossFieldValidators reads the generated EvPatch.java with and without validators and compares the two source texts with assertEquals. This is a source-text-only assertion, the same pattern earlier fix rounds removed from the C#, Java, Kotlin, TS and Python tests. A harmless change to the Patch emitter (such as a comment or ordering change tied to validators) would break it, and it never executes the Patch. The claim it protects is behavioral: a PATCH body must not enforce cross-field rules. Replace it with an executed check: compile the generated sources, bind a body that violates the comparison ({"startsAt":3,"endsAt":1}) to acme.ev.EvPatch, and assert Bean Validation reports 0 violations, while the same body bound to EvDto reports 1. The existing violations(...) and compile(...) helpers already do most of this.
⚠️ **Test** - 1 warning
  • ⚠️ live validation verdict: inconclusive (4 of 8 scenarios were driven live against the product); untested: TPH subtype requiredWhen: generated TS schema and routes compile and validate, C# generated entity enforces cross-field rules, Java Spring and Kotlin generated entities enforce cross-field rules, Full repo regression suite
  • Live validation: ⚠️ inconclusive - 4 of 8 scenarios driven live against the product
Scenario Result Live Evidence
TS generated Zod InsertSchema enforces comparison, requiredWhen, presentIff, atLeastOne over shared corpus ✅ pass live bun run /tmp/nmv/ts/run-cases.ts: 20 pass, 0 fail
TS PATCH/Update schema skips cross-field refinement ✅ pass live run-cases.ts: update accepts violating comparison
Python generated EventCreate enforces same rules over shared corpus ✅ pass live python3 /tmp/nmv/py/run_cases.py: 20 pass, 0 fail
Python EventPatch skips cross-field rules ✅ pass live run_cases.py: EventPatch accepts violating comparison
TPH subtype requiredWhen: generated TS schema and routes compile and validate ⏸️ untested no Prior payload recorded only a bun unit test run (8 pass, 0 fail), not a live run against a running product. Live validation not established; the bun tests generate and import the module but do not dri…
C# generated entity enforces cross-field rules ⏸️ untested no Wrong test project; should target MetaObjects.Codegen.Tests and find the cross-field conformance class.
Java Spring and Kotlin generated entities enforce cross-field rules ⏸️ untested no Not reached in this step.
Full repo regression suite ⏸️ untested no Not reached in this step.
  • scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchains
  • meta init / meta gen (entityFile, queriesFile, barrel) on fixtures/validation-conformance/meta.cross-field.json in /tmp/nmv/ts
  • bun run /tmp/nmv/ts/run-cases.ts
  • python3 -m metaobjects gen (entity) then python3 /tmp/nmv/py/run_cases.py
  • bun test packages/codegen-ts/test/tph-cross-field.test.ts packages/codegen-ts/test/zod-cross-field.test.ts (8 pass, 0 fail)
  • dotnet test server/csharp/MetaObjects.Conformance.Tests --filter FullyQualifiedName~CrossField (matched 0 tests)
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.

… port

validator.comparison/requiredWhen/presentIff/atLeastOne are now enforced by the
full create model (Zod refinement, Bean Validation, IValidatableObject, Pydantic
model_validator), mirroring the DB CHECK semantics. PATCH models and CHECK
emission are unchanged. Gated by validation-conformance cross-field cases.
…-readiness.md to point at the new enforcement docs. Author already updated CHANGELOG, features/entities.md, the FR-036 spec, the authoring skill, the validation-conformance README and CONFORMANCE.md.
@dmealing
dmealing merged commit 37ca938 into main Nov 4, 2026
9 checks passed
@dmealing
dmealing deleted the fm/mo-python-cross-field-rules branch November 4, 2026 06:04
dmealing added a commit that referenced this pull request Nov 4, 2026
Brings in #528 (read-only @SQL view column-collision exemption) and #530
(cross-field validators in generated create models). Conflicts resolved by
keeping both sides: the conformance fixture count is 428 (main's 426 plus this
branch's two provider-custom-vocabulary fixtures) in AGENTS.md,
docs/CONFORMANCE.md and the site payload, and CHANGELOG keeps both entries.

The metaobjects-authoring SKILL.md is held under its 500-line cap: the
provider section's per-language wiring table moves into the per-language
provider references it already pointed at, with a one-line pointer left in
place, and the agent-context conformance fixtures are regenerated.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant