From dcc4936b842bbfc9ee2d9744e718683679858ea2 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 3 Nov 2026 21:04:38 -0500 Subject: [PATCH 01/10] feat: generated record models enforce cross-field validators in every 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. --- CHANGELOG.md | 16 ++ .../skills/metaobjects-authoring/SKILL.md | 6 +- docs/CONFORMANCE.md | 2 +- docs/features/entities.md | 22 ++ ...ion-enforcement-and-fr-035-release-gaps.md | 17 +- .../codegen/templates/zod-validators.ts | 149 ++++++++++- .../codegen/templates/zod-validators.ts | 149 ++++++++++- .../skills/metaobjects-authoring/SKILL.md | 6 +- .../skills/metaobjects-authoring/SKILL.md | 6 +- .../skills/metaobjects-authoring/SKILL.md | 6 +- .../skills/metaobjects-authoring/SKILL.md | 6 +- .../skills/metaobjects-authoring/SKILL.md | 6 +- fixtures/validation-conformance/README.md | 22 ++ .../cross-field-cases.json | 250 ++++++++++++++++++ .../meta.cross-field.json | 30 +++ .../CrossFieldValidationConformanceTests.cs | 125 +++++++++ .../CrossFieldValidatorGenerationTests.cs | 160 +++++++++++ .../Generators/EntityGenerator.cs | 235 ++++++++++++++++ .../generator/kotlin/KotlinEntityGenerator.kt | 120 +++++++++ .../kotlin/KotlinCrossFieldValidationTest.kt | 66 +++++ .../kotlin/ValidationConformanceTest.kt | 19 +- .../generator/spring/SpringDtoGenerator.java | 11 +- .../spring/SpringGeneratorSupport.java | 123 +++++++++ .../SpringCrossFieldValidationTest.java | 88 ++++++ .../spring/ValidationConformanceTest.java | 36 ++- .../codegen/generators/entity_model.py | 112 ++++++++ .../codegen/test_validation_conformance.py | 55 ++++ .../src/templates/form-file.ts | 7 +- .../src/templates/zod-validators.ts | 149 ++++++++++- .../codegen-ts/test/zod-cross-field.test.ts | 121 +++++++++ .../integration-tests/src/validation-cases.ts | 8 + ...validation-conformance-cross-field.test.ts | 69 +++++ .../src/templates/zod-validators.ts | 149 ++++++++++- 33 files changed, 2294 insertions(+), 52 deletions(-) create mode 100644 fixtures/validation-conformance/cross-field-cases.json create mode 100644 fixtures/validation-conformance/meta.cross-field.json create mode 100644 server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs create mode 100644 server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs create mode 100644 server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldValidationTest.kt create mode 100644 server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringCrossFieldValidationTest.java create mode 100644 server/typescript/packages/codegen-ts/test/zod-cross-field.test.ts create mode 100644 server/typescript/packages/integration-tests/test/validation-conformance-cross-field.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b13247ac0..e4f32ce763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,22 @@ here.** ## [Unreleased] +### Added + +- **Generated record models enforce the four cross-field validators in every port.** + `validator.comparison`, `validator.requiredWhen`, `validator.presentIff` and + `validator.atLeastOne` used to render only as DB `CHECK` constraints, so a store with no `CHECK` + support (a graph or document database) enforced none of them and the application had to repeat the + rules in its writer. The full create/insert model now enforces them with the language's native + mechanism: a Zod refinement on the insert schema (TypeScript), a class-level Bean Validation + constraint on the create DTO (Java, Kotlin), `IValidatableObject` (C#) and a Pydantic + `model_validator` (Python). Semantics mirror the `CHECK` derivation: null-safe, `comparison` passes + when either side is null, `@equals` is read per the gating field's subtype. PATCH and + partial-update models are unchanged (a partial body cannot see the merged row, so the database + `CHECK` remains the enforcement there), and `CHECK` emission is unchanged. A record that violates a + declared rule is now rejected at the model where it used to reach the database. Gated by + `fixtures/validation-conformance/cross-field-cases.json` in all five ports. + ### Fixed - **`meta migrate` refuses, at plan time, to drop a column a view it does not own still reads.** diff --git a/agent-context/skills/metaobjects-authoring/SKILL.md b/agent-context/skills/metaobjects-authoring/SKILL.md index a1e709017f..008e5dae1b 100644 --- a/agent-context/skills/metaobjects-authoring/SKILL.md +++ b/agent-context/skills/metaobjects-authoring/SKILL.md @@ -426,7 +426,11 @@ behavior: Entity-scoped `validator.*` children declare invariants that reference sibling fields **by name** (the same name-reference pattern as `identity.*`). The backend derives the -enforcement (a CHECK constraint / cross-field assertion) — no raw expression is stored. +enforcement — no raw expression is stored. A DB `CHECK` constraint is emitted by `meta migrate`, +and the generated full create/record model also enforces the rule in every port (Zod refinement, +Bean Validation class-level constraint, `IValidatableObject`, Pydantic `model_validator`), so a +store with no `CHECK` support is still covered. PATCH and partial-update models do not enforce them +(a partial body cannot see the merged row); the DB `CHECK` is the enforcement there. | Subtype | Rule | Key attrs | |---|---|---| diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index d7e9157567..32209dec2b 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -38,7 +38,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 59 (53 query + 6 migration) | all 59; the 24 `report-*` and the `-15` decimal scenarios also on SQLite, MySQL and D1, `decimal-exact` also on MySQL (a scenario's `engines:` key is exact) | 53 query (migrations TS-only, ADR-0015); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (via Exposed); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query; the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (pg8000); the `report-*` and `engines:` scenarios also on SQLite (`sqlite3`) and MySQL (PyMySQL) | | [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 119 (31 core + 10 tph + 9 m2m + 5 jsonb + 2 write-through + 2 secret-fields + 12 projection + 37 report + 11 decimal) | ✓ (Fastify reference + generated lane); `report/` and `decimal/` also on SQLite, MySQL and D1 | ✓ (embedded HTTP + JDBC); `report/` and `decimal/` generated controllers over OMDB on Postgres, SQLite and MySQL | ✓ (embedded HTTP + Exposed); `report/` and `decimal/` also on SQLite and MySQL | ✓ (Kestrel + Npgsql); `report/` and `decimal/` also on SQLite and MySQL | ✓ (FastAPI + pg8000); `report/` and `decimal/` also on SQLite and MySQL | | [`fixtures/decimal-wire/`](../fixtures/decimal-wire/) | 1 vector file (format, parse, filter operand, 15-digit read) | ✓ (`runtime-ts` and the generated helper) | ✓ (`CanonicalDecimal`) | ✓ (the generated wire file) | ✓ (`DecimalWire`) | ✓ (`decimal_wire`) | -| [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) | +| [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases + 19 cross-field cases (`cross-field-cases.json`, generated record model only) | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) | | [`fixtures/registry-conformance/`](../fixtures/registry-conformance/) | 1 canonical manifest | ✓ (reference emitter) | ✓ | ✓ | ✓ | ✓ | | [`fixtures/object-model-conformance/`](../fixtures/object-model-conformance/) | 1 shared metadata fixture (per-port scenarios) | ✓ | ✓ | ✓ | ✓ | ✓ | | [`fixtures/codegen-conformance/`](../fixtures/codegen-conformance/) | 6 shared inputs; `shared-provided-enum` carries the enum-type decision table and `cross-package-binding` the reference-binding decision table (ADR-0059) every port answers | ✓ | ✓ | ✓ | ✓ | ✓ | diff --git a/docs/features/entities.md b/docs/features/entities.md index 74f96a2a36..5247997235 100644 --- a/docs/features/entities.md +++ b/docs/features/entities.md @@ -319,6 +319,28 @@ row = om.create("Author", {"name": "Ada", "bio": None}) found = om.find_by_id("Author", row["id"]) ``` +## Cross-field validators + +`validator.comparison`, `validator.requiredWhen`, `validator.presentIff` and `validator.atLeastOne` +are declared as children of the entity and reference sibling fields by name. They are enforced in +two places: + +- **The generated full record (create) model**, in every port, with the language's native + mechanism: a Zod refinement on the insert schema (TypeScript), a class-level Bean Validation + constraint on the create DTO (Java, Kotlin), `IValidatableObject` (C#) and a Pydantic + `model_validator` (Python). A store with no `CHECK` support, such as a graph or document + database, is therefore still covered. +- **A database `CHECK` constraint**, derived by `meta migrate`. + +The two share one set of semantics: absent or null is SQL `NULL`; `comparison` passes when either +side is null; `requiredWhen` is violated only when the gate equals `@equals` and the target is null; +`presentIff` requires the target to be present exactly when the gate equals `@equals`; `atLeastOne` +needs one non-null field. `@equals` is read per the gating field's subtype. The shared cases in +`fixtures/validation-conformance/cross-field-cases.json` gate all five ports. + +PATCH and partial-update models do **not** enforce these rules, because a partial body cannot see +the merged row. The database `CHECK` is the enforcement there. + ## Verified by The following conformance fixtures gate this feature's behavior across ports: diff --git a/docs/superpowers/specs/2026-07-14-fr-036-constraint-validation-enforcement-and-fr-035-release-gaps.md b/docs/superpowers/specs/2026-07-14-fr-036-constraint-validation-enforcement-and-fr-035-release-gaps.md index 728d593ac4..c03e51005a 100644 --- a/docs/superpowers/specs/2026-07-14-fr-036-constraint-validation-enforcement-and-fr-035-release-gaps.md +++ b/docs/superpowers/specs/2026-07-14-fr-036-constraint-validation-enforcement-and-fr-035-release-gaps.md @@ -50,10 +50,21 @@ has no empty-string case):** (no anchors) accepts `"xxABCyy"` on TS/Python, rejects on Java/Kotlin/C#. Ruling 2 pins **full-match**. **Field-scoped vs entity-scoped split is correct and load-bearing:** cross-field validators -(`comparison`, `presentIff`, `requiredWhen`, `atLeastOne`) render ONLY as TS-owned DB CHECK +(`comparison`, `presentIff`, `requiredWhen`, `atLeastOne`) were rendered ONLY as TS-owned DB CHECK constraints (`migrate-ts/src/expected-schema.ts`), never as jakarta/Zod — so they evaluate the MERGED -row after UPDATE and hold under PATCH with zero handler work. Do NOT add cross-field validation to any -port's handler. Per-field only. +row after UPDATE and hold under PATCH with zero handler work. PATCH and partial-update handlers still +add nothing: a partial body cannot see the merged row, so the DB CHECK stays the enforcement there. + +> **Amended (Unreleased) — record models now enforce cross-field rules.** The original reading above +> ("DB CHECK only") left a store with no CHECK constraints (a graph or document database, say) with +> no enforcement of these four validators at all. Every port's generated **full record model** — the +> create/insert shape — now also enforces them, in the language's native idiom: a Zod refinement on +> the insert schema (TS), a class-level Bean Validation constraint on the create DTO (Java, Kotlin), +> `IValidatableObject` on the create model (C#) and a Pydantic `model_validator(mode="after")` +> (Python). The semantics mirror the DB CHECK derivation exactly (null-safe; `comparison` passes when +> either side is null; `@equals` rendered per the gating field's subtype) and are gated by the shared +> `fixtures/validation-conformance/cross-field-cases.json` cases in all five port runners. PATCH and +> partial-update models are deliberately unchanged, and DB CHECK emission is unchanged. --- diff --git a/examples/advanced-modeling/codegen/templates/zod-validators.ts b/examples/advanced-modeling/codegen/templates/zod-validators.ts index ea4945feae..c9fe7b03dc 100644 --- a/examples/advanced-modeling/codegen/templates/zod-validators.ts +++ b/examples/advanced-modeling/codegen/templates/zod-validators.ts @@ -28,6 +28,10 @@ import { FIELD_ATTR_DB_COLUMN_TYPE, DB_COLUMN_TYPE_JSONB, AUTO_SET_ON_CREATE, AUTO_SET_ON_UPDATE, VALIDATOR_ATTR_MAX, VALIDATOR_ATTR_MIN, VALIDATOR_ATTR_PATTERN, + VALIDATOR_SUBTYPE_COMPARISON, VALIDATOR_SUBTYPE_REQUIRED_WHEN, + VALIDATOR_SUBTYPE_PRESENT_IFF, VALIDATOR_SUBTYPE_AT_LEAST_ONE, + VALIDATOR_ATTR_LEFT, VALIDATOR_ATTR_OP, VALIDATOR_ATTR_RIGHT, + VALIDATOR_ATTR_FIELD, VALIDATOR_ATTR_WHEN, VALIDATOR_ATTR_EQUALS, VALIDATOR_ATTR_FIELDS, GENERATION_INCREMENT, GENERATION_UUID, OBJECT_ATTR_DISCRIMINATOR, OBJECT_ATTR_DISCRIMINATOR_VALUE, OBJECT_SUBTYPE_VALUE, @@ -256,6 +260,128 @@ function isInsertRequiredPk(field: MetaField, assignedPk: Set): boolean return field.attr(FIELD_ATTR_DEFAULT) === undefined; } +// ── Entity-scoped cross-field validators on the full (insert) record ────────── +// validator.comparison / requiredWhen / presentIff / atLeastOne are enforced on the +// INSERT-shaped schemas only, as one `.superRefine`. A PATCH body cannot see the merged +// row, so the Update schema is deliberately left alone (the DB CHECK stays the guard +// there). Semantics mirror the migrate-ts CHECK derivation: absent / null / undefined is +// SQL NULL, "" is NOT null; a comparison with a NULL side passes; requiredWhen is violated +// only when the gate EQUALS the literal and the target is null; presentIff requires +// (target != null) === (gate equals literal), a null gate being "not equal". + +const CROSS_FIELD_OPS: Record = { + gt: ">", gte: ">=", lt: "<", lte: "<=", ne: "!==", eq: "===", +}; + +const NUMERIC_SUBTYPES: ReadonlySet = new Set([ + FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, + FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_CURRENCY, +]); + +/** Names of the fields the INSERT-shaped schemas carry (same skip rules as the emitters). */ +function insertSchemaFieldNameSet(obj: MetaObject): Set { + const autoGen = autoGenPkFieldNames(obj); + const out = new Set(); + for (const f of obj.fields()) { + if (autoGen.has(f.name)) continue; + if (isReadOnlyMutability(f) || f.isDerived()) continue; + out.add(f.name); + } + return out; +} + +/** The @equals literal as a TS expression, typed by the gating field's subtype. */ +function crossFieldLiteral(raw: unknown, when: MetaField): { lit: string; numeric: boolean } { + const s = String(raw); + if (when.subType === FIELD_SUBTYPE_BOOLEAN) { + return { lit: s === "true" || s === "1" || s === "TRUE" ? "true" : "false", numeric: false }; + } + if (NUMERIC_SUBTYPES.has(when.subType) && /^-?\d+(\.\d+)?$/.test(s)) return { lit: s, numeric: true }; + return { lit: JSON.stringify(s), numeric: false }; +} + +/** Does this object declare at least one cross-field validator that will be enforced + * on its insert schema? Exported so the form emitter can tell a refined schema + * (which Zod will not `.omit()`) from a plain object. */ +export function hasInsertRefinements(obj: MetaObject): boolean { + return crossFieldRefinementBody(obj).length > 0; +} + +function crossFieldRefinementBody(obj: MetaObject): string[] { + const inSchema = insertSchemaFieldNameSet(obj); + const byName = new Map(obj.fields().map((f) => [f.name, f] as const)); + // A reference must name a field that exists AND is in the insert schema; otherwise skip + // the validator (migrate skips on a missing field; a readOnly/derived/auto-PK field is + // never in the body, so enforcing on it would reject every create). + const ref = (name: unknown): MetaField | undefined => { + if (typeof name !== "string" || !inSchema.has(name)) return undefined; + return byName.get(name); + }; + const acc = (f: MetaField) => `row[${JSON.stringify(f.name)}]`; + const issue = (path: string, msg: string) => + `ctx.addIssue({ code: "custom", path: [${path}], message: ${JSON.stringify(msg)} });`; + const blocks: string[] = []; + // ADR-0039: entity.validators() is the resolving accessor (never ownValidators). + for (const v of obj.validators()) { + switch (v.subType) { + case VALIDATOR_SUBTYPE_COMPARISON: { + const left = ref(v.attr(VALIDATOR_ATTR_LEFT)); + const right = ref(v.attr(VALIDATOR_ATTR_RIGHT)); + const opName = String(v.attr(VALIDATOR_ATTR_OP)); + const op = CROSS_FIELD_OPS[opName]; + if (!left || !right || !op) break; + const numeric = NUMERIC_SUBTYPES.has(left.subType); + const c = numeric + ? "Number(l) - Number(r)" + : "l instanceof Date && r instanceof Date ? l.getTime() - r.getTime() : String(l) < String(r) ? -1 : String(l) > String(r) ? 1 : 0"; + blocks.push( + ` {\n const l: unknown = ${acc(left)};\n const r: unknown = ${acc(right)};\n` + + ` if (l != null && r != null) {\n const c = ${c};\n` + + ` if (!(c ${op} 0)) ${issue(JSON.stringify(left.name), `${left.name} must be ${opName} ${right.name}`)}\n }\n }`, + ); + break; + } + case VALIDATOR_SUBTYPE_REQUIRED_WHEN: + case VALIDATOR_SUBTYPE_PRESENT_IFF: { + const target = ref(v.attr(VALIDATOR_ATTR_FIELD)); + const when = ref(v.attr(VALIDATOR_ATTR_WHEN)); + if (!target || !when) break; + const { lit, numeric } = crossFieldLiteral(v.attr(VALIDATOR_ATTR_EQUALS), when); + const gate = numeric ? `g != null && Number(g) === ${lit}` : `g === ${lit}`; + const path = JSON.stringify(target.name); + const decl = ` const g: unknown = ${acc(when)};\n const t: unknown = ${acc(target)};\n const open = ${gate};\n`; + if (v.subType === VALIDATOR_SUBTYPE_REQUIRED_WHEN) { + blocks.push(` {\n${decl} if (open && t == null) ${issue(path, `${target.name} is required when ${when.name} is ${String(v.attr(VALIDATOR_ATTR_EQUALS))}`)}\n }`); + } else { + blocks.push(` {\n${decl} if ((t != null) !== open) ${issue(path, `${target.name} must be present if and only if ${when.name} is ${String(v.attr(VALIDATOR_ATTR_EQUALS))}`)}\n }`); + } + break; + } + case VALIDATOR_SUBTYPE_AT_LEAST_ONE: { + const raw = v.attr(VALIDATOR_ATTR_FIELDS); + const names = Array.isArray(raw) ? raw : (typeof raw === "string" ? [raw] : []); + const refs = names.map(ref); + if (refs.length === 0 || refs.some((r) => r === undefined)) break; + const fields = refs as MetaField[]; + const anyPresent = fields.map((f) => `${acc(f)} != null`).join(" || "); + blocks.push(` if (!(${anyPresent})) ${issue(JSON.stringify(fields[0]!.name), `at least one of ${fields.map((f) => f.name).join(", ")} is required`)}`); + break; + } + default: + break; + } + } + return blocks; +} + +/** `.superRefine(...)` suffix for an insert-shaped schema, or "" when the object has no + * enforceable cross-field validator (output is then byte-identical to before). */ +function insertRefinementSuffix(obj: MetaObject): string { + const blocks = crossFieldRefinementBody(obj); + if (blocks.length === 0) return ""; + return `.superRefine((row, ctx) => {\n${blocks.join("\n")}\n})`; +} + /** * Emit ONLY the `InsertSchema`. Used by the value-object file emitter * for metaobjects with no writable source.rdb — those have no PATCH/update @@ -322,7 +448,7 @@ export function renderInsertSchemaOnly(obj: MetaObject, ctx?: RenderContext): Co return code` ${utcTimestampHelperSince(utcUsesBefore)}${decimalHelperSince(decimalUsesBefore)}${docsPrefix}export const ${insertSchemaName} = ${z}.object({ ${joinCode(insertFieldLines, { on: ",\n" })} -}); +})${insertRefinementSuffix(obj)}; `; } @@ -535,6 +661,21 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code const docs = renderDocsFor(obj); const docsPrefix = docs ? `${docs}\n` : ""; + // A Zod object carrying refinements cannot be `.omit()`ed, and a TPH subtype's form + // omits the pinned discriminator. So when both apply, the unrefined shape is also + // exported as `InsertBaseSchema` for the form to omit from; the refined + // `InsertSchema` is built on it. + const refineSuffix = insertRefinementSuffix(obj); + const insertDecl = refineSuffix !== "" && tphPin !== undefined + ? code`${docsPrefix}export const ${objName}InsertBaseSchema = ${z}.object({ +${joinCode(insertFieldLines, { on: ",\n" })} +}); + +export const ${insertSchemaName} = ${objName}InsertBaseSchema${refineSuffix};` + : code`${docsPrefix}export const ${insertSchemaName} = ${z}.object({ +${joinCode(insertFieldLines, { on: ",\n" })} +})${refineSuffix};`; + // #203 — emit the preserving insert-shape only for @autoSet entities (nothing to // preserve otherwise). Backs `insertPreserving` (import/restore/replication). const preservingBlock = emitPreserving @@ -545,16 +686,14 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code * (no create-time now() stamp) so the caller's original values are preserved. */ export const ${preservingSchemaName} = ${z}.object({ ${joinCode(preservingFieldLines, { on: ",\n" })} -}); +})${insertRefinementSuffix(obj)}; /** Typed input of \`insertPreserving${objName}\` — ${preservingSchemaName}'s pre-transform shape. */ export type ${objName}CreatePreserving = ${z}.input;` : code``; return code` -${utcTimestampHelperSince(utcUsesBefore)}${decimalHelperSince(decimalUsesBefore)}${docsPrefix}export const ${insertSchemaName} = ${z}.object({ -${joinCode(insertFieldLines, { on: ",\n" })} -}); +${utcTimestampHelperSince(utcUsesBefore)}${decimalHelperSince(decimalUsesBefore)}${insertDecl} ${docsPrefix}export const ${updateSchemaName} = ${z}.object({ ${joinCode(updateFieldLines, { on: ",\n" })} diff --git a/examples/showcase/codegen/templates/zod-validators.ts b/examples/showcase/codegen/templates/zod-validators.ts index ea4945feae..c9fe7b03dc 100644 --- a/examples/showcase/codegen/templates/zod-validators.ts +++ b/examples/showcase/codegen/templates/zod-validators.ts @@ -28,6 +28,10 @@ import { FIELD_ATTR_DB_COLUMN_TYPE, DB_COLUMN_TYPE_JSONB, AUTO_SET_ON_CREATE, AUTO_SET_ON_UPDATE, VALIDATOR_ATTR_MAX, VALIDATOR_ATTR_MIN, VALIDATOR_ATTR_PATTERN, + VALIDATOR_SUBTYPE_COMPARISON, VALIDATOR_SUBTYPE_REQUIRED_WHEN, + VALIDATOR_SUBTYPE_PRESENT_IFF, VALIDATOR_SUBTYPE_AT_LEAST_ONE, + VALIDATOR_ATTR_LEFT, VALIDATOR_ATTR_OP, VALIDATOR_ATTR_RIGHT, + VALIDATOR_ATTR_FIELD, VALIDATOR_ATTR_WHEN, VALIDATOR_ATTR_EQUALS, VALIDATOR_ATTR_FIELDS, GENERATION_INCREMENT, GENERATION_UUID, OBJECT_ATTR_DISCRIMINATOR, OBJECT_ATTR_DISCRIMINATOR_VALUE, OBJECT_SUBTYPE_VALUE, @@ -256,6 +260,128 @@ function isInsertRequiredPk(field: MetaField, assignedPk: Set): boolean return field.attr(FIELD_ATTR_DEFAULT) === undefined; } +// ── Entity-scoped cross-field validators on the full (insert) record ────────── +// validator.comparison / requiredWhen / presentIff / atLeastOne are enforced on the +// INSERT-shaped schemas only, as one `.superRefine`. A PATCH body cannot see the merged +// row, so the Update schema is deliberately left alone (the DB CHECK stays the guard +// there). Semantics mirror the migrate-ts CHECK derivation: absent / null / undefined is +// SQL NULL, "" is NOT null; a comparison with a NULL side passes; requiredWhen is violated +// only when the gate EQUALS the literal and the target is null; presentIff requires +// (target != null) === (gate equals literal), a null gate being "not equal". + +const CROSS_FIELD_OPS: Record = { + gt: ">", gte: ">=", lt: "<", lte: "<=", ne: "!==", eq: "===", +}; + +const NUMERIC_SUBTYPES: ReadonlySet = new Set([ + FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, + FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_CURRENCY, +]); + +/** Names of the fields the INSERT-shaped schemas carry (same skip rules as the emitters). */ +function insertSchemaFieldNameSet(obj: MetaObject): Set { + const autoGen = autoGenPkFieldNames(obj); + const out = new Set(); + for (const f of obj.fields()) { + if (autoGen.has(f.name)) continue; + if (isReadOnlyMutability(f) || f.isDerived()) continue; + out.add(f.name); + } + return out; +} + +/** The @equals literal as a TS expression, typed by the gating field's subtype. */ +function crossFieldLiteral(raw: unknown, when: MetaField): { lit: string; numeric: boolean } { + const s = String(raw); + if (when.subType === FIELD_SUBTYPE_BOOLEAN) { + return { lit: s === "true" || s === "1" || s === "TRUE" ? "true" : "false", numeric: false }; + } + if (NUMERIC_SUBTYPES.has(when.subType) && /^-?\d+(\.\d+)?$/.test(s)) return { lit: s, numeric: true }; + return { lit: JSON.stringify(s), numeric: false }; +} + +/** Does this object declare at least one cross-field validator that will be enforced + * on its insert schema? Exported so the form emitter can tell a refined schema + * (which Zod will not `.omit()`) from a plain object. */ +export function hasInsertRefinements(obj: MetaObject): boolean { + return crossFieldRefinementBody(obj).length > 0; +} + +function crossFieldRefinementBody(obj: MetaObject): string[] { + const inSchema = insertSchemaFieldNameSet(obj); + const byName = new Map(obj.fields().map((f) => [f.name, f] as const)); + // A reference must name a field that exists AND is in the insert schema; otherwise skip + // the validator (migrate skips on a missing field; a readOnly/derived/auto-PK field is + // never in the body, so enforcing on it would reject every create). + const ref = (name: unknown): MetaField | undefined => { + if (typeof name !== "string" || !inSchema.has(name)) return undefined; + return byName.get(name); + }; + const acc = (f: MetaField) => `row[${JSON.stringify(f.name)}]`; + const issue = (path: string, msg: string) => + `ctx.addIssue({ code: "custom", path: [${path}], message: ${JSON.stringify(msg)} });`; + const blocks: string[] = []; + // ADR-0039: entity.validators() is the resolving accessor (never ownValidators). + for (const v of obj.validators()) { + switch (v.subType) { + case VALIDATOR_SUBTYPE_COMPARISON: { + const left = ref(v.attr(VALIDATOR_ATTR_LEFT)); + const right = ref(v.attr(VALIDATOR_ATTR_RIGHT)); + const opName = String(v.attr(VALIDATOR_ATTR_OP)); + const op = CROSS_FIELD_OPS[opName]; + if (!left || !right || !op) break; + const numeric = NUMERIC_SUBTYPES.has(left.subType); + const c = numeric + ? "Number(l) - Number(r)" + : "l instanceof Date && r instanceof Date ? l.getTime() - r.getTime() : String(l) < String(r) ? -1 : String(l) > String(r) ? 1 : 0"; + blocks.push( + ` {\n const l: unknown = ${acc(left)};\n const r: unknown = ${acc(right)};\n` + + ` if (l != null && r != null) {\n const c = ${c};\n` + + ` if (!(c ${op} 0)) ${issue(JSON.stringify(left.name), `${left.name} must be ${opName} ${right.name}`)}\n }\n }`, + ); + break; + } + case VALIDATOR_SUBTYPE_REQUIRED_WHEN: + case VALIDATOR_SUBTYPE_PRESENT_IFF: { + const target = ref(v.attr(VALIDATOR_ATTR_FIELD)); + const when = ref(v.attr(VALIDATOR_ATTR_WHEN)); + if (!target || !when) break; + const { lit, numeric } = crossFieldLiteral(v.attr(VALIDATOR_ATTR_EQUALS), when); + const gate = numeric ? `g != null && Number(g) === ${lit}` : `g === ${lit}`; + const path = JSON.stringify(target.name); + const decl = ` const g: unknown = ${acc(when)};\n const t: unknown = ${acc(target)};\n const open = ${gate};\n`; + if (v.subType === VALIDATOR_SUBTYPE_REQUIRED_WHEN) { + blocks.push(` {\n${decl} if (open && t == null) ${issue(path, `${target.name} is required when ${when.name} is ${String(v.attr(VALIDATOR_ATTR_EQUALS))}`)}\n }`); + } else { + blocks.push(` {\n${decl} if ((t != null) !== open) ${issue(path, `${target.name} must be present if and only if ${when.name} is ${String(v.attr(VALIDATOR_ATTR_EQUALS))}`)}\n }`); + } + break; + } + case VALIDATOR_SUBTYPE_AT_LEAST_ONE: { + const raw = v.attr(VALIDATOR_ATTR_FIELDS); + const names = Array.isArray(raw) ? raw : (typeof raw === "string" ? [raw] : []); + const refs = names.map(ref); + if (refs.length === 0 || refs.some((r) => r === undefined)) break; + const fields = refs as MetaField[]; + const anyPresent = fields.map((f) => `${acc(f)} != null`).join(" || "); + blocks.push(` if (!(${anyPresent})) ${issue(JSON.stringify(fields[0]!.name), `at least one of ${fields.map((f) => f.name).join(", ")} is required`)}`); + break; + } + default: + break; + } + } + return blocks; +} + +/** `.superRefine(...)` suffix for an insert-shaped schema, or "" when the object has no + * enforceable cross-field validator (output is then byte-identical to before). */ +function insertRefinementSuffix(obj: MetaObject): string { + const blocks = crossFieldRefinementBody(obj); + if (blocks.length === 0) return ""; + return `.superRefine((row, ctx) => {\n${blocks.join("\n")}\n})`; +} + /** * Emit ONLY the `InsertSchema`. Used by the value-object file emitter * for metaobjects with no writable source.rdb — those have no PATCH/update @@ -322,7 +448,7 @@ export function renderInsertSchemaOnly(obj: MetaObject, ctx?: RenderContext): Co return code` ${utcTimestampHelperSince(utcUsesBefore)}${decimalHelperSince(decimalUsesBefore)}${docsPrefix}export const ${insertSchemaName} = ${z}.object({ ${joinCode(insertFieldLines, { on: ",\n" })} -}); +})${insertRefinementSuffix(obj)}; `; } @@ -535,6 +661,21 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code const docs = renderDocsFor(obj); const docsPrefix = docs ? `${docs}\n` : ""; + // A Zod object carrying refinements cannot be `.omit()`ed, and a TPH subtype's form + // omits the pinned discriminator. So when both apply, the unrefined shape is also + // exported as `InsertBaseSchema` for the form to omit from; the refined + // `InsertSchema` is built on it. + const refineSuffix = insertRefinementSuffix(obj); + const insertDecl = refineSuffix !== "" && tphPin !== undefined + ? code`${docsPrefix}export const ${objName}InsertBaseSchema = ${z}.object({ +${joinCode(insertFieldLines, { on: ",\n" })} +}); + +export const ${insertSchemaName} = ${objName}InsertBaseSchema${refineSuffix};` + : code`${docsPrefix}export const ${insertSchemaName} = ${z}.object({ +${joinCode(insertFieldLines, { on: ",\n" })} +})${refineSuffix};`; + // #203 — emit the preserving insert-shape only for @autoSet entities (nothing to // preserve otherwise). Backs `insertPreserving` (import/restore/replication). const preservingBlock = emitPreserving @@ -545,16 +686,14 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code * (no create-time now() stamp) so the caller's original values are preserved. */ export const ${preservingSchemaName} = ${z}.object({ ${joinCode(preservingFieldLines, { on: ",\n" })} -}); +})${insertRefinementSuffix(obj)}; /** Typed input of \`insertPreserving${objName}\` — ${preservingSchemaName}'s pre-transform shape. */ export type ${objName}CreatePreserving = ${z}.input;` : code``; return code` -${utcTimestampHelperSince(utcUsesBefore)}${decimalHelperSince(decimalUsesBefore)}${docsPrefix}export const ${insertSchemaName} = ${z}.object({ -${joinCode(insertFieldLines, { on: ",\n" })} -}); +${utcTimestampHelperSince(utcUsesBefore)}${decimalHelperSince(decimalUsesBefore)}${insertDecl} ${docsPrefix}export const ${updateSchemaName} = ${z}.object({ ${joinCode(updateFieldLines, { on: ",\n" })} diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index a1e709017f..008e5dae1b 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -426,7 +426,11 @@ behavior: Entity-scoped `validator.*` children declare invariants that reference sibling fields **by name** (the same name-reference pattern as `identity.*`). The backend derives the -enforcement (a CHECK constraint / cross-field assertion) — no raw expression is stored. +enforcement — no raw expression is stored. A DB `CHECK` constraint is emitted by `meta migrate`, +and the generated full create/record model also enforces the rule in every port (Zod refinement, +Bean Validation class-level constraint, `IValidatableObject`, Pydantic `model_validator`), so a +store with no `CHECK` support is still covered. PATCH and partial-update models do not enforce them +(a partial body cannot see the merged row); the DB `CHECK` is the enforcement there. | Subtype | Rule | Key attrs | |---|---|---| diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md index a1e709017f..008e5dae1b 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -426,7 +426,11 @@ behavior: Entity-scoped `validator.*` children declare invariants that reference sibling fields **by name** (the same name-reference pattern as `identity.*`). The backend derives the -enforcement (a CHECK constraint / cross-field assertion) — no raw expression is stored. +enforcement — no raw expression is stored. A DB `CHECK` constraint is emitted by `meta migrate`, +and the generated full create/record model also enforces the rule in every port (Zod refinement, +Bean Validation class-level constraint, `IValidatableObject`, Pydantic `model_validator`), so a +store with no `CHECK` support is still covered. PATCH and partial-update models do not enforce them +(a partial body cannot see the merged row); the DB `CHECK` is the enforcement there. | Subtype | Rule | Key attrs | |---|---|---| diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md index a1e709017f..008e5dae1b 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -426,7 +426,11 @@ behavior: Entity-scoped `validator.*` children declare invariants that reference sibling fields **by name** (the same name-reference pattern as `identity.*`). The backend derives the -enforcement (a CHECK constraint / cross-field assertion) — no raw expression is stored. +enforcement — no raw expression is stored. A DB `CHECK` constraint is emitted by `meta migrate`, +and the generated full create/record model also enforces the rule in every port (Zod refinement, +Bean Validation class-level constraint, `IValidatableObject`, Pydantic `model_validator`), so a +store with no `CHECK` support is still covered. PATCH and partial-update models do not enforce them +(a partial body cannot see the merged row); the DB `CHECK` is the enforcement there. | Subtype | Rule | Key attrs | |---|---|---| diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index a1e709017f..008e5dae1b 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -426,7 +426,11 @@ behavior: Entity-scoped `validator.*` children declare invariants that reference sibling fields **by name** (the same name-reference pattern as `identity.*`). The backend derives the -enforcement (a CHECK constraint / cross-field assertion) — no raw expression is stored. +enforcement — no raw expression is stored. A DB `CHECK` constraint is emitted by `meta migrate`, +and the generated full create/record model also enforces the rule in every port (Zod refinement, +Bean Validation class-level constraint, `IValidatableObject`, Pydantic `model_validator`), so a +store with no `CHECK` support is still covered. PATCH and partial-update models do not enforce them +(a partial body cannot see the merged row); the DB `CHECK` is the enforcement there. | Subtype | Rule | Key attrs | |---|---|---| diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md index a1e709017f..008e5dae1b 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -426,7 +426,11 @@ behavior: Entity-scoped `validator.*` children declare invariants that reference sibling fields **by name** (the same name-reference pattern as `identity.*`). The backend derives the -enforcement (a CHECK constraint / cross-field assertion) — no raw expression is stored. +enforcement — no raw expression is stored. A DB `CHECK` constraint is emitted by `meta migrate`, +and the generated full create/record model also enforces the rule in every port (Zod refinement, +Bean Validation class-level constraint, `IValidatableObject`, Pydantic `model_validator`), so a +store with no `CHECK` support is still covered. PATCH and partial-update models do not enforce them +(a partial body cannot see the merged row); the DB `CHECK` is the enforcement there. | Subtype | Rule | Key attrs | |---|---|---| diff --git a/fixtures/validation-conformance/README.md b/fixtures/validation-conformance/README.md index 0bb31d696b..9b577dd312 100644 --- a/fixtures/validation-conformance/README.md +++ b/fixtures/validation-conformance/README.md @@ -111,6 +111,28 @@ FR-036 pin cases (the two semantic pins + the length-precedence pin): absent `note` is still rejected. No port may apply the Pin 1 floor (min 1) when an author has explicitly said `@min: 0`. +## Cross-field validators — `meta.cross-field.json` + `cross-field-cases.json` + +A second, separate pair of files pins the four entity-scoped cross-field validators +(`validator.comparison`, `validator.requiredWhen`, `validator.presentIff`, +`validator.atLeastOne`) on the generated **full record (create) model**. They are separate files +so the run-time runners, which read `cases.json`, are unaffected. The entity is `Event`; every field +is optional, so absence is expressible in every port. + +The semantics mirror the DB `CHECK` derivation in `migrate-ts/src/expected-schema.ts`, with an absent +or null value standing for SQL `NULL` (and `""` NOT null): + +- `comparison` (`startsAt lte endsAt`): passes when either side is null. +- `requiredWhen`: violated only when the gating field equals `@equals` and the target is null; a null + gate is unconstrained. Both a string gate (`kind = BIRTH`) and a boolean gate (`flag = true`). +- `presentIff`: the target is present exactly when the gate equals `@equals`; a null gate counts as + not equal, so the target must be absent. +- `atLeastOne` (`email`, `phone`): at least one is non-null. + +Each case violates at most one validator (single-violation design). PATCH and partial-update models +are NOT covered here by design: they do not enforce cross-field rules, and the DB `CHECK` stays the +enforcement for them. + ## `field.uri` / `field.inet` strict contract (#234, ADR-0037 — Contract A) `Account` carries two OPTIONAL fields — `website` (`field.uri`) and `sourceIp` diff --git a/fixtures/validation-conformance/cross-field-cases.json b/fixtures/validation-conformance/cross-field-cases.json new file mode 100644 index 0000000000..8374d727fb --- /dev/null +++ b/fixtures/validation-conformance/cross-field-cases.json @@ -0,0 +1,250 @@ +{ + "cases": [ + { + "name": "cross-valid-baseline", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "kind": "BIRTH", + "subject": "p1", + "state": "OPEN", + "email": "a@b.c" + }, + "expectValid": true + }, + { + "name": "comparison-violated", + "entity": "Event", + "payload": { + "startsAt": 5, + "endsAt": 2, + "kind": "BIRTH", + "subject": "p1", + "state": "OPEN", + "email": "a@b.c" + }, + "expectValid": false + }, + { + "name": "comparison-equal-boundary", + "entity": "Event", + "payload": { + "startsAt": 2, + "endsAt": 2, + "kind": "BIRTH", + "subject": "p1", + "state": "OPEN", + "email": "a@b.c" + }, + "expectValid": true + }, + { + "name": "comparison-null-right-passes", + "entity": "Event", + "payload": { + "startsAt": 1, + "kind": "BIRTH", + "subject": "p1", + "state": "OPEN", + "email": "a@b.c" + }, + "expectValid": true + }, + { + "name": "comparison-null-left-passes", + "entity": "Event", + "payload": { + "endsAt": 2, + "kind": "BIRTH", + "subject": "p1", + "state": "OPEN", + "email": "a@b.c" + }, + "expectValid": true + }, + { + "name": "required-when-violated", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "kind": "BIRTH", + "state": "OPEN", + "email": "a@b.c" + }, + "expectValid": false + }, + { + "name": "required-when-other-value-unconstrained", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "kind": "DEATH", + "state": "OPEN", + "email": "a@b.c" + }, + "expectValid": true + }, + { + "name": "required-when-gate-null-unconstrained", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "state": "OPEN", + "email": "a@b.c" + }, + "expectValid": true + }, + { + "name": "required-when-boolean-violated", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "kind": "BIRTH", + "subject": "p1", + "state": "OPEN", + "email": "a@b.c", + "flag": true + }, + "expectValid": false + }, + { + "name": "required-when-boolean-satisfied", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "kind": "BIRTH", + "subject": "p1", + "state": "OPEN", + "email": "a@b.c", + "flag": true, + "flagNote": "why" + }, + "expectValid": true + }, + { + "name": "required-when-boolean-false-unconstrained", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "kind": "BIRTH", + "subject": "p1", + "state": "OPEN", + "email": "a@b.c", + "flag": false + }, + "expectValid": true + }, + { + "name": "present-iff-missing-target", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "kind": "BIRTH", + "subject": "p1", + "state": "CLOSED", + "email": "a@b.c" + }, + "expectValid": false + }, + { + "name": "present-iff-forbidden-target", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "kind": "BIRTH", + "subject": "p1", + "state": "OPEN", + "email": "a@b.c", + "closedNote": "stray" + }, + "expectValid": false + }, + { + "name": "present-iff-satisfied", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "kind": "BIRTH", + "subject": "p1", + "state": "CLOSED", + "email": "a@b.c", + "closedNote": "done" + }, + "expectValid": true + }, + { + "name": "present-iff-gate-null-forbids-target", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "kind": "BIRTH", + "subject": "p1", + "email": "a@b.c", + "closedNote": "stray" + }, + "expectValid": false + }, + { + "name": "present-iff-both-absent", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "kind": "BIRTH", + "subject": "p1", + "email": "a@b.c" + }, + "expectValid": true + }, + { + "name": "at-least-one-violated", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "kind": "BIRTH", + "subject": "p1", + "state": "OPEN" + }, + "expectValid": false + }, + { + "name": "at-least-one-second-field", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "kind": "BIRTH", + "subject": "p1", + "state": "OPEN", + "phone": "555" + }, + "expectValid": true + }, + { + "name": "at-least-one-both", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "kind": "BIRTH", + "subject": "p1", + "state": "OPEN", + "email": "a@b.c", + "phone": "555" + }, + "expectValid": true + } + ] +} diff --git a/fixtures/validation-conformance/meta.cross-field.json b/fixtures/validation-conformance/meta.cross-field.json new file mode 100644 index 0000000000..fe47239063 --- /dev/null +++ b/fixtures/validation-conformance/meta.cross-field.json @@ -0,0 +1,30 @@ +{ + "metadata.root": { + "package": "acme::events", + "children": [ + { "object.entity": { + "name": "Event", + "children": [ + { "source.rdb": { "@table": "events" } }, + { "field.long": { "name": "id" } }, + { "field.int": { "name": "startsAt" } }, + { "field.int": { "name": "endsAt" } }, + { "field.string": { "name": "kind" } }, + { "field.string": { "name": "subject" } }, + { "field.string": { "name": "state" } }, + { "field.string": { "name": "closedNote" } }, + { "field.boolean": { "name": "flag" } }, + { "field.string": { "name": "flagNote" } }, + { "field.string": { "name": "email" } }, + { "field.string": { "name": "phone" } }, + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } }, + { "validator.comparison": { "name": "startsBeforeEnds", "@left": "startsAt", "@op": "lte", "@right": "endsAt" } }, + { "validator.requiredWhen": { "name": "birthNeedsSubject", "@field": "subject", "@when": "kind", "@equals": "BIRTH" } }, + { "validator.requiredWhen": { "name": "flaggedNeedsNote", "@field": "flagNote", "@when": "flag", "@equals": "true" } }, + { "validator.presentIff": { "name": "closedIffNote", "@field": "closedNote", "@when": "state", "@equals": "CLOSED" } }, + { "validator.atLeastOne": { "name": "reachable", "@fields": ["email", "phone"] } } + ] + }} + ] + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs new file mode 100644 index 0000000000..68f6dc7189 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs @@ -0,0 +1,125 @@ +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using System.Text.Json; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using MetaObjects.Codegen; +using MetaObjects.Codegen.Generators; +using MetaObjects.Loader; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +/// +/// Cross-field validator gate (C# port). Runs the shared +/// fixtures/validation-conformance/cross-field-cases.json corpus through the GENERATED +/// Event record (compiled in-memory with Roslyn) exactly the way +/// does: bind the payload, then +/// +/// with validateAllProperties: true, which invokes once the +/// property-level attributes pass. +/// +public sealed class CrossFieldValidationConformanceTests +{ + private static string CorpusDir() + { + string root = AppContext.BaseDirectory; + while (!Directory.Exists(Path.Combine(root, "fixtures", "validation-conformance"))) + { + string? parent = Directory.GetParent(root)?.FullName; + if (parent is null || parent == root) + throw new InvalidOperationException("fixtures/validation-conformance not found"); + root = parent; + } + return Path.Combine(root, "fixtures", "validation-conformance"); + } + + private static JsonElement[] AllCases() + { + using JsonDocument doc = JsonDocument.Parse( + File.ReadAllText(Path.Combine(CorpusDir(), "cross-field-cases.json"))); + return doc.RootElement.GetProperty("cases").EnumerateArray().Select(c => c.Clone()).ToArray(); + } + + public static IEnumerable Cases() => + AllCases().Select(c => new object[] { c.GetProperty("name").GetString()! }); + + [Fact] + public void Discovers_all_cross_field_cases() => Assert.Equal(19, AllCases().Length); + + [Theory] + [MemberData(nameof(Cases))] + public void Generated_record_cross_field_validation_matches_expected_verdict(string caseName) + { + JsonElement theCase = AllCases().Single(c => c.GetProperty("name").GetString() == caseName); + string entityName = theCase.TryGetProperty("entity", out JsonElement e) ? e.GetString()! : "Event"; + Type type = Generated.Asm.GetType($"Acme.Events.Generated.{entityName}")!; + bool expectValid = theCase.GetProperty("expectValid").GetBoolean(); + + bool valid; + var results = new List(); + try + { + object? instance = JsonSerializer.Deserialize(theCase.GetProperty("payload").GetRawText(), type, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true }); + valid = instance is not null && Validator.TryValidateObject( + instance, new ValidationContext(instance), results, validateAllProperties: true); + } + catch (JsonException) + { + valid = false; + } + + Assert.True(valid == expectValid, + $"case '{caseName}': expected valid={expectValid} but got valid={valid}; " + + $"errors=[{string.Join("; ", results.Select(r => r.ErrorMessage))}]"); + } + + [Fact] + public void Violation_names_the_target_members() + { + Type type = Generated.Asm.GetType("Acme.Events.Generated.Event")!; + Assert.True(typeof(IValidatableObject).IsAssignableFrom(type)); + object inst = JsonSerializer.Deserialize( + "{\"startsAt\":5,\"endsAt\":2,\"kind\":\"BIRTH\",\"state\":\"OPEN\",\"email\":\"a@b.c\"}", type, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + var results = new List(); + Assert.False(Validator.TryValidateObject(inst, new ValidationContext(inst), results, true)); + var members = results.SelectMany(r => r.MemberNames).ToHashSet(); + Assert.Contains("StartsAt", members); + Assert.Contains("EndsAt", members); + Assert.Contains("Subject", members); // BIRTH without a subject + } + + private static class Generated + { + internal static readonly Assembly Asm = Compile(); + + private static Assembly Compile() + { + var loadResult = new MetaDataLoader().Load( + [new FileSource(Path.Combine(CorpusDir(), "meta.cross-field.json"))]); + Assert.Empty(loadResult.Errors); + var ctx = new GenContext + { + Entities = loadResult.Root.Objects(), + Root = loadResult.Root, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Events.Generated" }, + }; + var trees = new EntityGenerator().Generate(ctx) + .Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12))) + .ToArray(); + var refs = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator).Where(p => p.Length > 0) + .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToList(); + var comp = CSharpCompilation.Create("crossfield_" + Guid.NewGuid().ToString("N"), trees, refs, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + using var ms = new MemoryStream(); + var emit = comp.Emit(ms); + var errors = emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error) + .Select(d => $"{d.Id}: {d.GetMessage()}").ToList(); + Assert.True(errors.Count == 0, "generated cross-field record should compile, got: " + string.Join("; ", errors)); + return Assembly.Load(ms.ToArray()); + } + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs new file mode 100644 index 0000000000..f6db07a8b6 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs @@ -0,0 +1,160 @@ +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using System.Text.Json; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using MetaObjects.Codegen; +using MetaObjects.Codegen.Generators; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +/// Generator-level tests for entity-scoped cross-field validators on the full record model. +public class CrossFieldValidatorGenerationTests +{ + private const string Model = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Event", "children": [ + { "source.rdb": { "@table": "events" } }, + { "field.long": { "name": "id" } }, + { "field.int": { "name": "lo" } }, + { "field.int": { "name": "hi" } }, + { "field.string": { "name": "email" } }, + { "field.string": { "name": "phone" } }, + { "identity.primary": { "@fields": "id" } }, + { "validator.comparison": { "name": "c", "@left": "lo", "@op": "lt", "@right": "hi" } }, + { "validator.atLeastOne": { "name": "r", "@fields": ["email", "phone"] } }, + { "validator.requiredWhen": { "name": "ghost", "@field": "email", "@when": "missing", "@equals": "x" } } + ]}} + ]}} + """; + + private const string Plain = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Widget", "children": [ + { "source.rdb": { "@table": "widgets" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "name" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + + private const string Inherited = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Parent", "children": [ + { "source.rdb": { "@table": "parents" } }, + { "field.long": { "name": "id" } }, + { "field.int": { "name": "a" } }, + { "field.int": { "name": "b" } }, + { "identity.primary": { "@fields": "id" } }, + { "validator.comparison": { "name": "ab", "@left": "a", "@op": "lte", "@right": "b" } } + ]}}, + { "object.entity": { "name": "Child", "extends": "Parent", "children": [ + { "source.rdb": { "@table": "children" } }, + { "field.int": { "name": "c" } }, + { "field.int": { "name": "d" } }, + { "validator.comparison": { "name": "cd", "@left": "c", "@op": "lte", "@right": "d" } } + ]}} + ]}} + """; + + private static GenContext Ctx(string model) + { + var r = new MetaDataLoader().Load([new InMemoryStringSource(model, id: "xf.json")]); + Assert.Empty(r.Errors); + return new GenContext + { + Entities = r.Root.Objects(), Root = r.Root, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme" }, + }; + } + + private static string Entity(string model, string name) => + new EntityGenerator().Generate(Ctx(model)).Single(f => f.Path == $"{name}.g.cs").Content; + + [Fact] + public void Emits_IValidatableObject_with_each_rule() + { + var src = Entity(Model, "Event"); + Assert.Contains("public class Event : IValidatableObject", src); + Assert.Contains("Lo is { } __l1 && Hi is { } __r1 && !(__l1 < __r1)", src); + Assert.Contains("if (Email is null && Phone is null)", src); + Assert.DoesNotContain("missing", src, StringComparison.OrdinalIgnoreCase); // unresolved ref skipped + } + + [Fact] + public void Entity_without_cross_field_validators_is_unchanged() + { + var src = Entity(Plain, "Widget"); + Assert.DoesNotContain("IValidatableObject", src); + Assert.DoesNotContain("Validate(", src); + } + + [Fact] + public void Update_handlers_do_not_run_object_validation() + { + var routes = new RoutesGenerator().Generate(Ctx(Model)).Single().Content; + var update = routes.Substring(routes.IndexOf("Task UpdateEvent", StringComparison.Ordinal)); + update = update.Substring(0, update.IndexOf("app.MapPatch", StringComparison.Ordinal)); + Assert.DoesNotContain("TryValidateObject", update); + Assert.DoesNotContain("Validate(", update); + Assert.Contains("TryValidateObject", routes); // create still validates the object + } + + [Fact] + public void Derived_entity_overrides_and_chains_to_base() + { + var ctx = Ctx(Inherited); + var files = new BasedEntityGenerator().Generate(ctx).ToList(); + var child = files.Single(f => f.Path == "Child.g.cs").Content; + Assert.Contains("public override IEnumerable Validate", child); + Assert.Contains("base.Validate(validationContext)", child); + Assert.DoesNotContain("IValidatableObject", child); + var parent = files.Single(f => f.Path == "Parent.g.cs").Content; + Assert.Contains("public class Parent : IValidatableObject", parent); + + var trees = files.Select(f => CSharpSyntaxTree.ParseText(f.Content)).ToArray(); + var refs = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!) + .Split(Path.PathSeparator).Where(p => p.Length > 0) + .Select(p => (MetadataReference)MetadataReference.CreateFromFile(p)).ToList(); + var comp = CSharpCompilation.Create("xfi_" + Guid.NewGuid().ToString("N"), trees, refs, + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + using var ms = new MemoryStream(); + var emit = comp.Emit(ms); + Assert.True(emit.Success, string.Join("; ", emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))); + var asm = Assembly.Load(ms.ToArray()); + var t = asm.GetType("Acme.Child")!; + object inst = JsonSerializer.Deserialize("{\"a\":3,\"b\":1,\"c\":1,\"d\":0}", t, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + var results = new List(); + Assert.False(Validator.TryValidateObject(inst, new ValidationContext(inst), results, true)); + Assert.Equal(2, results.Count); // inherited rule AND the child's own rule + } + + [Fact] + public void Without_an_emitted_base_class_a_validator_on_an_unemitted_member_is_skipped() + { + // The stock mapped declaration names no C# base, so Child carries only its own fields; + // the inherited rule references members it does not have and must be skipped, not emitted. + var child = Entity(Inherited, "Child"); + Assert.Contains("public virtual IEnumerable Validate", child); + Assert.DoesNotContain("A is {", child); + Assert.Contains("C is { } __l", child); + } + + // Emits the metadata `extends` chain as a C# base, the shape an adopter override produces. + private sealed class BasedEntityGenerator : EntityGenerator + { + protected override void EmitClassDeclarationLine( + System.Text.StringBuilder sb, MetaObject entity, string className, ClassDeclarationKind kind, GenContext ctx) + { + if (kind == ClassDeclarationKind.MappedEntity && DirectMappedParent(entity, ctx.Config) is { } p) + sb.AppendLine($"public class {className} : {CSharpNaming.Pascal(p.Name)}"); + else + base.EmitClassDeclarationLine(sb, entity, className, kind, ctx); + } + } +} diff --git a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs index 810c3c78e7..b3367eaa9c 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/EntityGenerator.cs @@ -293,6 +293,19 @@ private EmittedFile EmitMappedClassImpl( sb.AppendLine(); EmitClassHeader(sb, entity, className, isProjection, pkFields, ctx); + // Cross-field validators run on the full create record only (never a read-only view). + // The C# base chain is real only when the declaration line actually names a base; fields + // the class skips as "inherited" are otherwise absent as properties, so a validator that + // references one is skipped rather than emitted against a member that does not exist. + var mappedBase = DirectMappedParent(entity, ctx.Config); + var declaresBase = mappedBase is not null && LastLineHasBase(sb); + var baseValidates = declaresBase && !InstanceArtifacts.IsAbstract(mappedBase!) + && HasEffectiveCrossField(mappedBase!); + var absent = mappedBase is not null && !declaresBase + ? new HashSet(mappedBase.Fields().Select(f => f.Name), StringComparer.Ordinal) + : null; + var validateMethod = isProjection ? null : CrossFieldValidateMethod(entity, baseValidates, absent); + if (validateMethod is not null && !baseValidates) AppendValidatableInterface(sb); sb.AppendLine("{"); // Nested enum declarations (before the class properties, as C# enum members @@ -368,6 +381,11 @@ private EmittedFile EmitMappedClassImpl( } sb.Append(string.Join("\n", members)); sb.AppendLine(); + if (validateMethod is not null) + { + sb.AppendLine(); + sb.Append(validateMethod); + } EmitClassBodyTrailer(sb, entity, ctx); sb.AppendLine("}"); return new EmittedFile($"{className}.g.cs", sb.ToString()); @@ -556,6 +574,10 @@ protected virtual EmittedFile EmitTphSubtypeClass(MetaObject entity, GenContext XmlDocBuilder.AppendTo(sb, entity); EmitClassDeclarationLine(sb, entity, className, ClassDeclarationKind.TphSubtype, ctx); + // TPH ancestors always emit Validate when they carry cross-field validators. + var tphBaseValidates = HasEffectiveCrossField(EmittedTphAncestor(entity, ctx)); + var validateMethod = CrossFieldValidateMethod(entity, tphBaseValidates); + if (validateMethod is not null && !tphBaseValidates) AppendValidatableInterface(sb); sb.AppendLine("{"); // Subtype-only enum declarations (an enum field declared on the subtype, not the base). @@ -598,6 +620,11 @@ protected virtual EmittedFile EmitTphSubtypeClass(MetaObject entity, GenContext } sb.Append(string.Join("\n", members)); sb.AppendLine(); + if (validateMethod is not null) + { + sb.AppendLine(); + sb.Append(validateMethod); + } EmitClassBodyTrailer(sb, entity, ctx); sb.AppendLine("}"); return new EmittedFile($"{className}.g.cs", sb.ToString()); @@ -973,6 +1000,214 @@ private static string EnumPropertyTypeName(MetaObject owner, MetaField field, Ge return new EmittedFile("Enums.g.cs", sb.ToString()); } + // ------------------------------------------------------------------------- + // Entity-scoped cross-field validators (validator.comparison / requiredWhen / + // presentIff / atLeastOne) -> IValidatableObject.Validate on the FULL record model, + // the class the generated create handler runs through Validator.TryValidateObject. + // Semantics mirror the schema-side CHECK derivation (migrate-ts crossFieldCheck): + // null = SQL NULL, "" is NOT null; a comparison with a null side passes; a null gate + // is unconstrained for requiredWhen and counts as "not equal" for presentIff. + // A validator naming a missing / array / non-scalar field is skipped silently. + // + // NOT emitted onto partial/PATCH handling: the update handler validates per property + // (TryValidateProperty), never TryValidateObject, so IValidatableObject does not run + // there; a partial body cannot see the merged row, and the DB CHECK stays the + // enforcement. Nuance: Validator.TryValidateObject runs IValidatableObject.Validate + // ONLY when every property-level attribute passed, so a body with a property-level + // violation reports that one first and the cross-field rules on a later pass. + // ------------------------------------------------------------------------- + + private static bool IsCrossFieldValidator(MetaValidator v) => + v.SubType is VALIDATOR_SUBTYPE_COMPARISON or VALIDATOR_SUBTYPE_REQUIRED_WHEN + or VALIDATOR_SUBTYPE_PRESENT_IFF or VALIDATOR_SUBTYPE_AT_LEAST_ONE; + + private enum CrossKind { Other, Numeric, Temporal, Str, Bool, Enum } + + private static CrossKind KindOf(MetaField f) => f.SubType switch + { + FIELD_SUBTYPE_INT or FIELD_SUBTYPE_LONG or FIELD_SUBTYPE_DOUBLE or FIELD_SUBTYPE_FLOAT + or FIELD_SUBTYPE_DECIMAL or FIELD_SUBTYPE_CURRENCY => CrossKind.Numeric, + FIELD_SUBTYPE_DATE or FIELD_SUBTYPE_TIME or FIELD_SUBTYPE_TIMESTAMP => CrossKind.Temporal, + FIELD_SUBTYPE_STRING => CrossKind.Str, + FIELD_SUBTYPE_BOOLEAN => CrossKind.Bool, + FIELD_SUBTYPE_ENUM => CrossKind.Enum, + _ => CrossKind.Other, + }; + + private static string CsLiteral(string s) => + "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", "\\r").Replace("\n", "\\n") + "\""; + + // The cross-field validators THIS class must evaluate. A class extending an emitted base + // that already implements Validate overrides it and evaluates only its OWN validators + // (ADR-0039: the generated-subclass own-only case); otherwise it evaluates the effective set. + private static List CrossFieldValidatorsFor(MetaObject entity, bool baseHasValidate) + { + // ADR-0039: own-only is sanctioned here — a generated subclass emits only the validators + // declared on itself, because the emitted base's Validate already covers the inherited ones. + var src = baseHasValidate ? entity.OwnValidators() : entity.Validators(); + return src.Where(IsCrossFieldValidator).ToList(); + } + + private static bool HasEffectiveCrossField(MetaObject entity) => + entity.Validators().Any(IsCrossFieldValidator); + + private static bool LastLineHasBase(StringBuilder sb) + { + var len = sb.Length; + while (len > 0 && (sb[len - 1] == '\n' || sb[len - 1] == '\r')) len--; + var start = len; + while (start > 0 && sb[start - 1] != '\n') start--; + return sb.ToString(start, len - start).Contains(" : ", StringComparison.Ordinal); + } + + // Appends `, IValidatableObject` (or ` : IValidatableObject`) to the just-written class + // declaration line. Works on the builder so an overridden declaration seam still applies. + private static void AppendValidatableInterface(StringBuilder sb) + { + var len = sb.Length; + while (len > 0 && (sb[len - 1] == '\n' || sb[len - 1] == '\r')) len--; + var lineStart = len; + while (lineStart > 0 && sb[lineStart - 1] != '\n') lineStart--; + var line = sb.ToString(lineStart, len - lineStart); + sb.Length = len; + sb.AppendLine(line.Contains(" : ", StringComparison.Ordinal) ? ", IValidatableObject" : " : IValidatableObject"); + } + + // Returns the emitted Validate method (4-space class-body indent), or null when nothing to emit. + private string? CrossFieldValidateMethod( + MetaObject entity, bool baseHasValidate, ISet? absentProps = null) + { + var validators = CrossFieldValidatorsFor(entity, baseHasValidate); + if (validators.Count == 0) return null; + + MetaField? Scalar(object? name) + { + if (name is not string n || n.Length == 0) return null; + // ADR-0039: resolving Fields() — a referenced field may be inherited. + var f = entity.Fields().FirstOrDefault(x => x.Name == n); + if (f is null || f.ResolvedIsArray()) return null; + if (absentProps is not null && absentProps.Contains(f.Name)) return null; // not a property of this class + return CSharpNaming.ScalarFor(f.SubType) is not null || f.SubType == FIELD_SUBTYPE_ENUM ? f : null; + } + + string Gate(MetaField when, string raw) + { + var p = PropertyName(when); + switch (KindOf(when)) + { + case CrossKind.Bool: + var truthy = raw is "true" or "1" or "TRUE"; + return $"({p} == {(truthy ? "true" : "false")})"; + case CrossKind.Numeric: + if (!System.Text.RegularExpressions.Regex.IsMatch(raw, @"^-?\d+(\.\d+)?$")) return "false"; + var isInt = when.SubType is FIELD_SUBTYPE_INT or FIELD_SUBTYPE_LONG; + if (isInt && raw.Contains('.')) return "false"; + var suffix = when.SubType switch + { + FIELD_SUBTYPE_DECIMAL or FIELD_SUBTYPE_CURRENCY => "m", + FIELD_SUBTYPE_DOUBLE => "d", + FIELD_SUBTYPE_FLOAT => "f", + _ => "", + }; + return $"({p} == {raw}{suffix})"; + case CrossKind.Str: + return $"({p} == {CsLiteral(raw)})"; + default: + return $"(((object?){p})?.ToString() == {CsLiteral(raw)})"; + } + } + + var body = new StringBuilder(); + var i = 0; + foreach (var v in validators) + { + i++; + switch (v.SubType) + { + case VALIDATOR_SUBTYPE_COMPARISON: + { + var l = Scalar(v.Attr(VALIDATOR_ATTR_LEFT)); + var r = Scalar(v.Attr(VALIDATOR_ATTR_RIGHT)); + var op = v.Attr(VALIDATOR_ATTR_OP) as string; + if (l is null || r is null || op is null) break; + var lk = KindOf(l); var rk = KindOf(r); + var ordering = op is "gt" or "gte" or "lt" or "lte"; + var csOp = op switch { "gt" => ">", "gte" => ">=", "lt" => "<", "lte" => "<=", "ne" => "!=", "eq" => "==", _ => null }; + if (csOp is null) break; + string cond; + var a = $"__l{i}"; var b = $"__r{i}"; + if (lk == CrossKind.Str && rk == CrossKind.Str) + cond = $"string.CompareOrdinal({a}, {b}) {csOp} 0"; + else if (lk == CrossKind.Numeric && rk == CrossKind.Numeric) + cond = $"{a} {csOp} {b}"; + else if (lk == CrossKind.Temporal && rk == CrossKind.Temporal + && CSharpNaming.ScalarForField(l) == CSharpNaming.ScalarForField(r)) + cond = $"{a} {csOp} {b}"; + else if (!ordering && lk == rk && lk != CrossKind.Other) + cond = op == "eq" ? $"Equals({a}, {b})" : $"!Equals({a}, {b})"; + else break; + var lp = PropertyName(l); var rp = PropertyName(r); + body.AppendLine($" if ({lp} is {{ }} {a} && {rp} is {{ }} {b} && !({cond}))"); + body.AppendLine($" yield return new ValidationResult({CsLiteral($"{lp} must be {op} {rp}.")}, new[] {{ nameof({lp}), nameof({rp}) }});"); + break; + } + case VALIDATOR_SUBTYPE_REQUIRED_WHEN: + case VALIDATOR_SUBTYPE_PRESENT_IFF: + { + var target = Scalar(v.Attr(VALIDATOR_ATTR_FIELD)); + var when = Scalar(v.Attr(VALIDATOR_ATTR_WHEN)); + if (target is null || when is null) break; + var raw = Convert.ToString(v.Attr(VALIDATOR_ATTR_EQUALS), System.Globalization.CultureInfo.InvariantCulture) ?? ""; + var gate = Gate(when, raw); + var tp = PropertyName(target); var wp = PropertyName(when); + if (v.SubType == VALIDATOR_SUBTYPE_REQUIRED_WHEN) + { + body.AppendLine($" if ({gate} && {tp} is null)"); + body.AppendLine($" yield return new ValidationResult({CsLiteral($"{tp} is required when {wp} is {raw}.")}, new[] {{ nameof({tp}) }});"); + } + else + { + body.AppendLine($" if (({tp} is not null) != {gate})"); + body.AppendLine($" yield return new ValidationResult({CsLiteral($"{tp} must be present if and only if {wp} is {raw}.")}, new[] {{ nameof({tp}) }});"); + } + break; + } + case VALIDATOR_SUBTYPE_AT_LEAST_ONE: + { + var raw = v.Attr(VALIDATOR_ATTR_FIELDS); + var names = raw switch + { + string one => new List { one }, + System.Collections.IEnumerable many => many.Cast().ToList(), + _ => new List(), + }; + var fs = names.Select(Scalar).ToList(); + if (fs.Count == 0 || fs.Any(f => f is null)) break; + var props = fs.Select(f => PropertyName(f!)).ToList(); + body.AppendLine($" if ({string.Join(" && ", props.Select(p => $"{p} is null"))})"); + body.AppendLine($" yield return new ValidationResult({CsLiteral($"At least one of {string.Join(", ", props)} is required.")}, new[] {{ {string.Join(", ", props.Select(p => $"nameof({p})"))} }});"); + break; + } + } + } + if (body.Length == 0) return null; + + var sb = new StringBuilder(); + sb.AppendLine(" /// Entity-scoped cross-field validators (comparison / requiredWhen / presentIff / atLeastOne). Create-time only; the database CHECK is the enforcement on partial updates."); + sb.AppendLine(baseHasValidate + ? " public override IEnumerable Validate(ValidationContext validationContext)" + : " public virtual IEnumerable Validate(ValidationContext validationContext)"); + sb.AppendLine(" {"); + if (baseHasValidate) + { + sb.AppendLine(" foreach (var __inherited in base.Validate(validationContext))"); + sb.AppendLine(" yield return __inherited;"); + } + sb.Append(body); + sb.AppendLine(" }"); + return sb.ToString(); + } + // Extension hook — invoked once per emitted property, AFTER the framework // attributes ([Key]/[Column]/[Required]/[MaxLength]/validators) and immediately // BEFORE the `public { get; ... }` line. The default body is empty diff --git a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt index 84c6c8e7ae..7a9ae968e2 100644 --- a/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt +++ b/server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinEntityGenerator.kt @@ -28,14 +28,26 @@ import com.metaobjects.generator.GeneratorIOWriter import com.metaobjects.generator.direct.MultiFileDirectGeneratorBase import com.metaobjects.loader.MetaDataLoader import com.metaobjects.`object`.MetaObject +import com.metaobjects.field.BooleanField +import com.metaobjects.field.CurrencyField +import com.metaobjects.field.DoubleField +import com.metaobjects.field.FloatField +import com.metaobjects.field.IntegerField +import com.metaobjects.field.LongField import com.metaobjects.validator.ArrayValidator +import com.metaobjects.validator.AtLeastOneValidator +import com.metaobjects.validator.ComparisonValidator +import com.metaobjects.validator.PresentIffValidator +import com.metaobjects.validator.RequiredWhenValidator import com.metaobjects.validator.LengthValidator import com.metaobjects.validator.MetaValidator import com.metaobjects.validator.NumericValidator import com.metaobjects.validator.RegexValidator import com.metaobjects.validator.RequiredValidator import com.squareup.kotlinpoet.AnnotationSpec +import com.squareup.kotlinpoet.BOOLEAN import com.squareup.kotlinpoet.ClassName +import com.squareup.kotlinpoet.CodeBlock import com.squareup.kotlinpoet.FileSpec import com.squareup.kotlinpoet.FunSpec import com.squareup.kotlinpoet.KModifier @@ -327,6 +339,12 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { ) } + // Entity-scoped cross-field validators: class-level Bean Validation on the full create body. + // Not on the TPH union / a report row / a value object, and never on a PATCH body. + if (!tphBase && !reportRow && obj.subType == MetaObject.SUBTYPE_ENTITY) { + crossFieldProperties(obj).forEach { typeBuilder.addProperty(it) } + } + val primaryCtor = ctorBuilder.build() typeBuilder.primaryConstructor(primaryCtor) addJavaBuilder(obj, typeBuilder, ClassName(pkg, shortName), primaryCtor.parameters) @@ -485,6 +503,108 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase() { .getOrNull() } + // === entity-scoped cross-field validators ================================== + + private fun isNumericCross(f: MetaField<*>): Boolean = + f is IntegerField || f is LongField || f is DoubleField || f is FloatField || + f is DecimalField || f is CurrencyField + + private fun crossAttr(v: MetaData, attr: String): String? = + if (v.hasMetaAttr(attr)) v.getMetaAttr(attr).value?.toString() else null + + /** Kotlin boolean expression: does the gating property equal the @equals literal (null gate = false)? */ + private fun crossEquals(w: MetaField<*>, raw: String?): CodeBlock { + val s = raw ?: "" + if (w is BooleanField) { + val truthy = s == "true" || s == "1" || s == "TRUE" + return CodeBlock.of("(%N == %L)", w.name, truthy) + } + if (isNumericCross(w) && Regex("-?\\d+(\\.\\d+)?").matches(s)) { + return CodeBlock.of( + "(%N != null && java.math.BigDecimal(%N.toString()).compareTo(java.math.BigDecimal(%S)) == 0)", + w.name, w.name, s, + ) + } + return CodeBlock.of("(%N?.toString() == %S)", w.name, s) + } + + /** + * One `@get:AssertTrue @get:JsonIgnore val isXxxValid: Boolean` per entity-scoped cross-field + * validator (comparison / requiredWhen / presentIff / atLeastOne). Null-safe in the DB CHECK + * sense (SQL NULL = Kotlin null; "" is not null). A validator naming a missing field is + * skipped. Body-declared, so it stays out of the data class's equals / copy / constructor. + */ + protected open fun crossFieldProperties(obj: MetaObject): List { + val fields = obj.metaFields.associateBy { it.name } + val out = mutableListOf() + val used = mutableSetOf() + for (v in obj.getChildren(MetaValidator::class.java)) { + val expr: CodeBlock = when (v) { + is ComparisonValidator -> { + val l = fields[crossAttr(v, ComparisonValidator.ATTR_LEFT)] ?: continue + val r = fields[crossAttr(v, ComparisonValidator.ATTR_RIGHT)] ?: continue + val op = when (crossAttr(v, ComparisonValidator.ATTR_OP)) { + "gt" -> ">"; "gte" -> ">="; "lt" -> "<"; "lte" -> "<="; "ne" -> "!="; "eq" -> "==" + else -> continue + } + if (l.isArrayType || r.isArrayType) continue + val cmp = if (isNumericCross(l) && isNumericCross(r) && l.subType != r.subType) { + CodeBlock.of("java.math.BigDecimal(%N.toString()).compareTo(java.math.BigDecimal(%N.toString())) $op 0", l.name, r.name) + } else { + CodeBlock.of("(%N as Comparable).compareTo(%N) $op 0", l.name, r.name) + } + CodeBlock.of("%N == null || %N == null || %L", l.name, r.name, cmp) + } + is RequiredWhenValidator, is PresentIffValidator -> { + val t = fields[crossAttr(v, RequiredWhenValidator.ATTR_FIELD)] ?: continue + val w = fields[crossAttr(v, RequiredWhenValidator.ATTR_WHEN)] ?: continue + if (w.isArrayType) continue + val gate = crossEquals(w, crossAttr(v, RequiredWhenValidator.ATTR_EQUALS)) + if (v is RequiredWhenValidator) CodeBlock.of("!%L || %N != null", gate, t.name) + else CodeBlock.of("(%N != null) == %L", t.name, gate) + } + is AtLeastOneValidator -> { + val raw = if (v.hasMetaAttr(AtLeastOneValidator.ATTR_FIELDS)) + v.getMetaAttr(AtLeastOneValidator.ATTR_FIELDS).value else null + val names = when (raw) { + is Collection<*> -> raw.map { it.toString() } + null -> emptyList() + else -> listOf(raw.toString()) + } + if (names.isEmpty() || names.any { it !in fields }) continue + CodeBlock.builder().apply { + names.forEachIndexed { i, n -> if (i > 0) add(" || "); add("%N != null", n) } + }.build() + } + else -> continue + } + val base = v.name.map { if (it.isLetterOrDigit() || it == '_') it else '_' }.joinToString("") + .ifEmpty { "CrossField" }.replaceFirstChar { it.uppercaseChar() } + var name = "is${base}Valid" + var n = 2 + while (!used.add(name)) name = "is${base}${n++}Valid" + out.add( + PropertySpec.builder(name, BOOLEAN) + .addKdoc("Cross-field validator ${v.name.replace("*/", "* /")} (class-level Bean Validation; the DB CHECK enforces the PATCH path).\n") + .addAnnotation( + AnnotationSpec.builder(ClassName("com.fasterxml.jackson.annotation", "JsonIgnore")) + .useSiteTarget(AnnotationSpec.UseSiteTarget.GET).build(), + ) + .addAnnotation( + AnnotationSpec.builder(ClassName(JAKARTA_CONSTRAINTS, "AssertTrue")) + .useSiteTarget(AnnotationSpec.UseSiteTarget.GET) + .addMember("message = %S", "validator ${v.name} violated").build(), + ) + .addAnnotation( + AnnotationSpec.builder(ClassName("kotlin", "Suppress")).addMember("%S", "UNCHECKED_CAST").build(), + ) + .getter(FunSpec.getterBuilder().addStatement("return %L", expr).build()) + .build(), + ) + } + return out + } + // === validation (SP-C validator parity, Unit 5) ========================= /** diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldValidationTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldValidationTest.kt new file mode 100644 index 0000000000..1f3e3c9792 --- /dev/null +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldValidationTest.kt @@ -0,0 +1,66 @@ +package com.metaobjects.generator.kotlin + +import com.metaobjects.metadata.ktx.loadString +import java.nio.file.Files +import kotlin.io.path.readText +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Entity-scoped cross-field validators render as `@get:AssertTrue @get:JsonIgnore` body + * properties on the entity data class (not in the constructor). Behavior is gated by + * [ValidationConformanceTest] against the shared corpus. + */ +class KotlinCrossFieldValidationTest { + + private fun model(withValidators: Boolean): String { + val validators = if (withValidators) """ + , { "validator.comparison": { "name": "startsBeforeEnds", "@left": "startsAt", "@op": "lte", "@right": "endsAt" } }, + { "validator.requiredWhen": { "name": "birthNeedsSubject", "@field": "subject", "@when": "kind", "@equals": "BIRTH" } }, + { "validator.presentIff": { "name": "closedIffNote", "@field": "note", "@when": "kind", "@equals": "CLOSED" } }, + { "validator.atLeastOne": { "name": "reachable", "@fields": ["subject", "note"] } }, + { "validator.requiredWhen": { "name": "ghost", "@field": "nope", "@when": "kind", "@equals": "X" } } + """ else "" + return """ + { "metadata.root": { "package": "acme::ev", "children": [ + { "object.entity": { "name": "Ev", "children": [ + { "source.rdb": { "@table": "evs" } }, + { "field.long": { "name": "id" } }, + { "field.int": { "name": "startsAt" } }, + { "field.int": { "name": "endsAt" } }, + { "field.string": { "name": "kind" } }, + { "field.string": { "name": "subject" } }, + { "field.string": { "name": "note" } }, + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } + $validators + ]}} + ]}} + """ + } + + private fun gen(withValidators: Boolean): String { + val out = Files.createTempDirectory("kt-crossfield-") + try { + val gen = KotlinEntityGenerator() + gen.setArgs(mapOf("outputDir" to out.toString())) + gen.execute(loadString("ev", model(withValidators))) + return out.resolve("acme/ev/Ev.kt").readText() + } finally { + out.toFile().deleteRecursively() + } + } + + @Test fun `data class carries one AssertTrue per validator and skips unresolvable ones`() { + val src = gen(true) + for (m in listOf("isStartsBeforeEndsValid", "isBirthNeedsSubjectValid", "isClosedIffNoteValid", "isReachableValid")) { + assertTrue("val $m: Boolean" in src, "$m missing:\n$src") + } + assertFalse("isGhostValid" in src, "validator naming a missing field is skipped:\n$src") + assertTrue("@get:JsonIgnore" in src && "@get:AssertTrue" in src, src) + } + + @Test fun `entity without cross-field validators emits no AssertTrue`() { + assertFalse("AssertTrue" in gen(false)) + } +} diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ValidationConformanceTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ValidationConformanceTest.kt index 83ac0ea0a8..1192885046 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ValidationConformanceTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/ValidationConformanceTest.kt @@ -48,8 +48,17 @@ class ValidationConformanceTest { } @Test fun `generated Account data class enforces the corpus verdicts`() { + runCorpus("meta.json", "cases.json", "Account", "acme.auth") + } + + /** Entity-scoped cross-field validators enforced by class-level Bean Validation on the data class. */ + @Test fun `generated Event data class enforces the cross-field verdicts`() { + runCorpus("meta.cross-field.json", "cross-field-cases.json", "Event", "acme.events") + } + + private fun runCorpus(metaName: String, casesName: String, defaultEntity: String, corpusPkg: String) { val corpus = repoRoot().resolve("fixtures/validation-conformance") - val metaJson = String(Files.readAllBytes(corpus.resolve("meta.json")), StandardCharsets.UTF_8) + val metaJson = String(Files.readAllBytes(corpus.resolve(metaName)), StandardCharsets.UTF_8) val outDir = Files.createTempDirectory("validation-conf-") try { @@ -59,7 +68,7 @@ class ValidationConformanceTest { gen.setArgs(mapOf("outputDir" to outDir.toString())) gen.execute(loader) - val accountKt = outDir.resolve("acme/auth/Account.kt") + val accountKt = outDir.resolve(corpusPkg.replace('.', '/') + "/$defaultEntity.kt") assertTrue(Files.exists(accountKt), "expected generated $accountKt; files=${Files.walk(outDir).toList()}") @@ -86,7 +95,7 @@ class ValidationConformanceTest { val mapper = ObjectMapper().registerKotlinModule() val validator: Validator = Validation.buildDefaultValidatorFactory().validator - val casesJson = String(Files.readAllBytes(corpus.resolve("cases.json")), StandardCharsets.UTF_8) + val casesJson = String(Files.readAllBytes(corpus.resolve(casesName)), StandardCharsets.UTF_8) val casesNode = mapper.readTree(casesJson).get("cases") assertTrue(casesNode != null && casesNode.size() > 0, "no cases in corpus") @@ -97,8 +106,8 @@ class ValidationConformanceTest { val payloadJson = caseNode.get("payload").toString() // A case may name a corpus entity other than Account (Ledger — an ASSIGNED // primary key, a shape Account's @generation-backed key cannot express). - val entity = caseNode.get("entity")?.asText() ?: "Account" - val accountClass = compileResult.classLoader.loadClass("acme.auth.$entity") + val entity = caseNode.get("entity")?.asText() ?: defaultEntity + val accountClass = compileResult.classLoader.loadClass("$corpusPkg.$entity") // Two boundary stages, exactly as a real Spring controller sees them: // 1. Jackson deserialization onto the generated data class. A missing diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java index 3b08e4c323..01f96b6bad 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringDtoGenerator.java @@ -241,11 +241,13 @@ protected void emit(MetaObject entity, Path outRoot) { // "writable" reading of this flag stays true. boolean writableForAutoSet = !GeneratorUtil.isReport(entity) && (SpringGeneratorSupport.repositoryAppliesTo(entity) || TphPlan.isTphSubtype(entity)); - List extraBodyMembers = + List extraBodyMembers = new ArrayList<>( (writableForAutoSet && SpringGeneratorSupport.hasAutoSetFields(entity)) ? autoSetStampHelpers(entity, fields, SpringNaming.dtoName( SpringNaming.splitFqn(entity.getName())[1])) - : List.of(); + : List.of()); + // Entity-scoped cross-field validators: class-level @AssertTrue rules on the full DTO only. + extraBodyMembers.addAll(SpringGeneratorSupport.crossFieldValidationMembers(entity, fields)); emitRecord(entity, outRoot, fields, annotationsPerField, extraBodyMembers); } @@ -549,6 +551,7 @@ private void emitRecord(MetaObject entity, Path outRoot, List fields, .anyMatch(a -> !a.replaceAll("@JsonDeserialize\\([^)]*\\)", "").trim().isEmpty()); // @Valid (jakarta.validation, NOT ...constraints) cascades validation into a // value-object jsonb component — emitted on VO fields by emit() (Program D). + boolean crossField = extraBodyMembers.stream().anyMatch(m -> m.contains("@AssertTrue")); boolean usesValid = annotationsPerField.stream().anyMatch(a -> a.contains("@Valid")); // #234: a strict field.uri / field.inet component carries @JsonDeserialize(using = …). boolean usesNetDeser = annotationsPerField.stream().anyMatch(a -> a.contains("@JsonDeserialize")); @@ -571,10 +574,10 @@ private void emitRecord(MetaObject entity, Path outRoot, List fields, if (usesValid) { src.append("import jakarta.validation.Valid;\n"); } - if (usesValidation) { + if (usesValidation || crossField) { src.append("import jakarta.validation.constraints.*;\n"); } - if (usesDecimal || usesNetDeser || usesValid || usesValidation) { + if (usesDecimal || usesNetDeser || usesValid || usesValidation || crossField) { src.append('\n'); } src.append("/** GENERATED — wire DTO for ").append(shortName) diff --git a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java index cccb653d7a..2dbf3087e2 100644 --- a/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java +++ b/server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/SpringGeneratorSupport.java @@ -12,7 +12,14 @@ import com.metaobjects.MetaData; import com.metaobjects.config.WireFormat; +import com.metaobjects.field.BooleanField; +import com.metaobjects.field.CurrencyField; +import com.metaobjects.field.DecimalField; +import com.metaobjects.field.DoubleField; import com.metaobjects.field.EnumField; +import com.metaobjects.field.FloatField; +import com.metaobjects.field.IntegerField; +import com.metaobjects.field.LongField; import com.metaobjects.field.MetaField; import com.metaobjects.field.ObjectField; import com.metaobjects.field.StringField; @@ -23,6 +30,10 @@ import com.metaobjects.origin.AggregateOrigin; import com.metaobjects.origin.MetaOrigin; import com.metaobjects.validator.ArrayValidator; +import com.metaobjects.validator.AtLeastOneValidator; +import com.metaobjects.validator.ComparisonValidator; +import com.metaobjects.validator.PresentIffValidator; +import com.metaobjects.validator.RequiredWhenValidator; import com.metaobjects.validator.LengthValidator; import com.metaobjects.validator.MetaValidator; import com.metaobjects.validator.NumericValidator; @@ -633,6 +644,118 @@ public static boolean hasValidator(MetaField field, Class f) { + return f instanceof IntegerField || f instanceof LongField || f instanceof DoubleField + || f instanceof FloatField || f instanceof DecimalField || f instanceof CurrencyField; + } + + private static String crossAttr(MetaData v, String attr) { + if (!v.hasMetaAttr(attr)) return null; + Object raw = v.getMetaAttr(attr).getValue(); + return raw == null ? null : String.valueOf(raw); + } + + /** Resolve a by-name field reference through the resolving accessor; null when unknown. */ + private static MetaField crossField(MetaObject entity, String name, List components) { + if (name == null || name.isEmpty()) return null; + for (MetaField f : components) { + if (f.getName().equals(name)) return f; + } + return null; + } + + /** Java boolean expression: does the gating field equal the @equals literal (null gate = false)? */ + private static String crossEqualsExpr(String ref, MetaField when, String raw) { + String s = raw == null ? "" : raw; + if (when instanceof BooleanField) { + boolean truthy = s.equals("true") || s.equals("1") || s.equals("TRUE"); + return "(" + ref + " != null && " + ref + ".booleanValue() == " + truthy + ")"; + } + if (isNumericCrossField(when) && s.matches("-?\\d+(\\.\\d+)?")) { + return "(" + ref + " != null && new java.math.BigDecimal(String.valueOf(" + ref + + ")).compareTo(new java.math.BigDecimal(\"" + s + "\")) == 0)"; + } + return "(" + ref + " != null && \"" + escapeJavaString(s) + "\".equals(String.valueOf(" + ref + ")))"; + } + + /** + * Record-body members enforcing the entity-scoped cross-field validators + * ({@code validator.comparison / requiredWhen / presentIff / atLeastOne}) on the FULL create + * DTO: one {@code @AssertTrue} {@code isXxxValid()} method per validator (Bean Validation + * reads it as a class-level rule; {@code @JsonIgnore} keeps it off the wire). Null-safe in + * the DB CHECK sense (SQL NULL = Java null; "" is not null). A validator whose referenced + * field is missing is skipped. NOT added to the PATCH body: a partial body cannot see the + * merged row, so the DB CHECK stays the enforcement there. + */ + public static List crossFieldValidationMembers(MetaObject entity, List components) { + List out = new ArrayList<>(); + if (!MetaObject.SUBTYPE_ENTITY.equals(entity.getSubType())) return out; + java.util.Set used = new java.util.HashSet<>(); + for (MetaValidator v : entity.getChildren(MetaValidator.class)) { + String expr = null; + if (v instanceof ComparisonValidator) { + MetaField l = crossField(entity, crossAttr(v, ComparisonValidator.ATTR_LEFT), components); + MetaField r = crossField(entity, crossAttr(v, ComparisonValidator.ATTR_RIGHT), components); + String op = crossAttr(v, ComparisonValidator.ATTR_OP); + String jop = op == null ? null : switch (op) { + case "gt" -> ">"; case "gte" -> ">="; case "lt" -> "<"; case "lte" -> "<="; + case "ne" -> "!="; case "eq" -> "=="; default -> null; }; + if (l == null || r == null || jop == null || l.isArrayType() || r.isArrayType()) continue; + String lc = "this." + SpringNaming.recordComponentName(l.getName()); + String rc = "this." + SpringNaming.recordComponentName(r.getName()); + String cmp; + if (isNumericCrossField(l) && isNumericCrossField(r) && !l.getSubType().equals(r.getSubType())) { + cmp = "new java.math.BigDecimal(String.valueOf(" + lc + ")).compareTo(new java.math.BigDecimal(String.valueOf(" + rc + "))) " + jop + " 0"; + } else { + cmp = "((Comparable) (Object) " + lc + ").compareTo(" + rc + ") " + jop + " 0"; + } + expr = lc + " == null || " + rc + " == null || " + cmp; + } else if (v instanceof RequiredWhenValidator || v instanceof PresentIffValidator) { + MetaField t = crossField(entity, crossAttr(v, RequiredWhenValidator.ATTR_FIELD), components); + MetaField w = crossField(entity, crossAttr(v, RequiredWhenValidator.ATTR_WHEN), components); + if (t == null || w == null || w.isArrayType()) continue; + String tc = "this." + SpringNaming.recordComponentName(t.getName()); + String gate = crossEqualsExpr("this." + SpringNaming.recordComponentName(w.getName()), w, + crossAttr(v, RequiredWhenValidator.ATTR_EQUALS)); + expr = v instanceof RequiredWhenValidator + ? "!" + gate + " || " + tc + " != null" + : "(" + tc + " != null) == " + gate; + } else if (v instanceof AtLeastOneValidator) { + Object raw = v.hasMetaAttr(AtLeastOneValidator.ATTR_FIELDS) + ? v.getMetaAttr(AtLeastOneValidator.ATTR_FIELDS).getValue() : null; + List names = new ArrayList<>(); + if (raw instanceof java.util.Collection c) { for (Object o : c) names.add(String.valueOf(o)); } + else if (raw != null) names.add(String.valueOf(raw)); + List parts = new ArrayList<>(); + boolean missing = names.isEmpty(); + for (String n : names) { + MetaField f = crossField(entity, n, components); + if (f == null) { missing = true; break; } + parts.add("this." + SpringNaming.recordComponentName(f.getName()) + " != null"); + } + if (missing) continue; + expr = String.join(" || ", parts); + } + if (expr == null) continue; + StringBuilder id = new StringBuilder(); + for (char ch : v.getName().toCharArray()) id.append(Character.isJavaIdentifierPart(ch) ? ch : '_'); + String base = id.length() == 0 ? "CrossField" : Character.toUpperCase(id.charAt(0)) + id.substring(1); + String method = "is" + base + "Valid"; + for (int n = 2; !used.add(method); n++) method = "is" + base + n + "Valid"; + out.add("\n /** Cross-field validator " + v.getName().replace("*/", "* /") + + " (class-level Bean Validation; the DB CHECK enforces the PATCH path). */\n" + + " @com.fasterxml.jackson.annotation.JsonIgnore\n" + + " @AssertTrue(message = \"validator " + escapeJavaString(v.getName()) + " violated\")\n" + + " @SuppressWarnings(\"unchecked\")\n" + + " public boolean " + method + "() {\n" + + " return " + expr + ";\n" + + " }\n"); + } + return out; + } + /** Escape a regex/string literal for safe embedding in generated Java source. */ public static String escapeJavaString(String s) { return com.metaobjects.generator.util.JavaIdentifiers.escapeStringLiteral(s); diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringCrossFieldValidationTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringCrossFieldValidationTest.java new file mode 100644 index 0000000000..b4cc3a9342 --- /dev/null +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringCrossFieldValidationTest.java @@ -0,0 +1,88 @@ +package com.metaobjects.generator.spring; + +import com.metaobjects.loader.MetaDataLoader; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Entity-scoped cross-field validators render as class-level {@code @AssertTrue} rules on the + * full {@code Dto} only; the partial {@code Patch} is unchanged (a partial body + * cannot see the merged row, so the DB CHECK stays the enforcement there). The behavioral + * verdicts are gated by {@code ValidationConformanceTest} against the shared corpus. + */ +public class SpringCrossFieldValidationTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + private static String model(boolean withValidators) { + String validators = withValidators ? """ + , { "validator.comparison": { "name": "startsBeforeEnds", "@left": "startsAt", "@op": "lte", "@right": "endsAt" } }, + { "validator.requiredWhen": { "name": "birthNeedsSubject", "@field": "subject", "@when": "kind", "@equals": "BIRTH" } }, + { "validator.presentIff": { "name": "closedIffNote", "@field": "note", "@when": "kind", "@equals": "CLOSED" } }, + { "validator.atLeastOne": { "name": "reachable", "@fields": ["subject", "note"] } }, + { "validator.requiredWhen": { "name": "ghost", "@field": "nope", "@when": "kind", "@equals": "X" } } + """ : ""; + return """ + { "metadata.root": { "package": "acme::ev", "children": [ + { "object.entity": { "name": "Ev", "children": [ + { "source.rdb": { "@table": "evs" } }, + { "field.long": { "name": "id" } }, + { "field.int": { "name": "startsAt" } }, + { "field.int": { "name": "endsAt" } }, + { "field.string": { "name": "kind" } }, + { "field.string": { "name": "subject" } }, + { "field.string": { "name": "note" } }, + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } + """ + validators + """ + ]}} + ]}} + """; + } + + private String gen(boolean withValidators, String file) throws Exception { + MetaDataLoader loader = SpringTestFixtures.loadFixture( + tmp.newFolder().toPath(), "ev", model(withValidators)); + Path out = tmp.newFolder().toPath(); + SpringDtoGenerator g = new SpringDtoGenerator(); + g.setArgs(Map.of("outputDir", out.toString())); + g.execute(loader); + return Files.readString(out.resolve("acme/ev/" + file)); + } + + @Test + public void dtoCarriesOneAssertTruePerValidatorAndSkipsUnresolvable() throws Exception { + String dto = gen(true, "EvDto.java"); + assertTrue(dto, dto.contains("import jakarta.validation.constraints.*;")); + for (String m : new String[] { "isStartsBeforeEndsValid", "isBirthNeedsSubjectValid", + "isClosedIffNoteValid", "isReachableValid" }) { + assertTrue(m + " missing:\n" + dto, dto.contains("public boolean " + m + "()")); + } + assertFalse("validator naming a missing field is skipped:\n" + dto, dto.contains("isGhostValid")); + assertTrue("kept off the wire:\n" + dto, + dto.contains("@com.fasterxml.jackson.annotation.JsonIgnore")); + assertTrue(dto, dto.contains("this.startsAt == null || this.endsAt == null")); + } + + @Test + public void patchIsUnchangedByCrossFieldValidators() throws Exception { + String withV = gen(true, "EvPatch.java"); + String without = gen(false, "EvPatch.java"); + assertTrue("PATCH body must not change", withV.equals(without)); + assertFalse(withV, withV.contains("AssertTrue")); + } + + @Test + public void entityWithoutCrossFieldValidatorsEmitsNoAssertTrue() throws Exception { + String dto = gen(false, "EvDto.java"); + assertFalse(dto, dto.contains("AssertTrue")); + } +} diff --git a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ValidationConformanceTest.java b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ValidationConformanceTest.java index cab3cbd108..a68ab1606b 100644 --- a/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ValidationConformanceTest.java +++ b/server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/ValidationConformanceTest.java @@ -66,17 +66,29 @@ private static String rootMessage(Throwable t) { } @Test - @SuppressWarnings("unchecked") public void generatedDtoEnforcesCorpusVerdicts() throws Exception { + runCorpus("meta.json", "cases.json", "Account", "acme.auth"); + } + + /** Entity-scoped cross-field validators (comparison / requiredWhen / presentIff / atLeastOne) + * enforced by the class-level Bean Validation on the generated full DTO. */ + @Test + public void generatedDtoEnforcesCrossFieldVerdicts() throws Exception { + runCorpus("meta.cross-field.json", "cross-field-cases.json", "Event", "acme.events"); + } + + @SuppressWarnings("unchecked") + private void runCorpus(String metaName, String casesName, String defaultEntity, String corpusPkg) + throws Exception { Path corpus = repoRoot().resolve("fixtures/validation-conformance"); - Path metaFile = corpus.resolve("meta.json"); - Path casesFile = corpus.resolve("cases.json"); - assertTrue("corpus meta.json must exist at " + metaFile, Files.exists(metaFile)); - assertTrue("corpus cases.json must exist at " + casesFile, Files.exists(casesFile)); + Path metaFile = corpus.resolve(metaName); + Path casesFile = corpus.resolve(casesName); + assertTrue("corpus meta must exist at " + metaFile, Files.exists(metaFile)); + assertTrue("corpus cases must exist at " + casesFile, Files.exists(casesFile)); // 1. Load the corpus metadata. MetaDataLoader loader = SpringTestFixtures.loadFixture( - tmp.newFolder().toPath(), "validationMeta", Files.readString(metaFile)); + tmp.newFolder().toPath(), metaName.replace('.', '_'), Files.readString(metaFile)); // 2. Generate the Account DTO. Path gen = tmp.newFolder("gen-dto").toPath(); @@ -84,8 +96,8 @@ public void generatedDtoEnforcesCorpusVerdicts() throws Exception { generator.setArgs(Map.of("outputDir", gen.toString())); generator.execute(loader); - File dtoSrc = gen.resolve("acme/auth/AccountDto.java").toFile(); - assertTrue("AccountDto.java must be generated at " + dtoSrc, dtoSrc.isFile()); + File dtoSrc = gen.resolve(corpusPkg.replace('.', '/') + "/" + defaultEntity + "Dto.java").toFile(); + assertTrue(defaultEntity + "Dto.java must be generated at " + dtoSrc, dtoSrc.isFile()); // 3. Compile the generated source in-memory. -parameters so Jackson can bind // record components by name from the corpus payload JSON. @@ -112,7 +124,7 @@ public void generatedDtoEnforcesCorpusVerdicts() throws Exception { for (var d : diags.getDiagnostics()) { sb.append(" ").append(d.getKind()).append(": ").append(d.getMessage(null)).append('\n'); } - sb.append("\n=== AccountDto.java ===\n").append(Files.readString(dtoSrc.toPath())); + sb.append("\n=== " + defaultEntity + "Dto.java ===\n").append(Files.readString(dtoSrc.toPath())); fail(sb.toString()); } @@ -137,8 +149,8 @@ public void generatedDtoEnforcesCorpusVerdicts() throws Exception { // A case may name a corpus entity other than Account (Ledger — an // ASSIGNED primary key, a shape Account's @generation-backed key // cannot express). - String entity = c.get("entity") == null ? "Account" : String.valueOf(c.get("entity")); - Class dtoCls = cl.loadClass("acme.auth." + entity + "Dto"); + String entity = c.get("entity") == null ? defaultEntity : String.valueOf(c.get("entity")); + Class dtoCls = cl.loadClass(corpusPkg + "." + entity + "Dto"); boolean actualValid; String detail; @@ -168,7 +180,7 @@ public void generatedDtoEnforcesCorpusVerdicts() throws Exception { if (failures.length() > 0) { fail("validation-conformance verdict mismatches (" + passed + "/" + cases.size() + " passed):\n" + failures - + "\n=== AccountDto.java ===\n" + Files.readString(dtoSrc.toPath())); + + "\n=== " + defaultEntity + "Dto.java ===\n" + Files.readString(dtoSrc.toPath())); } assertEquals("all corpus cases must match the single-source verdict", cases.size(), passed); diff --git a/server/python/src/metaobjects/codegen/generators/entity_model.py b/server/python/src/metaobjects/codegen/generators/entity_model.py index e695ff2e28..70a4d5b053 100644 --- a/server/python/src/metaobjects/codegen/generators/entity_model.py +++ b/server/python/src/metaobjects/codegen/generators/entity_model.py @@ -12,6 +12,7 @@ from metaobjects.meta.core.field.meta_field import MetaField from metaobjects.meta.core.object.meta_object import MetaObject +from metaobjects.meta.meta_data import MetaData from metaobjects.meta.core.object.object_constants import ( OBJECT_SUBTYPE_ENTITY, OBJECT_SUBTYPE_VALUE, @@ -457,6 +458,103 @@ def _assigned_pk_field_names(entity: MetaObject) -> set[str]: return {n for n in _primary_identity_field_names(entity) if n not in auto_gen} +# --- Cross-field validators on the full record (create) model ---------------- +# Mirrors migrate-ts ``crossFieldCheck`` / ``renderEquals``: null-safe, SQL-CHECK-like +# (None is SQL NULL; "" is NOT null). Enforced on the Create model only: a Patch body +# cannot see the merged row, so the DB CHECK stays the enforcement point there. +_COMPARISON_PY_OP = {"gt": ">", "gte": ">=", "lt": "<", "lte": "<=", "ne": "!=", "eq": "=="} +_CROSS_FIELD_SUBTYPES = ( + vc.VALIDATOR_SUBTYPE_COMPARISON, + vc.VALIDATOR_SUBTYPE_REQUIRED_WHEN, + vc.VALIDATOR_SUBTYPE_PRESENT_IFF, + vc.VALIDATOR_SUBTYPE_AT_LEAST_ONE, +) +_INT_SUBTYPES = (fc.FIELD_SUBTYPE_INT, fc.FIELD_SUBTYPE_LONG) +_FLOAT_SUBTYPES = (fc.FIELD_SUBTYPE_DOUBLE, fc.FIELD_SUBTYPE_FLOAT) +_DECIMAL_SUBTYPES = (fc.FIELD_SUBTYPE_DECIMAL, fc.FIELD_SUBTYPE_CURRENCY) +_NUMERIC_LITERAL = re.compile(r"^-?\d+(\.\d+)?$") + + +def _equals_literal(raw: object, when_field: MetaField, imports: set[str]) -> str: + """Python source for an ``@equals`` gating literal, typed by the gating field's + subtype (boolean -> True/False, numeric -> number, else string). Mirrors + ``renderEquals``.""" + text = str(raw) + sub = when_field.sub_type + if sub == fc.FIELD_SUBTYPE_BOOLEAN: + return "True" if text in ("true", "1", "TRUE") else "False" + if sub in _INT_SUBTYPES and _NUMERIC_LITERAL.match(text): + return str(int(float(text))) if "." not in text else repr(float(text)) + if sub in _FLOAT_SUBTYPES and _NUMERIC_LITERAL.match(text): + return repr(float(text)) + if sub in _DECIMAL_SUBTYPES and _NUMERIC_LITERAL.match(text): + imports.add("from decimal import Decimal") + return f"Decimal({text!r})" + return repr(text) + + +def _cross_field_rules_method( + entity: MetaObject, model_name: str, model_fields: set[str], imports: set[str] +) -> list[str]: + """A ``model_validator(mode="after")`` enforcing the entity's cross-field validators + over the fields the model actually carries; ``[]`` when none apply. A validator whose + referenced field is missing from the model is skipped silently (as the DB CHECK is).""" + by_name = {f.name: f for f in entity.fields() if f.name in model_fields} + checks: list[str] = [] + for v in entity.children(): + if v.type != TYPE_VALIDATOR or v.sub_type not in _CROSS_FIELD_SUBTYPES: + continue + # ADR-0039: resolving read — a cross-field validator's attrs may be inherited. + def attr(name: str, node: MetaData = v) -> object: + return node.get_meta_attr(name) + + label = repr(f"{v.name}") + if v.sub_type == vc.VALIDATOR_SUBTYPE_COMPARISON: + left, right = attr(vc.VALIDATOR_ATTR_LEFT), attr(vc.VALIDATOR_ATTR_RIGHT) + op = _COMPARISON_PY_OP.get(str(attr(vc.VALIDATOR_ATTR_OP))) + if left not in by_name or right not in by_name or op is None: + continue + checks.append( + f" if self.{left} is not None and self.{right} is not None " + f"and not (self.{left} {op} self.{right}):\n" + f" raise ValueError({label})" + ) + elif v.sub_type in (vc.VALIDATOR_SUBTYPE_REQUIRED_WHEN, vc.VALIDATOR_SUBTYPE_PRESENT_IFF): + target, when = attr(vc.VALIDATOR_ATTR_FIELD), attr(vc.VALIDATOR_ATTR_WHEN) + if target not in by_name or when not in by_name: + continue + lit = _equals_literal(attr(vc.VALIDATOR_ATTR_EQUALS), by_name[str(when)], imports) + cmp_op = "is" if lit in ("True", "False") else "==" + gate = f"(self.{when} is not None and self.{when} {cmp_op} {lit})" + if v.sub_type == vc.VALIDATOR_SUBTYPE_REQUIRED_WHEN: + checks.append( + f" if {gate} and self.{target} is None:\n" + f" raise ValueError({label})" + ) + else: + checks.append( + f" if (self.{target} is not None) != {gate}:\n" + f" raise ValueError({label})" + ) + else: + raw = attr(vc.VALIDATOR_ATTR_FIELDS) + names = list(raw) if isinstance(raw, (list, tuple)) else ([raw] if isinstance(raw, str) else []) + if not names or any(n not in by_name for n in names): + continue + all_none = " and ".join(f"self.{n} is None" for n in names) + checks.append(f" if {all_none}:\n raise ValueError({label})") + if not checks: + return [] + return [ + "", + ' @model_validator(mode="after")', + f" def _cross_field_rules(self) -> {model_name}:", + *checks, + " return self", + ] + + + def _create_field_line( field: MetaField, imports: set[str], config: GenConfig, *, force_required: bool = False ) -> tuple[str, bool]: @@ -663,6 +761,15 @@ def _emit_create_lines( imports.add("from typing import Literal") return lines, uses_field + def _emit_cross_field_rules( + self, entity: MetaObject, create_lines: list[str], imports: set[str] + ) -> list[str]: + """The ``Create`` cross-field ``model_validator`` (comparison / requiredWhen / + presentIff / atLeastOne), keyed by the field lines the create model carries. + Create only: a Patch body cannot see the merged row. Override to customize.""" + carried = {m.group(1) for line in create_lines if (m := re.match(r" (\w+):", line))} + return _cross_field_rules_method(entity, f"{model_class_name(entity)}Create", carried, imports) + def _emit_patch_lines( self, entity: MetaObject, @@ -755,11 +862,13 @@ def render_entity_model( emit_create = entity.sub_type in (OBJECT_SUBTYPE_ENTITY, OBJECT_SUBTYPE_VALUE) emit_patch = entity.sub_type == OBJECT_SUBTYPE_ENTITY create_lines: list[str] = [] + cross_lines: list[str] = [] create_uses_field = False patch_lines: list[str] = [] patch_uses_field = False if emit_create: create_lines, create_uses_field = self._emit_create_lines(entity, imports, config) + cross_lines = self._emit_cross_field_rules(entity, create_lines, imports) if emit_patch: patch_lines, patch_uses_field = self._emit_patch_lines(entity, imports, config) @@ -771,6 +880,8 @@ def render_entity_model( pyd_names.append("BaseModel") if uses_field or create_uses_field or patch_uses_field: pyd_names.append("Field") + if cross_lines: + pyd_names.append("model_validator") parts: list[str] = [ generated_header(entity.name, _effective_fqn(entity)), @@ -827,6 +938,7 @@ def render_entity_model( '(writeOnce is settable here, once); @default/@autoSet optional; present ' 'values validated (FR-036)."""', *create_lines, + *cross_lines, ] if emit_patch: parts += [ diff --git a/server/python/tests/codegen/test_validation_conformance.py b/server/python/tests/codegen/test_validation_conformance.py index bb7d5bd415..3ce39d4cb4 100644 --- a/server/python/tests/codegen/test_validation_conformance.py +++ b/server/python/tests/codegen/test_validation_conformance.py @@ -98,3 +98,58 @@ def test_validation_conformance(case: dict) -> None: f"case {case['name']!r}: generated model accepted={valid} " f"expectValid={case['expectValid']}" ) + + +# --- cross-field corpus (comparison / requiredWhen / presentIff / atLeastOne) --------- + +def _load_cross_field_entity(name: str = "Event") -> MetaObject: + meta = (_corpus_dir() / "meta.cross-field.json").read_text() + result = MetaDataLoader.from_string(meta) + assert not result.errors, [str(e) for e in result.errors] + for child in result.root.children(): + if child.type == TYPE_OBJECT and getattr(child, "name", None) == name: + assert isinstance(child, MetaObject) + return child + raise AssertionError(f"{name} not found in meta.cross-field.json") + + +def _import_source(source: str, mod_name: str): + mod_path = Path(tempfile.mkdtemp()) / f"{mod_name}.py" + mod_path.write_text(source) + spec = importlib.util.spec_from_file_location(mod_name, mod_path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = module + spec.loader.exec_module(module) + return module + + +def _load_cross_field_cases() -> list[dict]: + return json.loads((_corpus_dir() / "cross-field-cases.json").read_text())["cases"] + + +@pytest.mark.parametrize("case", _load_cross_field_cases(), ids=lambda c: c["name"]) +def test_cross_field_conformance(case: dict) -> None: + module = _import_source( + render_entity_model(_load_cross_field_entity(case["entity"])), "generated_cross_event" + ) + model = getattr(module, f"{case['entity']}Create") + try: + model(**case["payload"]) + valid = True + except ValidationError: + valid = False + assert valid == case["expectValid"], f"case {case['name']!r}: accepted={valid}" + + +def test_cross_field_rules_only_on_create_model() -> None: + module = _import_source( + render_entity_model(_load_cross_field_entity()), "generated_cross_event_patch" + ) + assert hasattr(module.EventCreate, "_cross_field_rules") + assert not hasattr(module.EventPatch, "_cross_field_rules") + assert not hasattr(module.Event, "_cross_field_rules") + # A partial body that would violate the rules is still accepted by Patch. + module.EventPatch(startsAt=9, endsAt=1) + src = render_entity_model(_load_cross_field_entity()) + assert src.count("model_validator") == 2 # import + the single decorator diff --git a/server/typescript/packages/codegen-ts-react/src/templates/form-file.ts b/server/typescript/packages/codegen-ts-react/src/templates/form-file.ts index b2a6d63429..a74413e955 100644 --- a/server/typescript/packages/codegen-ts-react/src/templates/form-file.ts +++ b/server/typescript/packages/codegen-ts-react/src/templates/form-file.ts @@ -64,6 +64,7 @@ import { } from "@metaobjectsdev/codegen-ts/engine"; import { humanize, valueObjectFor, numericInputControl } from "@metaobjectsdev/codegen-ts/templates/field-meta"; import { tphDiscriminatorPin } from "@metaobjectsdev/codegen-ts/engine"; +import { hasInsertRefinements } from "@metaobjectsdev/codegen-ts/templates/zod-validators"; function primaryFieldNames(entity: MetaObject): Set { const set = new Set(); @@ -352,7 +353,11 @@ export function renderFormFile(entity: MetaObject, ctx: RenderContext): string { const omitTph = (schema: string) => tphPin !== undefined ? `${schema}.omit({ ${tphPin.fieldName}: true })` : schema; - const insertSchema = omitTph(`${entityName}InsertSchema`); + // A refined (cross-field) schema cannot be `.omit()`ed; codegen exports the + // unrefined `InsertBaseSchema` for exactly this TPH case. + const insertSchema = tphPin !== undefined && hasInsertRefinements(entity) + ? `${entityName}InsertBaseSchema.omit({ ${tphPin.fieldName}: true })` + : omitTph(`${entityName}InsertSchema`); const updateSchema = omitTph(`${entityName}UpdateSchema`); const formSchema = `props.defaultValues !== undefined ? ${updateSchema} : ${insertSchema}`; diff --git a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts index e6a526d36d..f40bc80acf 100644 --- a/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts +++ b/server/typescript/packages/codegen-ts/src/templates/zod-validators.ts @@ -28,6 +28,10 @@ import { FIELD_ATTR_DB_COLUMN_TYPE, DB_COLUMN_TYPE_JSONB, AUTO_SET_ON_CREATE, AUTO_SET_ON_UPDATE, VALIDATOR_ATTR_MAX, VALIDATOR_ATTR_MIN, VALIDATOR_ATTR_PATTERN, + VALIDATOR_SUBTYPE_COMPARISON, VALIDATOR_SUBTYPE_REQUIRED_WHEN, + VALIDATOR_SUBTYPE_PRESENT_IFF, VALIDATOR_SUBTYPE_AT_LEAST_ONE, + VALIDATOR_ATTR_LEFT, VALIDATOR_ATTR_OP, VALIDATOR_ATTR_RIGHT, + VALIDATOR_ATTR_FIELD, VALIDATOR_ATTR_WHEN, VALIDATOR_ATTR_EQUALS, VALIDATOR_ATTR_FIELDS, GENERATION_INCREMENT, GENERATION_UUID, OBJECT_ATTR_DISCRIMINATOR, OBJECT_ATTR_DISCRIMINATOR_VALUE, OBJECT_SUBTYPE_VALUE, @@ -256,6 +260,128 @@ function isInsertRequiredPk(field: MetaField, assignedPk: Set): boolean return field.attr(FIELD_ATTR_DEFAULT) === undefined; } +// ── Entity-scoped cross-field validators on the full (insert) record ────────── +// validator.comparison / requiredWhen / presentIff / atLeastOne are enforced on the +// INSERT-shaped schemas only, as one `.superRefine`. A PATCH body cannot see the merged +// row, so the Update schema is deliberately left alone (the DB CHECK stays the guard +// there). Semantics mirror the migrate-ts CHECK derivation: absent / null / undefined is +// SQL NULL, "" is NOT null; a comparison with a NULL side passes; requiredWhen is violated +// only when the gate EQUALS the literal and the target is null; presentIff requires +// (target != null) === (gate equals literal), a null gate being "not equal". + +const CROSS_FIELD_OPS: Record = { + gt: ">", gte: ">=", lt: "<", lte: "<=", ne: "!==", eq: "===", +}; + +const NUMERIC_SUBTYPES: ReadonlySet = new Set([ + FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, + FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_CURRENCY, +]); + +/** Names of the fields the INSERT-shaped schemas carry (same skip rules as the emitters). */ +function insertSchemaFieldNameSet(obj: MetaObject): Set { + const autoGen = autoGenPkFieldNames(obj); + const out = new Set(); + for (const f of obj.fields()) { + if (autoGen.has(f.name)) continue; + if (isReadOnlyMutability(f) || f.isDerived()) continue; + out.add(f.name); + } + return out; +} + +/** The @equals literal as a TS expression, typed by the gating field's subtype. */ +function crossFieldLiteral(raw: unknown, when: MetaField): { lit: string; numeric: boolean } { + const s = String(raw); + if (when.subType === FIELD_SUBTYPE_BOOLEAN) { + return { lit: s === "true" || s === "1" || s === "TRUE" ? "true" : "false", numeric: false }; + } + if (NUMERIC_SUBTYPES.has(when.subType) && /^-?\d+(\.\d+)?$/.test(s)) return { lit: s, numeric: true }; + return { lit: JSON.stringify(s), numeric: false }; +} + +/** Does this object declare at least one cross-field validator that will be enforced + * on its insert schema? Exported so the form emitter can tell a refined schema + * (which Zod will not `.omit()`) from a plain object. */ +export function hasInsertRefinements(obj: MetaObject): boolean { + return crossFieldRefinementBody(obj).length > 0; +} + +function crossFieldRefinementBody(obj: MetaObject): string[] { + const inSchema = insertSchemaFieldNameSet(obj); + const byName = new Map(obj.fields().map((f) => [f.name, f] as const)); + // A reference must name a field that exists AND is in the insert schema; otherwise skip + // the validator (migrate skips on a missing field; a readOnly/derived/auto-PK field is + // never in the body, so enforcing on it would reject every create). + const ref = (name: unknown): MetaField | undefined => { + if (typeof name !== "string" || !inSchema.has(name)) return undefined; + return byName.get(name); + }; + const acc = (f: MetaField) => `row[${JSON.stringify(f.name)}]`; + const issue = (path: string, msg: string) => + `ctx.addIssue({ code: "custom", path: [${path}], message: ${JSON.stringify(msg)} });`; + const blocks: string[] = []; + // ADR-0039: entity.validators() is the resolving accessor (never ownValidators). + for (const v of obj.validators()) { + switch (v.subType) { + case VALIDATOR_SUBTYPE_COMPARISON: { + const left = ref(v.attr(VALIDATOR_ATTR_LEFT)); + const right = ref(v.attr(VALIDATOR_ATTR_RIGHT)); + const opName = String(v.attr(VALIDATOR_ATTR_OP)); + const op = CROSS_FIELD_OPS[opName]; + if (!left || !right || !op) break; + const numeric = NUMERIC_SUBTYPES.has(left.subType); + const c = numeric + ? "Number(l) - Number(r)" + : "l instanceof Date && r instanceof Date ? l.getTime() - r.getTime() : String(l) < String(r) ? -1 : String(l) > String(r) ? 1 : 0"; + blocks.push( + ` {\n const l: unknown = ${acc(left)};\n const r: unknown = ${acc(right)};\n` + + ` if (l != null && r != null) {\n const c = ${c};\n` + + ` if (!(c ${op} 0)) ${issue(JSON.stringify(left.name), `${left.name} must be ${opName} ${right.name}`)}\n }\n }`, + ); + break; + } + case VALIDATOR_SUBTYPE_REQUIRED_WHEN: + case VALIDATOR_SUBTYPE_PRESENT_IFF: { + const target = ref(v.attr(VALIDATOR_ATTR_FIELD)); + const when = ref(v.attr(VALIDATOR_ATTR_WHEN)); + if (!target || !when) break; + const { lit, numeric } = crossFieldLiteral(v.attr(VALIDATOR_ATTR_EQUALS), when); + const gate = numeric ? `g != null && Number(g) === ${lit}` : `g === ${lit}`; + const path = JSON.stringify(target.name); + const decl = ` const g: unknown = ${acc(when)};\n const t: unknown = ${acc(target)};\n const open = ${gate};\n`; + if (v.subType === VALIDATOR_SUBTYPE_REQUIRED_WHEN) { + blocks.push(` {\n${decl} if (open && t == null) ${issue(path, `${target.name} is required when ${when.name} is ${String(v.attr(VALIDATOR_ATTR_EQUALS))}`)}\n }`); + } else { + blocks.push(` {\n${decl} if ((t != null) !== open) ${issue(path, `${target.name} must be present if and only if ${when.name} is ${String(v.attr(VALIDATOR_ATTR_EQUALS))}`)}\n }`); + } + break; + } + case VALIDATOR_SUBTYPE_AT_LEAST_ONE: { + const raw = v.attr(VALIDATOR_ATTR_FIELDS); + const names = Array.isArray(raw) ? raw : (typeof raw === "string" ? [raw] : []); + const refs = names.map(ref); + if (refs.length === 0 || refs.some((r) => r === undefined)) break; + const fields = refs as MetaField[]; + const anyPresent = fields.map((f) => `${acc(f)} != null`).join(" || "); + blocks.push(` if (!(${anyPresent})) ${issue(JSON.stringify(fields[0]!.name), `at least one of ${fields.map((f) => f.name).join(", ")} is required`)}`); + break; + } + default: + break; + } + } + return blocks; +} + +/** `.superRefine(...)` suffix for an insert-shaped schema, or "" when the object has no + * enforceable cross-field validator (output is then byte-identical to before). */ +function insertRefinementSuffix(obj: MetaObject): string { + const blocks = crossFieldRefinementBody(obj); + if (blocks.length === 0) return ""; + return `.superRefine((row, ctx) => {\n${blocks.join("\n")}\n})`; +} + /** * Emit ONLY the `InsertSchema`. Used by the value-object file emitter * for metaobjects with no writable source.rdb — those have no PATCH/update @@ -322,7 +448,7 @@ export function renderInsertSchemaOnly(obj: MetaObject, ctx?: RenderContext): Co return code` ${utcTimestampHelperSince(utcUsesBefore)}${decimalHelperSince(decimalUsesBefore)}${docsPrefix}export const ${insertSchemaName} = ${z}.object({ ${joinCode(insertFieldLines, { on: ",\n" })} -}); +})${insertRefinementSuffix(obj)}; `; } @@ -535,6 +661,21 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code const docs = renderDocsFor(obj); const docsPrefix = docs ? `${docs}\n` : ""; + // A Zod object carrying refinements cannot be `.omit()`ed, and a TPH subtype's form + // omits the pinned discriminator. So when both apply, the unrefined shape is also + // exported as `InsertBaseSchema` for the form to omit from; the refined + // `InsertSchema` is built on it. + const refineSuffix = insertRefinementSuffix(obj); + const insertDecl = refineSuffix !== "" && tphPin !== undefined + ? code`${docsPrefix}export const ${objName}InsertBaseSchema = ${z}.object({ +${joinCode(insertFieldLines, { on: ",\n" })} +}); + +export const ${insertSchemaName} = ${objName}InsertBaseSchema${refineSuffix};` + : code`${docsPrefix}export const ${insertSchemaName} = ${z}.object({ +${joinCode(insertFieldLines, { on: ",\n" })} +})${refineSuffix};`; + // #203 — emit the preserving insert-shape only for @autoSet entities (nothing to // preserve otherwise). Backs `insertPreserving` (import/restore/replication). const preservingBlock = emitPreserving @@ -545,16 +686,14 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code * (no create-time now() stamp) so the caller's original values are preserved. */ export const ${preservingSchemaName} = ${z}.object({ ${joinCode(preservingFieldLines, { on: ",\n" })} -}); +})${insertRefinementSuffix(obj)}; /** Typed input of \`insertPreserving${objName}\` — ${preservingSchemaName}'s pre-transform shape. */ export type ${objName}CreatePreserving = ${z}.input;` : code``; return code` -${utcTimestampHelperSince(utcUsesBefore)}${decimalHelperSince(decimalUsesBefore)}${docsPrefix}export const ${insertSchemaName} = ${z}.object({ -${joinCode(insertFieldLines, { on: ",\n" })} -}); +${utcTimestampHelperSince(utcUsesBefore)}${decimalHelperSince(decimalUsesBefore)}${insertDecl} ${docsPrefix}export const ${updateSchemaName} = ${z}.object({ ${joinCode(updateFieldLines, { on: ",\n" })} diff --git a/server/typescript/packages/codegen-ts/test/zod-cross-field.test.ts b/server/typescript/packages/codegen-ts/test/zod-cross-field.test.ts new file mode 100644 index 0000000000..4a6727da0c --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/zod-cross-field.test.ts @@ -0,0 +1,121 @@ +// Entity-scoped cross-field validators (comparison / requiredWhen / presentIff / +// atLeastOne) are enforced by a `.superRefine` on the INSERT-shaped schemas ONLY. +// Execution pins (render -> import -> safeParse), plus the "update schema unchanged" pin. + +import { describe, test, expect, afterEach } from "bun:test"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + TypeId, TYPE_IDENTITY, TYPE_VALIDATOR, + IDENTITY_SUBTYPE_PRIMARY, OBJECT_SUBTYPE_ENTITY, + FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_BOOLEAN, + VALIDATOR_SUBTYPE_COMPARISON, VALIDATOR_SUBTYPE_REQUIRED_WHEN, + VALIDATOR_SUBTYPE_PRESENT_IFF, VALIDATOR_SUBTYPE_AT_LEAST_ONE, + VALIDATOR_ATTR_LEFT, VALIDATOR_ATTR_OP, VALIDATOR_ATTR_RIGHT, + VALIDATOR_ATTR_FIELD, VALIDATOR_ATTR_WHEN, VALIDATOR_ATTR_EQUALS, VALIDATOR_ATTR_FIELDS, +} from "@metaobjectsdev/metadata"; +import type { AttrValue, MetaField, MetaObject } from "@metaobjectsdev/metadata"; +import { meta, metaObject, metaField } from "./_meta-build.js"; +import { renderZodValidators, hasInsertRefinements } from "../src/templates/zod-validators.js"; + +// biome-ignore lint/suspicious/noExplicitAny: dynamically imported generated module — no static shape +type GeneratedModule = Record; + +const tmpDirs: string[] = []; +afterEach(() => { + while (tmpDirs.length > 0) rmSync(tmpDirs.pop()!, { recursive: true, force: true }); +}); + +async function executeGenerated(source: string): Promise { + const dir = mkdtempSync(join(import.meta.dir, "tmp-zod-xf-")); + tmpDirs.push(dir); + const file = join(dir, "schema.ts"); + writeFileSync(file, source); + return import(pathToFileURL(file).href); +} + +function entityWith(name: string, fields: MetaField[], validators: ReturnType[]): MetaObject { + const obj = metaObject(OBJECT_SUBTYPE_ENTITY, name); + obj.addChild(metaField(FIELD_SUBTYPE_LONG, "id")); + for (const f of fields) obj.addChild(f); + const primary = meta(new TypeId(TYPE_IDENTITY, IDENTITY_SUBTYPE_PRIMARY), "primary"); + primary.setAttr("fields", ["id"]); + primary.setAttr("generation", "increment"); + obj.addChild(primary); + for (const v of validators) obj.addChild(v); + return obj; +} + +function validator(subType: string, name: string, attrs: Record) { + const v = meta(new TypeId(TYPE_VALIDATOR, subType), name); + for (const [k, val] of Object.entries(attrs)) v.setAttr(k, val); + return v; +} + +const f = (sub: string, name: string) => metaField(sub, name); + +function sample(): MetaObject { + return entityWith("Thing", [ + f(FIELD_SUBTYPE_INT, "a"), f(FIELD_SUBTYPE_INT, "b"), f(FIELD_SUBTYPE_STRING, "kind"), + f(FIELD_SUBTYPE_STRING, "target"), f(FIELD_SUBTYPE_BOOLEAN, "flag"), f(FIELD_SUBTYPE_INT, "level"), + f(FIELD_SUBTYPE_STRING, "email"), f(FIELD_SUBTYPE_STRING, "phone"), + ], [ + validator(VALIDATOR_SUBTYPE_COMPARISON, "cmp", { [VALIDATOR_ATTR_LEFT]: "a", [VALIDATOR_ATTR_OP]: "lt", [VALIDATOR_ATTR_RIGHT]: "b" }), + validator(VALIDATOR_SUBTYPE_REQUIRED_WHEN, "rw", { [VALIDATOR_ATTR_FIELD]: "target", [VALIDATOR_ATTR_WHEN]: "kind", [VALIDATOR_ATTR_EQUALS]: "X" }), + validator(VALIDATOR_SUBTYPE_PRESENT_IFF, "pi", { [VALIDATOR_ATTR_FIELD]: "target", [VALIDATOR_ATTR_WHEN]: "flag", [VALIDATOR_ATTR_EQUALS]: "true" }), + validator(VALIDATOR_SUBTYPE_AT_LEAST_ONE, "alo", { [VALIDATOR_ATTR_FIELDS]: ["email", "phone"] }), + ]); +} + +describe("cross-field validators on the insert schema", () => { + test("insert schema is refined; update schema is byte-for-byte what it was without validators", () => { + const withV = renderZodValidators(sample()).toString(); + const without = renderZodValidators(entityWith("Thing", [ + f(FIELD_SUBTYPE_INT, "a"), f(FIELD_SUBTYPE_INT, "b"), f(FIELD_SUBTYPE_STRING, "kind"), + f(FIELD_SUBTYPE_STRING, "target"), f(FIELD_SUBTYPE_BOOLEAN, "flag"), f(FIELD_SUBTYPE_INT, "level"), + f(FIELD_SUBTYPE_STRING, "email"), f(FIELD_SUBTYPE_STRING, "phone"), + ], [])).toString(); + expect(withV).toContain(".superRefine("); + expect(without).not.toContain("superRefine"); + const upd = (s: string) => s.slice(s.indexOf("export const ThingUpdateSchema")); + expect(upd(withV)).toBe(upd(without)); + expect(upd(withV)).not.toContain("superRefine"); + expect(hasInsertRefinements(sample())).toBe(true); + }); + + test("comparison, requiredWhen, presentIff, atLeastOne", async () => { + const mod = await executeGenerated(renderZodValidators(sample()).toString()); + const ok = (v: unknown) => mod.ThingInsertSchema.safeParse(v).success; + const base = { a: 1, b: 2, kind: "Y", email: "e" }; + expect(ok(base)).toBe(true); + expect(ok({ ...base, a: 2 })).toBe(false); // lt violated on equality + expect(ok({ ...base, a: undefined })).toBe(true); // null side passes + expect(ok({ ...base, kind: "X" })).toBe(false); // requiredWhen: target missing + expect(ok({ ...base, kind: "X", target: "t", flag: true })).toBe(true); + expect(ok({ ...base, flag: true, target: "t" })).toBe(true); + expect(ok({ ...base, target: "t" })).toBe(false); // presentIff: null gate => target must be absent + expect(ok({ ...base, flag: false, target: "t" })).toBe(false); + expect(ok({ a: 1, b: 2, kind: "Y" })).toBe(false); // atLeastOne + expect(ok({ ...base, email: "" })).toBe(true); // "" is not null + const issues = mod.ThingInsertSchema.safeParse({ ...base, a: 5 }); + expect(issues.error.issues[0].path).toEqual(["a"]); + }); + + test("a validator referencing a missing field is skipped silently", async () => { + const e = entityWith("Solo", [f(FIELD_SUBTYPE_INT, "a")], [ + validator(VALIDATOR_SUBTYPE_COMPARISON, "cmp", { [VALIDATOR_ATTR_LEFT]: "a", [VALIDATOR_ATTR_OP]: "lt", [VALIDATOR_ATTR_RIGHT]: "nope" }), + ]); + expect(renderZodValidators(e).toString()).not.toContain("superRefine"); + }); + + test("numeric @equals compares numerically against the gate field", async () => { + const e = entityWith("Lv", [f(FIELD_SUBTYPE_INT, "level"), f(FIELD_SUBTYPE_STRING, "note")], [ + validator(VALIDATOR_SUBTYPE_REQUIRED_WHEN, "rw", { [VALIDATOR_ATTR_FIELD]: "note", [VALIDATOR_ATTR_WHEN]: "level", [VALIDATOR_ATTR_EQUALS]: "3" }), + ]); + const mod = await executeGenerated(renderZodValidators(e).toString()); + expect(mod.LvInsertSchema.safeParse({ level: 3 }).success).toBe(false); + expect(mod.LvInsertSchema.safeParse({ level: 4 }).success).toBe(true); + expect(mod.LvInsertSchema.safeParse({ level: 3, note: "n" }).success).toBe(true); + }); +}); diff --git a/server/typescript/packages/integration-tests/src/validation-cases.ts b/server/typescript/packages/integration-tests/src/validation-cases.ts index 7a2379f305..fcd61b33c5 100644 --- a/server/typescript/packages/integration-tests/src/validation-cases.ts +++ b/server/typescript/packages/integration-tests/src/validation-cases.ts @@ -27,3 +27,11 @@ export function loadCases(): ValidationCase[] { const parsed = JSON.parse(raw) as { cases: ValidationCase[] }; return parsed.cases; } + +/** Read fixtures/validation-conformance/cross-field-cases.json — the entity-scoped + * cross-field validator corpus (meta.cross-field.json, entity `Event`). */ +export function loadCrossFieldCases(): ValidationCase[] { + const raw = readFileSync(join(VALIDATION_DIR, "cross-field-cases.json"), "utf8"); + const parsed = JSON.parse(raw) as { cases: ValidationCase[] }; + return parsed.cases; +} diff --git a/server/typescript/packages/integration-tests/test/validation-conformance-cross-field.test.ts b/server/typescript/packages/integration-tests/test/validation-conformance-cross-field.test.ts new file mode 100644 index 0000000000..ad505b9bba --- /dev/null +++ b/server/typescript/packages/integration-tests/test/validation-conformance-cross-field.test.ts @@ -0,0 +1,69 @@ +// Cross-field validation-conformance, TS (Zod) reference runner. +// +// Renders the GENERATED `EventInsertSchema` (validator.comparison / requiredWhen / +// presentIff / atLeastOne enforced as a refinement) through the real codegen template +// into a temp module, then safeParses every cross-field-cases.json payload. + +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import type { ZodTypeAny } from "zod"; +import { renderZodValidators } from "@metaobjectsdev/codegen-ts/templates/zod-validators"; +import { VALIDATION_DIR } from "../src/paths.ts"; +import { loadMetadataFile } from "../src/load-metadata.ts"; +import { loadCrossFieldCases } from "../src/validation-cases.ts"; + +type ParseFn = { safeParse(value: unknown): { success: boolean } }; + +const ENTITY_NAMES = ["Event"] as const; +const insertSchemas = new Map(); +let updateSchema: ParseFn; +let tmpDir: string; + +beforeAll(async () => { + const root = await loadMetadataFile(join(VALIDATION_DIR, "meta.cross-field.json")); + const zodPath = pathToFileURL(Bun.resolveSync("zod", import.meta.dir)).href; + tmpDir = mkdtempSync(join(tmpdir(), "validation-cross-field-")); + const modulePaths: Array = []; + for (const name of ENTITY_NAMES) { + const entity = root.findObject(name); + if (!entity) throw new Error(`cross-field meta has no object named ${name}`); + const generated = renderZodValidators(entity).toString().replace(/(['"])zod\1/g, JSON.stringify(zodPath)); + const modulePath = join(tmpDir, `${name}.ts`); + writeFileSync(modulePath, generated, "utf8"); + modulePaths.push([name, modulePath] as const); + } + for (const [name, modulePath] of modulePaths) { + const mod = (await import(pathToFileURL(modulePath).href)) as Record; + const schema = mod[`${name}InsertSchema`]; + if (!schema) throw new Error(`generated module did not export ${name}InsertSchema`); + insertSchemas.set(name, schema as unknown as ParseFn); + updateSchema = mod[`${name}UpdateSchema`] as unknown as ParseFn; + } +}); + +describe("cross-field validation conformance — TS Zod reference runner", () => { + for (const c of loadCrossFieldCases()) { + test(c.name, () => { + const entityName = c.entity ?? "Event"; + const schema = insertSchemas.get(entityName); + if (!schema) throw new Error(`case "${c.name}" names unknown entity ${entityName}`); + expect( + schema.safeParse(c.payload).success, + `case "${c.name}": expected valid=${c.expectValid}`, + ).toBe(c.expectValid); + }); + } + + test("the PATCH schema never enforces cross-field rules (a partial body cannot see the merged row)", () => { + // Violates comparison, requiredWhen, presentIff and atLeastOne at once. + expect(updateSchema.safeParse({ startsAt: 9, endsAt: 1, kind: "BIRTH", state: "CLOSED" }).success).toBe(true); + expect(updateSchema.safeParse({}).success).toBe(true); + }); +}); + +afterAll(() => { + if (tmpDir) rmSync(tmpDir, { recursive: true, force: true }); +}); diff --git a/server/typescript/packages/test-generators/src/templates/zod-validators.ts b/server/typescript/packages/test-generators/src/templates/zod-validators.ts index ea4945feae..c9fe7b03dc 100644 --- a/server/typescript/packages/test-generators/src/templates/zod-validators.ts +++ b/server/typescript/packages/test-generators/src/templates/zod-validators.ts @@ -28,6 +28,10 @@ import { FIELD_ATTR_DB_COLUMN_TYPE, DB_COLUMN_TYPE_JSONB, AUTO_SET_ON_CREATE, AUTO_SET_ON_UPDATE, VALIDATOR_ATTR_MAX, VALIDATOR_ATTR_MIN, VALIDATOR_ATTR_PATTERN, + VALIDATOR_SUBTYPE_COMPARISON, VALIDATOR_SUBTYPE_REQUIRED_WHEN, + VALIDATOR_SUBTYPE_PRESENT_IFF, VALIDATOR_SUBTYPE_AT_LEAST_ONE, + VALIDATOR_ATTR_LEFT, VALIDATOR_ATTR_OP, VALIDATOR_ATTR_RIGHT, + VALIDATOR_ATTR_FIELD, VALIDATOR_ATTR_WHEN, VALIDATOR_ATTR_EQUALS, VALIDATOR_ATTR_FIELDS, GENERATION_INCREMENT, GENERATION_UUID, OBJECT_ATTR_DISCRIMINATOR, OBJECT_ATTR_DISCRIMINATOR_VALUE, OBJECT_SUBTYPE_VALUE, @@ -256,6 +260,128 @@ function isInsertRequiredPk(field: MetaField, assignedPk: Set): boolean return field.attr(FIELD_ATTR_DEFAULT) === undefined; } +// ── Entity-scoped cross-field validators on the full (insert) record ────────── +// validator.comparison / requiredWhen / presentIff / atLeastOne are enforced on the +// INSERT-shaped schemas only, as one `.superRefine`. A PATCH body cannot see the merged +// row, so the Update schema is deliberately left alone (the DB CHECK stays the guard +// there). Semantics mirror the migrate-ts CHECK derivation: absent / null / undefined is +// SQL NULL, "" is NOT null; a comparison with a NULL side passes; requiredWhen is violated +// only when the gate EQUALS the literal and the target is null; presentIff requires +// (target != null) === (gate equals literal), a null gate being "not equal". + +const CROSS_FIELD_OPS: Record = { + gt: ">", gte: ">=", lt: "<", lte: "<=", ne: "!==", eq: "===", +}; + +const NUMERIC_SUBTYPES: ReadonlySet = new Set([ + FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, + FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_CURRENCY, +]); + +/** Names of the fields the INSERT-shaped schemas carry (same skip rules as the emitters). */ +function insertSchemaFieldNameSet(obj: MetaObject): Set { + const autoGen = autoGenPkFieldNames(obj); + const out = new Set(); + for (const f of obj.fields()) { + if (autoGen.has(f.name)) continue; + if (isReadOnlyMutability(f) || f.isDerived()) continue; + out.add(f.name); + } + return out; +} + +/** The @equals literal as a TS expression, typed by the gating field's subtype. */ +function crossFieldLiteral(raw: unknown, when: MetaField): { lit: string; numeric: boolean } { + const s = String(raw); + if (when.subType === FIELD_SUBTYPE_BOOLEAN) { + return { lit: s === "true" || s === "1" || s === "TRUE" ? "true" : "false", numeric: false }; + } + if (NUMERIC_SUBTYPES.has(when.subType) && /^-?\d+(\.\d+)?$/.test(s)) return { lit: s, numeric: true }; + return { lit: JSON.stringify(s), numeric: false }; +} + +/** Does this object declare at least one cross-field validator that will be enforced + * on its insert schema? Exported so the form emitter can tell a refined schema + * (which Zod will not `.omit()`) from a plain object. */ +export function hasInsertRefinements(obj: MetaObject): boolean { + return crossFieldRefinementBody(obj).length > 0; +} + +function crossFieldRefinementBody(obj: MetaObject): string[] { + const inSchema = insertSchemaFieldNameSet(obj); + const byName = new Map(obj.fields().map((f) => [f.name, f] as const)); + // A reference must name a field that exists AND is in the insert schema; otherwise skip + // the validator (migrate skips on a missing field; a readOnly/derived/auto-PK field is + // never in the body, so enforcing on it would reject every create). + const ref = (name: unknown): MetaField | undefined => { + if (typeof name !== "string" || !inSchema.has(name)) return undefined; + return byName.get(name); + }; + const acc = (f: MetaField) => `row[${JSON.stringify(f.name)}]`; + const issue = (path: string, msg: string) => + `ctx.addIssue({ code: "custom", path: [${path}], message: ${JSON.stringify(msg)} });`; + const blocks: string[] = []; + // ADR-0039: entity.validators() is the resolving accessor (never ownValidators). + for (const v of obj.validators()) { + switch (v.subType) { + case VALIDATOR_SUBTYPE_COMPARISON: { + const left = ref(v.attr(VALIDATOR_ATTR_LEFT)); + const right = ref(v.attr(VALIDATOR_ATTR_RIGHT)); + const opName = String(v.attr(VALIDATOR_ATTR_OP)); + const op = CROSS_FIELD_OPS[opName]; + if (!left || !right || !op) break; + const numeric = NUMERIC_SUBTYPES.has(left.subType); + const c = numeric + ? "Number(l) - Number(r)" + : "l instanceof Date && r instanceof Date ? l.getTime() - r.getTime() : String(l) < String(r) ? -1 : String(l) > String(r) ? 1 : 0"; + blocks.push( + ` {\n const l: unknown = ${acc(left)};\n const r: unknown = ${acc(right)};\n` + + ` if (l != null && r != null) {\n const c = ${c};\n` + + ` if (!(c ${op} 0)) ${issue(JSON.stringify(left.name), `${left.name} must be ${opName} ${right.name}`)}\n }\n }`, + ); + break; + } + case VALIDATOR_SUBTYPE_REQUIRED_WHEN: + case VALIDATOR_SUBTYPE_PRESENT_IFF: { + const target = ref(v.attr(VALIDATOR_ATTR_FIELD)); + const when = ref(v.attr(VALIDATOR_ATTR_WHEN)); + if (!target || !when) break; + const { lit, numeric } = crossFieldLiteral(v.attr(VALIDATOR_ATTR_EQUALS), when); + const gate = numeric ? `g != null && Number(g) === ${lit}` : `g === ${lit}`; + const path = JSON.stringify(target.name); + const decl = ` const g: unknown = ${acc(when)};\n const t: unknown = ${acc(target)};\n const open = ${gate};\n`; + if (v.subType === VALIDATOR_SUBTYPE_REQUIRED_WHEN) { + blocks.push(` {\n${decl} if (open && t == null) ${issue(path, `${target.name} is required when ${when.name} is ${String(v.attr(VALIDATOR_ATTR_EQUALS))}`)}\n }`); + } else { + blocks.push(` {\n${decl} if ((t != null) !== open) ${issue(path, `${target.name} must be present if and only if ${when.name} is ${String(v.attr(VALIDATOR_ATTR_EQUALS))}`)}\n }`); + } + break; + } + case VALIDATOR_SUBTYPE_AT_LEAST_ONE: { + const raw = v.attr(VALIDATOR_ATTR_FIELDS); + const names = Array.isArray(raw) ? raw : (typeof raw === "string" ? [raw] : []); + const refs = names.map(ref); + if (refs.length === 0 || refs.some((r) => r === undefined)) break; + const fields = refs as MetaField[]; + const anyPresent = fields.map((f) => `${acc(f)} != null`).join(" || "); + blocks.push(` if (!(${anyPresent})) ${issue(JSON.stringify(fields[0]!.name), `at least one of ${fields.map((f) => f.name).join(", ")} is required`)}`); + break; + } + default: + break; + } + } + return blocks; +} + +/** `.superRefine(...)` suffix for an insert-shaped schema, or "" when the object has no + * enforceable cross-field validator (output is then byte-identical to before). */ +function insertRefinementSuffix(obj: MetaObject): string { + const blocks = crossFieldRefinementBody(obj); + if (blocks.length === 0) return ""; + return `.superRefine((row, ctx) => {\n${blocks.join("\n")}\n})`; +} + /** * Emit ONLY the `InsertSchema`. Used by the value-object file emitter * for metaobjects with no writable source.rdb — those have no PATCH/update @@ -322,7 +448,7 @@ export function renderInsertSchemaOnly(obj: MetaObject, ctx?: RenderContext): Co return code` ${utcTimestampHelperSince(utcUsesBefore)}${decimalHelperSince(decimalUsesBefore)}${docsPrefix}export const ${insertSchemaName} = ${z}.object({ ${joinCode(insertFieldLines, { on: ",\n" })} -}); +})${insertRefinementSuffix(obj)}; `; } @@ -535,6 +661,21 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code const docs = renderDocsFor(obj); const docsPrefix = docs ? `${docs}\n` : ""; + // A Zod object carrying refinements cannot be `.omit()`ed, and a TPH subtype's form + // omits the pinned discriminator. So when both apply, the unrefined shape is also + // exported as `InsertBaseSchema` for the form to omit from; the refined + // `InsertSchema` is built on it. + const refineSuffix = insertRefinementSuffix(obj); + const insertDecl = refineSuffix !== "" && tphPin !== undefined + ? code`${docsPrefix}export const ${objName}InsertBaseSchema = ${z}.object({ +${joinCode(insertFieldLines, { on: ",\n" })} +}); + +export const ${insertSchemaName} = ${objName}InsertBaseSchema${refineSuffix};` + : code`${docsPrefix}export const ${insertSchemaName} = ${z}.object({ +${joinCode(insertFieldLines, { on: ",\n" })} +})${refineSuffix};`; + // #203 — emit the preserving insert-shape only for @autoSet entities (nothing to // preserve otherwise). Backs `insertPreserving` (import/restore/replication). const preservingBlock = emitPreserving @@ -545,16 +686,14 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code * (no create-time now() stamp) so the caller's original values are preserved. */ export const ${preservingSchemaName} = ${z}.object({ ${joinCode(preservingFieldLines, { on: ",\n" })} -}); +})${insertRefinementSuffix(obj)}; /** Typed input of \`insertPreserving${objName}\` — ${preservingSchemaName}'s pre-transform shape. */ export type ${objName}CreatePreserving = ${z}.input;` : code``; return code` -${utcTimestampHelperSince(utcUsesBefore)}${decimalHelperSince(decimalUsesBefore)}${docsPrefix}export const ${insertSchemaName} = ${z}.object({ -${joinCode(insertFieldLines, { on: ",\n" })} -}); +${utcTimestampHelperSince(utcUsesBefore)}${decimalHelperSince(decimalUsesBefore)}${insertDecl} ${docsPrefix}export const ${updateSchemaName} = ${z}.object({ ${joinCode(updateFieldLines, { on: ",\n" })} From 19f243d314674ae763a008be6ac5e91c8421a068 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 3 Nov 2026 21:41:21 -0500 Subject: [PATCH 02/10] no-mistakes(review): Enforce cross-field rules on TPH subtype create in TS, Java, Kotlin --- .../codegen/generators/routes.ts | 9 +- .../codegen/templates/queries-file.ts | 9 +- .../codegen/templates/zod-validators.ts | 29 ++- .../showcase/codegen/generators/routes.ts | 9 +- .../codegen/templates/queries-file.ts | 9 +- .../codegen/templates/zod-validators.ts | 29 ++- server/java/codegen-kotlin/pom.xml | 1 + .../generator/kotlin/GeneratorRegistry.kt | 8 +- .../generator/kotlin/KotlinCrossFieldRules.kt | 101 ++++++++++ .../generator/kotlin/KotlinEntityGenerator.kt | 75 +------- .../kotlin/KotlinSpringControllerGenerator.kt | 11 ++ .../spring/SpringControllerGenerator.java | 20 +- .../spring/SpringGeneratorSupport.java | 55 ++++-- .../GeneratedTphCrossFieldCompileRunTest.java | 163 ++++++++++++++++ .../TphCrossFieldControllerRunTest.kt | 112 +++++++++++ .../metaobjects/mojo/EjectSupportTest.java | 3 +- .../src/templates/form-file.ts | 12 +- .../codegen-ts/src/reference/routes.ts | 9 +- .../codegen-ts/src/templates/queries-file.ts | 9 +- .../codegen-ts/src/templates/routes-file.ts | 9 +- .../src/templates/zod-validators.ts | 29 ++- .../codegen-ts/test/tph-cross-field.test.ts | 180 ++++++++++++++++++ .../test-generators/src/generators/routes.ts | 9 +- .../src/templates/queries-file.ts | 9 +- .../src/templates/zod-validators.ts | 29 ++- 25 files changed, 776 insertions(+), 162 deletions(-) create mode 100644 server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinCrossFieldRules.kt create mode 100644 server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/GeneratedTphCrossFieldCompileRunTest.java create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/tph/generated/TphCrossFieldControllerRunTest.kt create mode 100644 server/typescript/packages/codegen-ts/test/tph-cross-field.test.ts diff --git a/examples/advanced-modeling/codegen/generators/routes.ts b/examples/advanced-modeling/codegen/generators/routes.ts index 56a79d85b6..08d409771c 100644 --- a/examples/advanced-modeling/codegen/generators/routes.ts +++ b/examples/advanced-modeling/codegen/generators/routes.ts @@ -94,6 +94,7 @@ import { } from "@metaobjectsdev/codegen-ts/engine"; import { renderRoutesIndex, routesIndexFileName } from "../templates/routes-index.js"; import { isTphDiscriminatorBase, tphPlan } from "../templates/tph-discriminator.js"; +import { hasInsertRefinements } from "../templates/zod-validators.js"; // --- composition (OWNED) — assembles one .routes.ts. Change this to change the output. --- // Dispatch: a TPH discriminator base → polymorphic list/get + a per-subtype CRUD set; a @@ -621,7 +622,11 @@ function renderTphRoutes( const subFileSpec = entityModuleSpecifier( ctx.selfTarget, ctx.entityModuleTarget, effectivePackage(sub), sub.name, ctx.extStyle, ); - const subInsertSym = imp(`${sub.name}InsertSchema@${subFileSpec}`); + // A subtype with cross-field rules has a refined InsertSchema, which Zod will not + // `.omit()`; its codegen exports the discriminator-less refined body instead. + const subInsertBody = hasInsertRefinements(sub) + ? code`${imp(`${sub.name}InsertBodySchema@${subFileSpec}`)}` + : code`${imp(`${sub.name}InsertSchema@${subFileSpec}`)}.omit({ ${discField}: true })`; // FR-036 Program B: the per-subtype UPDATE must carry the FR-035 present-key // tristate (a non-@required subtype column accepts an explicit null → clears; // a @required column's explicit null is rejected). The subtype's own @@ -640,7 +645,7 @@ function renderTphRoutes( path: ${baseConstSym}.$path + ${JSON.stringify(`/${segment}`)}, db: ${dbSym}, table: ${tableSym}, - insertSchema: ${subInsertSym}.omit({ ${discField}: true }), + insertSchema: ${subInsertBody}, updateSchema: ${subUpdateSym}, filterAllowlist: ${subFilterSym}, sortAllowlist: ${subSortSym}, diff --git a/examples/advanced-modeling/codegen/templates/queries-file.ts b/examples/advanced-modeling/codegen/templates/queries-file.ts index e9e3c386d9..d7362d6181 100644 --- a/examples/advanced-modeling/codegen/templates/queries-file.ts +++ b/examples/advanced-modeling/codegen/templates/queries-file.ts @@ -28,7 +28,7 @@ import { isTphDiscriminatorBase, tphConcreteSubtypes } from "./tph-discriminator import { isProjection, isWriteThrough } from "@metaobjectsdev/codegen-ts/engine"; import { isReport } from "@metaobjectsdev/codegen-ts/engine"; import { hasItemRoute } from "@metaobjectsdev/codegen-ts/engine"; -import { hasAutoSetFields } from "./zod-validators.js"; +import { hasAutoSetFields, hasInsertRefinements } from "./zod-validators.js"; import { effectivePackage } from "@metaobjectsdev/codegen-ts/engine"; /** @@ -372,6 +372,9 @@ export async function list${pluralize(baseName)}(db: Db, opts?: { limit?: number // the schema (schemaInputType), so a subtype module from an owned entity generator // that predates the `Create` alias still satisfies it. const subCreateSym = schemaInputType(subInsertSym); + // A refined (cross-field) InsertSchema has no `.partial()`; the partial update reads + // the unrefined shape, as an unrefined subtype's does. + const subPartialSym = hasInsertRefinements(sub) ? imp(`${sub.name}InsertBaseSchema@${subFileSpec}`) : subInsertSym; subtypeSections.push(code` export async function list${pluralize(sub.name)}(db: Db, opts?: { limit?: number; offset?: number }): Promise<${subTypeSym}[]> { @@ -395,7 +398,7 @@ ${supportsReturning(ctx.dialect) ? code`export async function create${sub.name}( } export async function update${sub.name}ById(db: Db, ${pkField}: ${pkType}, data: Partial<${subCreateSym}>): Promise<${subTypeSym} | null> { - const validated = ${subInsertSym}.partial().parse(data) as Record; + const validated = ${subPartialSym}.partial().parse(data) as Record; // The discriminator is immutable — a ${sub.name} can never become another subtype. const { [${JSON.stringify(discField)}]: _disc, ...safe } = validated; const [row] = await db.update(${tableSym}).set(safe) @@ -417,7 +420,7 @@ export async function create${sub.name}(db: Db, data: ${subCreateSym}): Promise< } export async function update${sub.name}ById(db: Db, ${pkField}: ${pkType}, data: Partial<${subCreateSym}>): Promise<${subTypeSym} | null> { - const validated = ${subInsertSym}.partial().parse(data) as Record; + const validated = ${subPartialSym}.partial().parse(data) as Record; // The discriminator is immutable — a ${sub.name} can never become another subtype. const { [${JSON.stringify(discField)}]: _disc, ...safe } = validated; if (Object.keys(safe).length > 0) { diff --git a/examples/advanced-modeling/codegen/templates/zod-validators.ts b/examples/advanced-modeling/codegen/templates/zod-validators.ts index c9fe7b03dc..4dd6aef2c3 100644 --- a/examples/advanced-modeling/codegen/templates/zod-validators.ts +++ b/examples/advanced-modeling/codegen/templates/zod-validators.ts @@ -301,8 +301,10 @@ function crossFieldLiteral(raw: unknown, when: MetaField): { lit: string; numeri } /** Does this object declare at least one cross-field validator that will be enforced - * on its insert schema? Exported so the form emitter can tell a refined schema - * (which Zod will not `.omit()`) from a plain object. */ + * on its insert schema? Exported so the route, query and form emitters can tell a refined + * schema (which Zod will not `.omit()` or `.partial()`) from a plain object; a refined TPH + * subtype also exports `InsertBaseSchema` (unrefined) and `InsertBodySchema` + * (refined, pinned discriminator omitted). */ export function hasInsertRefinements(obj: MetaObject): boolean { return crossFieldRefinementBody(obj).length > 0; } @@ -317,7 +319,12 @@ function crossFieldRefinementBody(obj: MetaObject): string[] { if (typeof name !== "string" || !inSchema.has(name)) return undefined; return byName.get(name); }; - const acc = (f: MetaField) => `row[${JSON.stringify(f.name)}]`; + // A TPH subtype's discriminator is pinned to one literal, so the rule reads that literal: + // the same refinement then holds whether or not the body carries the discriminator. + const pin = tphDiscriminatorPin(obj); + const acc = (f: MetaField) => pin !== undefined && f.name === pin.fieldName + ? JSON.stringify(pin.value) + : `row[${JSON.stringify(f.name)}]`; const issue = (path: string, msg: string) => `ctx.addIssue({ code: "custom", path: [${path}], message: ${JSON.stringify(msg)} });`; const blocks: string[] = []; @@ -661,17 +668,21 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code const docs = renderDocsFor(obj); const docsPrefix = docs ? `${docs}\n` : ""; - // A Zod object carrying refinements cannot be `.omit()`ed, and a TPH subtype's form - // omits the pinned discriminator. So when both apply, the unrefined shape is also - // exported as `InsertBaseSchema` for the form to omit from; the refined - // `InsertSchema` is built on it. + // A Zod object carrying refinements cannot be `.omit()`ed or `.partial()`ed, and a TPH + // subtype's route and form omit the pinned discriminator while its update query takes a + // partial. So when both apply, the unrefined shape is exported as `InsertBaseSchema` + // and refined twice: as `InsertSchema` and, discriminator omitted, as + // `InsertBodySchema`. const refineSuffix = insertRefinementSuffix(obj); const insertDecl = refineSuffix !== "" && tphPin !== undefined - ? code`${docsPrefix}export const ${objName}InsertBaseSchema = ${z}.object({ + ? code`export const ${objName}InsertBaseSchema = ${z}.object({ ${joinCode(insertFieldLines, { on: ",\n" })} }); -export const ${insertSchemaName} = ${objName}InsertBaseSchema${refineSuffix};` +${docsPrefix}export const ${insertSchemaName} = ${objName}InsertBaseSchema${refineSuffix}; + +/** ${insertSchemaName} without the pinned \`${tphPin.fieldName}\` — the body a per-subtype route accepts. */ +export const ${objName}InsertBodySchema = ${objName}InsertBaseSchema.omit({ ${tphPin.fieldName}: true })${refineSuffix};` : code`${docsPrefix}export const ${insertSchemaName} = ${z}.object({ ${joinCode(insertFieldLines, { on: ",\n" })} })${refineSuffix};`; diff --git a/examples/showcase/codegen/generators/routes.ts b/examples/showcase/codegen/generators/routes.ts index 56a79d85b6..08d409771c 100644 --- a/examples/showcase/codegen/generators/routes.ts +++ b/examples/showcase/codegen/generators/routes.ts @@ -94,6 +94,7 @@ import { } from "@metaobjectsdev/codegen-ts/engine"; import { renderRoutesIndex, routesIndexFileName } from "../templates/routes-index.js"; import { isTphDiscriminatorBase, tphPlan } from "../templates/tph-discriminator.js"; +import { hasInsertRefinements } from "../templates/zod-validators.js"; // --- composition (OWNED) — assembles one .routes.ts. Change this to change the output. --- // Dispatch: a TPH discriminator base → polymorphic list/get + a per-subtype CRUD set; a @@ -621,7 +622,11 @@ function renderTphRoutes( const subFileSpec = entityModuleSpecifier( ctx.selfTarget, ctx.entityModuleTarget, effectivePackage(sub), sub.name, ctx.extStyle, ); - const subInsertSym = imp(`${sub.name}InsertSchema@${subFileSpec}`); + // A subtype with cross-field rules has a refined InsertSchema, which Zod will not + // `.omit()`; its codegen exports the discriminator-less refined body instead. + const subInsertBody = hasInsertRefinements(sub) + ? code`${imp(`${sub.name}InsertBodySchema@${subFileSpec}`)}` + : code`${imp(`${sub.name}InsertSchema@${subFileSpec}`)}.omit({ ${discField}: true })`; // FR-036 Program B: the per-subtype UPDATE must carry the FR-035 present-key // tristate (a non-@required subtype column accepts an explicit null → clears; // a @required column's explicit null is rejected). The subtype's own @@ -640,7 +645,7 @@ function renderTphRoutes( path: ${baseConstSym}.$path + ${JSON.stringify(`/${segment}`)}, db: ${dbSym}, table: ${tableSym}, - insertSchema: ${subInsertSym}.omit({ ${discField}: true }), + insertSchema: ${subInsertBody}, updateSchema: ${subUpdateSym}, filterAllowlist: ${subFilterSym}, sortAllowlist: ${subSortSym}, diff --git a/examples/showcase/codegen/templates/queries-file.ts b/examples/showcase/codegen/templates/queries-file.ts index e9e3c386d9..d7362d6181 100644 --- a/examples/showcase/codegen/templates/queries-file.ts +++ b/examples/showcase/codegen/templates/queries-file.ts @@ -28,7 +28,7 @@ import { isTphDiscriminatorBase, tphConcreteSubtypes } from "./tph-discriminator import { isProjection, isWriteThrough } from "@metaobjectsdev/codegen-ts/engine"; import { isReport } from "@metaobjectsdev/codegen-ts/engine"; import { hasItemRoute } from "@metaobjectsdev/codegen-ts/engine"; -import { hasAutoSetFields } from "./zod-validators.js"; +import { hasAutoSetFields, hasInsertRefinements } from "./zod-validators.js"; import { effectivePackage } from "@metaobjectsdev/codegen-ts/engine"; /** @@ -372,6 +372,9 @@ export async function list${pluralize(baseName)}(db: Db, opts?: { limit?: number // the schema (schemaInputType), so a subtype module from an owned entity generator // that predates the `Create` alias still satisfies it. const subCreateSym = schemaInputType(subInsertSym); + // A refined (cross-field) InsertSchema has no `.partial()`; the partial update reads + // the unrefined shape, as an unrefined subtype's does. + const subPartialSym = hasInsertRefinements(sub) ? imp(`${sub.name}InsertBaseSchema@${subFileSpec}`) : subInsertSym; subtypeSections.push(code` export async function list${pluralize(sub.name)}(db: Db, opts?: { limit?: number; offset?: number }): Promise<${subTypeSym}[]> { @@ -395,7 +398,7 @@ ${supportsReturning(ctx.dialect) ? code`export async function create${sub.name}( } export async function update${sub.name}ById(db: Db, ${pkField}: ${pkType}, data: Partial<${subCreateSym}>): Promise<${subTypeSym} | null> { - const validated = ${subInsertSym}.partial().parse(data) as Record; + const validated = ${subPartialSym}.partial().parse(data) as Record; // The discriminator is immutable — a ${sub.name} can never become another subtype. const { [${JSON.stringify(discField)}]: _disc, ...safe } = validated; const [row] = await db.update(${tableSym}).set(safe) @@ -417,7 +420,7 @@ export async function create${sub.name}(db: Db, data: ${subCreateSym}): Promise< } export async function update${sub.name}ById(db: Db, ${pkField}: ${pkType}, data: Partial<${subCreateSym}>): Promise<${subTypeSym} | null> { - const validated = ${subInsertSym}.partial().parse(data) as Record; + const validated = ${subPartialSym}.partial().parse(data) as Record; // The discriminator is immutable — a ${sub.name} can never become another subtype. const { [${JSON.stringify(discField)}]: _disc, ...safe } = validated; if (Object.keys(safe).length > 0) { diff --git a/examples/showcase/codegen/templates/zod-validators.ts b/examples/showcase/codegen/templates/zod-validators.ts index c9fe7b03dc..4dd6aef2c3 100644 --- a/examples/showcase/codegen/templates/zod-validators.ts +++ b/examples/showcase/codegen/templates/zod-validators.ts @@ -301,8 +301,10 @@ function crossFieldLiteral(raw: unknown, when: MetaField): { lit: string; numeri } /** Does this object declare at least one cross-field validator that will be enforced - * on its insert schema? Exported so the form emitter can tell a refined schema - * (which Zod will not `.omit()`) from a plain object. */ + * on its insert schema? Exported so the route, query and form emitters can tell a refined + * schema (which Zod will not `.omit()` or `.partial()`) from a plain object; a refined TPH + * subtype also exports `InsertBaseSchema` (unrefined) and `InsertBodySchema` + * (refined, pinned discriminator omitted). */ export function hasInsertRefinements(obj: MetaObject): boolean { return crossFieldRefinementBody(obj).length > 0; } @@ -317,7 +319,12 @@ function crossFieldRefinementBody(obj: MetaObject): string[] { if (typeof name !== "string" || !inSchema.has(name)) return undefined; return byName.get(name); }; - const acc = (f: MetaField) => `row[${JSON.stringify(f.name)}]`; + // A TPH subtype's discriminator is pinned to one literal, so the rule reads that literal: + // the same refinement then holds whether or not the body carries the discriminator. + const pin = tphDiscriminatorPin(obj); + const acc = (f: MetaField) => pin !== undefined && f.name === pin.fieldName + ? JSON.stringify(pin.value) + : `row[${JSON.stringify(f.name)}]`; const issue = (path: string, msg: string) => `ctx.addIssue({ code: "custom", path: [${path}], message: ${JSON.stringify(msg)} });`; const blocks: string[] = []; @@ -661,17 +668,21 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code const docs = renderDocsFor(obj); const docsPrefix = docs ? `${docs}\n` : ""; - // A Zod object carrying refinements cannot be `.omit()`ed, and a TPH subtype's form - // omits the pinned discriminator. So when both apply, the unrefined shape is also - // exported as `InsertBaseSchema` for the form to omit from; the refined - // `InsertSchema` is built on it. + // A Zod object carrying refinements cannot be `.omit()`ed or `.partial()`ed, and a TPH + // subtype's route and form omit the pinned discriminator while its update query takes a + // partial. So when both apply, the unrefined shape is exported as `InsertBaseSchema` + // and refined twice: as `InsertSchema` and, discriminator omitted, as + // `InsertBodySchema`. const refineSuffix = insertRefinementSuffix(obj); const insertDecl = refineSuffix !== "" && tphPin !== undefined - ? code`${docsPrefix}export const ${objName}InsertBaseSchema = ${z}.object({ + ? code`export const ${objName}InsertBaseSchema = ${z}.object({ ${joinCode(insertFieldLines, { on: ",\n" })} }); -export const ${insertSchemaName} = ${objName}InsertBaseSchema${refineSuffix};` +${docsPrefix}export const ${insertSchemaName} = ${objName}InsertBaseSchema${refineSuffix}; + +/** ${insertSchemaName} without the pinned \`${tphPin.fieldName}\` — the body a per-subtype route accepts. */ +export const ${objName}InsertBodySchema = ${objName}InsertBaseSchema.omit({ ${tphPin.fieldName}: true })${refineSuffix};` : code`${docsPrefix}export const ${insertSchemaName} = ${z}.object({ ${joinCode(insertFieldLines, { on: ",\n" })} })${refineSuffix};`; diff --git a/server/java/codegen-kotlin/pom.xml b/server/java/codegen-kotlin/pom.xml index 5a5fe50e6a..723a221397 100644 --- a/server/java/codegen-kotlin/pom.xml +++ b/server/java/codegen-kotlin/pom.xml @@ -251,6 +251,7 @@ KotlinDecimalWireEmitter.kt KotlinExtractSchemaEmitter.kt KotlinOutputFormatSpecEmitter.kt + KotlinCrossFieldRules.kt