Repository navigation
feat: enforce cross-field validators in every port's generated create models - #530
Merged
Merged
Conversation
… 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.
…in TS, Java, Kotlin
… as validation error
…-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
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.
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.
What Changed
validator.comparison,validator.requiredWhen,validator.presentIffandvalidator.atLeastOne, using each language's native mechanism: a Zod refinement (TypeScript,codegen-tszod-validatorstemplate), a class-level Bean Validation constraint (Java/Kotlin Spring DTOs and Kotlin entity generator),IValidatableObject(C#EntityGenerator), and a Pydanticmodel_validator(Pythonentity_model.py). Checks are null-safe and mirror the DBCHECKderivation;@equalsliterals are typed by the gating field's subtype.CHECKconstraint emission is unchanged. The DBCHECKremains the enforcement point for partial updates.fixtures/validation-conformance/meta.cross-field.jsonandcross-field-cases.jsoncorpus, 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).
Pipeline
Updates from git push no-mistakes
✅ **Intent** - passed
✅ No issues found.
✅ **Rebase** - passed
✅ No issues found.
server/typescript/packages/codegen-ts/src/templates/zod-validators.ts:669- TS TPH subtype + cross-field validator:renderZodValidatorsnow 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 noomit, a TypeError at module load; Zod 4:.omiton 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 declaresvalidator.requiredWhen, thenmeta 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>InsertBaseSchemaand 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 withvalidator.validateValue(<Sub>Dto.class, "<prop>", dto.<prop>()), and a class-level@AssertTrueis not a per-property constraint. Failing sequence: TPH subtype withvalidator.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 callAppendCreateValidation/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-fieldisXxxValidcheck (for examplevalidator.validate(dto)filtered to those property paths, or a call to the subtype DTO method), and add a TPH case toSpringCrossFieldValidationTest/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:renderZodValidatorsnow 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 noomit, a TypeError at module load; Zod 4:.omiton 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 declaresvalidator.requiredWhen, thenmeta 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>InsertBaseSchemaand re-apply the refinement for the omitted shape. Add a TPH+cross-field generation/compile test.server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs:302-baseValidatesandtphBaseValidatescome fromHasEffectiveCrossField, which only asks whether a cross-field validator is declared. The base emitsValidateonly if a validator survives the silent-skip rules. Failing case: the base declaresvalidator.atLeastOneover afield.object, or a comparison of mismatched kinds, so it emits noValidate. A derived or TPH subtype that declares one validrequiredWhenthen emitspublic override ... Validatecallingbase.Validate(...). That fails to compile (CS0115). Derive "base emits Validate" from the same skip logic. Siblings: EntityGenerator.cs:578 (TPH path), andOwnValidators()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 inserver/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringCrossFieldValidationTest.javalines 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@isArraynumeric field in a comparison givesNumber([..])= NaN, so every create is rejected. Skip array fields inref().🔧 Fix applied.
4 issues (3 warnings, 1 info) still open:
server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs:302-baseValidatesandtphBaseValidatescome fromHasEffectiveCrossField, which only asks whether a cross-field validator is declared. The base emitsValidateonly if a validator survives the silent-skip rules. Failing case: the base declaresvalidator.atLeastOneover afield.object, or a comparison of mismatched kinds, so it emits noValidate. A derived or TPH subtype that declares one validrequiredWhenthen emitspublic override ... Validatecallingbase.Validate(...). That fails to compile (CS0115). Derive "base emits Validate" from the same skip logic. Siblings: EntityGenerator.cs:578 (TPH path), andOwnValidators()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 inserver/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringCrossFieldValidationTest.javalines 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.ScalarTypemaps decimal todecimaland double/float todouble/float. C# has no implicit conversion betweendecimalanddouble/float, sodecimal? is { } __l && double? is { } __r && !(__l < __r)is CS0019. Failing case:validator.comparisonwith afield.decimalleft and afield.doubleright. Metadata loads and migrate-ts emits a CHECK, but the generated entity no longer compiles. Java and Kotlin handle mixed numeric subtypes by comparing throughBigDecimal. 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 isdecimal, cast the other side todecimal, 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@isArrayfields in a comparison or gate (the review-5 fix made TS consistent). In Python, a comparison over two@isArrayfields compares lists lexicographically, which is a wrong result with no error. A comparison over a field.object-typed field raisesTypeErrorfrom themodel_validator. Pydantic wraps only ValueError/AssertionError, so theTypeErrorescapes as a 500 instead of a 422. The same applies to a gate or target on an array field. Add theis_arrayskip 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 raisesClassCastExceptionthere.🔧 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 withFunSpec.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.comparisonwith@leftscheduledStartTime,@oplte,@rightscheduledEndTime. The rendered expressionscheduledStartTime == null || scheduledEndTime == null || (scheduledStartTime as Comparable<Any>).compareTo(scheduledEndTime) <= 0is well over 100 columns. A wrap that lands before<= 0yields a generated entity that does not compile. The same applies to(t != null) == gatefor presentIff and to!gate || t != nullfor requiredWhen. The shared corpus and unit tests use short names and never reach the wrap column. Smallest fix: emitreturn (%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.ValidationConformanceTestalready 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-fieldmodel_validatorcompares raw field values withself.<l> <op> self.<r>.timestampmaps to plaindatetime.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 avalidator.comparisonlt over those fields raisesTypeError: can't compare offset-naive and offset-aware datetimesinside 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 intry/except TypeErrorand raiseValueError(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. Thea validator referencing a missing field is skipped silentlytest only assertsrenderZodValidators(e).toString()does not containsuperRefine. The first test assertstoContain(".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 assertEntityInsertSchema.safeParse({a: 5}).success === true. For the update schema, rely on the PATCH execution pin invalidation-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 approvedBywithapprovedByreadOnly and@when status @equals APPROVED. A POST withstatus=APPROVEDgets 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.ktrules(uses allobj.metaFields) and EntityGenerator.csCrossFieldValidateMethod.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_modelends withassert 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 thatEventCreaterejects a violating body whileEventPatchaccepts 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) referenceKotlinCrossFieldRulesby bare same-package name, without the by-name import every sibling helper has (lines 9-16 of each file). GeneratorRegistryejectWithnow copies KotlinCrossFieldRules.kt with theentityandroutesgenerators, 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: addimport com.metaobjects.generator.kotlin.KotlinCrossFieldRulesto 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 adatefield and atimestampfield 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-patchIsUnchangedByCrossFieldValidatorsreads the generatedEvPatch.javawith 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}) toacme.ev.EvPatch, and assert Bean Validation reports 0 violations, while the same body bound toEvDtoreports 1. The existingviolations(...)andcompile(...)helpers already do most of this.scripts/ci-local.sh --only ts-fast --only ts-unit --strict-toolchainsmeta init / meta gen (entityFile, queriesFile, barrel) on fixtures/validation-conformance/meta.cross-field.json in /tmp/nmv/tsbun run /tmp/nmv/ts/run-cases.tspython3 -m metaobjects gen (entity) then python3 /tmp/nmv/py/run_cases.pybun 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.