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/1.0-readiness.md b/docs/1.0-readiness.md index b54be142ed..08ba4ef1d8 100644 --- a/docs/1.0-readiness.md +++ b/docs/1.0-readiness.md @@ -155,29 +155,11 @@ Status legend: โœ… done ยท ๐Ÿ”ถ in progress ยท โฌœ not started ยท โ“ **[RATIFY] in all three non-TS ports; a Python runtime path dropped an inherited M:N) โ€” assume more lurk, and lead every cross-port fix with a shared fixture. - **Known instance, measured 2026-09-06 โ€” the four entity-scoped cross-field validators.** - `validator.comparison`, `requiredWhen`, `presentIff` and `atLeastOne` are registered - vocabulary in all five ports and are taught by the shipped `metaobjects-authoring` skill, - but their only enforcement is the database `CHECK` that TS-owned `migrate` emits (gated by - `migrate-ts/test/validator-check/cross-field.test.ts`, 7 tests). **No port's generated API - tier validates them:** Python's `entity_model.py` and C#'s `EntityGenerator.cs` dispatch - only on the four FIELD-scoped validators (`length`, `array`, `numeric`, `regex`), and the - shared `validation-conformance` corpus covers only those same four โ€” **zero fixtures across - every behavioral corpus touch the cross-field family.** They are also four of the 13 entries - in `registry-conformance/coverage-report.json`'s `untestedSubTypes`, which is the same list - the `.base` defect hid in until `0.25.0`. - - Consequence for an adopter today: a request body violating a declared cross-field rule is - refused by the DATABASE, not by the API, in every port โ€” a constraint violation rather than - a 400. - - **This does not block the cut**, and the reason is worth stating so it is not re-litigated: - the vocabulary is enforced (the CHECK reaches every port, because schema is TS-owned per - ADR-0015), and adding API-tier enforcement later changes no load verdict and no metadata โ€” - it is additive, and reaches an adopter as a wrong request failing earlier and more clearly. - What must NOT happen is adding a cross-port `validation-conformance` fixture for these - before the ports implement the tier; that turns three lanes red to record a gap this bullet - already records. + **Known instance (measured 2026-09-06, resolved Unreleased).** The four entity-scoped cross-field + validators (`comparison`, `requiredWhen`, `presentIff`, `atLeastOne`) were enforced only by the DB + `CHECK`. The generated record model now enforces them in every port; see + [features/entities.md](features/entities.md#cross-field-validators) and the CHANGELOG `[Unreleased]` entry. + They remain among the `untestedSubTypes` in `registry-conformance/coverage-report.json`, which tracks the registry, not the generated behavior. ## F. Adoption proof (the audit as readiness signal) diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index d7e9157567..a8650cce54 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 + 20 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/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 ea4945feae..661966d8ea 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,135 @@ 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 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; +} + +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); + }; + // 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[] = []; + // 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 || left.resolvedIsArray() || right.resolvedIsArray()) 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 || when.resolvedIsArray()) 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 +455,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 +668,25 @@ 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 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`export const ${objName}InsertBaseSchema = ${z}.object({ +${joinCode(insertFieldLines, { on: ",\n" })} +}); + +${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};`; + // #203 โ€” emit the preserving insert-shape only for @autoSet entities (nothing to // preserve otherwise). Backs `insertPreserving` (import/restore/replication). const preservingBlock = emitPreserving @@ -545,16 +697,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/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 ea4945feae..661966d8ea 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,135 @@ 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 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; +} + +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); + }; + // 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[] = []; + // 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 || left.resolvedIsArray() || right.resolvedIsArray()) 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 || when.resolvedIsArray()) 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 +455,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 +668,25 @@ 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 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`export const ${objName}InsertBaseSchema = ${z}.object({ +${joinCode(insertFieldLines, { on: ",\n" })} +}); + +${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};`; + // #203 โ€” emit the preserving insert-shape only for @autoSet entities (nothing to // preserve otherwise). Backs `insertPreserving` (import/restore/replication). const preservingBlock = emitPreserving @@ -545,16 +697,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..b6fc8ca33e 100644 --- a/fixtures/validation-conformance/README.md +++ b/fixtures/validation-conformance/README.md @@ -111,6 +111,30 @@ 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. +- A rule naming a field the create body never carries (`reviewedBy`, `@mutability: readOnly`; likewise a + derived field or a store-generated key) is skipped, so `review = DONE` without a reviewer is valid. + +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..57949d652f --- /dev/null +++ b/fixtures/validation-conformance/cross-field-cases.json @@ -0,0 +1,263 @@ +{ + "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 + }, + { + "name": "rule-on-read-only-field-skipped", + "entity": "Event", + "payload": { + "startsAt": 1, + "endsAt": 2, + "kind": "OTHER", + "state": "OPEN", + "email": "a@b.c", + "review": "DONE" + }, + "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..c2e4b65c98 --- /dev/null +++ b/fixtures/validation-conformance/meta.cross-field.json @@ -0,0 +1,33 @@ +{ + "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" } }, + { "field.string": { "name": "review" } }, + { "field.string": { "name": "reviewedBy", "@mutability": "readOnly" } }, + { "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"] } }, + { "validator.requiredWhen": { "name": "reviewNeedsReviewer", "@field": "reviewedBy", "@when": "review", "@equals": "DONE" } } + ] + }} + ] + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs new file mode 100644 index 0000000000..72f15b02a6 --- /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(20, 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..1bf6703669 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidatorGenerationTests.cs @@ -0,0 +1,232 @@ +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: each +/// compiles the generated classes with Roslyn and runs . +/// Oracle: the validators' declared meaning (FR-036's CHECK semantics), e.g. lo lt hi +/// rejects 2 >= 1. The shared corpus gates the full verdict table; these cover inheritance and +/// the skipped-validator cases the corpus does not. +/// +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" } } + ]}} + ]}} + """; + + // The parent's only cross-field validator compares an int with a string, which no port emits, + // so the parent class has nothing to evaluate; the child still declares a rule of its own. + private const string SkippedParent = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Parent", "children": [ + { "source.rdb": { "@table": "parents" } }, + { "field.long": { "name": "id" } }, + { "field.int": { "name": "a" } }, + { "field.string": { "name": "b" } }, + { "identity.primary": { "@fields": "id" } }, + { "validator.comparison": { "name": "ab", "@left": "a", "@op": "lt", "@right": "b" } } + ]}}, + { "object.entity": { "name": "Child", "extends": "Parent", "children": [ + { "source.rdb": { "@table": "children" } }, + { "field.string": { "name": "kind" } }, + { "field.string": { "name": "note" } }, + { "validator.requiredWhen": { "name": "kn", "@field": "note", "@when": "kind", "@equals": "X" } } + ]}} + ]}} + """; + + // TPH: the abstract intermediate level is not emitted (EmitAbstractShapes off), so its rule + // must still be evaluated by the concrete subtype that extends the emitted base. + private const string TphAbstractMid = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Animal", "@discriminator": "type", "children": [ + { "source.rdb": { "@table": "animals" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "type" } }, + { "field.int": { "name": "lo" } }, + { "field.int": { "name": "hi" } }, + { "identity.primary": { "@fields": "id" } }, + { "validator.comparison": { "name": "lohi", "@left": "lo", "@op": "lte", "@right": "hi" } } + ]}}, + { "object.entity": { "name": "Mammal", "extends": "Animal", "abstract": true, "children": [ + { "field.string": { "name": "kind" } }, + { "field.string": { "name": "fur" } }, + { "validator.requiredWhen": { "name": "furry", "@field": "fur", "@when": "kind", "@equals": "FURRY" } } + ]}}, + { "object.entity": { "name": "Dog", "extends": "Mammal", "@discriminatorValue": "Dog", "children": [ + { "field.string": { "name": "bark" } } + ]}} + ]}} + """; + + // A decimal compared with a double: C# has no implicit conversion between the two. + private const string MixedNumeric = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Quote", "children": [ + { "source.rdb": { "@table": "quotes" } }, + { "field.long": { "name": "id" } }, + { "field.decimal": { "name": "price", "@precision": 10, "@scale": 2 } }, + { "field.double": { "name": "cap" } }, + { "identity.primary": { "@fields": "id" } }, + { "validator.comparison": { "name": "underCap", "@left": "price", "@op": "lte", "@right": "cap" } } + ]}} + ]}} + """; + + 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 Assembly Compile(EntityGenerator generator, string model) + { + var trees = generator.Generate(Ctx(model)).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))); + return Assembly.Load(ms.ToArray()); + } + + private static List Validate(Assembly asm, string type, string json) + { + object inst = JsonSerializer.Deserialize(json, asm.GetType($"Acme.{type}")!, + new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + var results = new List(); + Validator.TryValidateObject(inst, new ValidationContext(inst), results, true); + return results; + } + + [Fact] + public void Each_resolvable_rule_is_enforced_and_a_rule_on_a_missing_field_is_skipped() + { + var asm = Compile(new EntityGenerator(), Model); + Assert.Empty(Validate(asm, "Event", "{\"lo\":1,\"hi\":2,\"email\":\"e\"}")); + Assert.Single(Validate(asm, "Event", "{\"lo\":2,\"hi\":1,\"email\":\"e\"}")); + Assert.Single(Validate(asm, "Event", "{\"lo\":1,\"hi\":2}")); + } + + [Fact] + public void A_decimal_compared_with_a_double_compiles_and_is_enforced() + { + var asm = Compile(new EntityGenerator(), MixedNumeric); + Assert.Single(Validate(asm, "Quote", "{\"price\":5.5,\"cap\":3}")); + Assert.Empty(Validate(asm, "Quote", "{\"price\":2.5,\"cap\":3}")); + } + + [Fact] + public void Entity_without_cross_field_validators_is_not_validatable() + { + var asm = Compile(new EntityGenerator(), Plain); + Assert.False(typeof(IValidatableObject).IsAssignableFrom(asm.GetType("Acme.Widget")!)); + } + + [Fact] + public void Derived_entity_runs_the_inherited_rule_and_its_own() + { + var asm = Compile(new BasedEntityGenerator(), Inherited); + Assert.Equal(2, Validate(asm, "Child", "{\"a\":3,\"b\":1,\"c\":1,\"d\":0}").Count); + } + + [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 is skipped. + var asm = Compile(new EntityGenerator(), Inherited); + Assert.Single(Validate(asm, "Child", "{\"a\":3,\"b\":1,\"c\":1,\"d\":0}")); + } + + [Fact] + public void Derived_entity_compiles_when_every_base_rule_is_skipped() + { + var asm = Compile(new BasedEntityGenerator(), SkippedParent); + Assert.Single(Validate(asm, "Child", "{\"kind\":\"X\"}")); + Assert.Empty(Validate(asm, "Child", "{\"kind\":\"X\",\"note\":\"n\"}")); + } + + [Fact] + public void Tph_subtype_runs_the_rule_of_an_unemitted_abstract_level() + { + var asm = Compile(new EntityGenerator(), TphAbstractMid); + Assert.Single(Validate(asm, "Dog", "{\"lo\":1,\"hi\":2,\"kind\":\"FURRY\"}")); + Assert.Single(Validate(asm, "Dog", "{\"lo\":3,\"hi\":2,\"kind\":\"FURRY\",\"fur\":\"f\"}")); + Assert.Empty(Validate(asm, "Dog", "{\"lo\":1,\"hi\":2,\"kind\":\"FURRY\",\"fur\":\"f\"}")); + } + + // 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..64c88f88d9 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 validatingBase = declaresBase && !InstanceArtifacts.IsAbstract(mappedBase!) + && HasEffectiveCrossField(mappedBase!) ? mappedBase : null; + var absent = mappedBase is not null && !declaresBase + ? new HashSet(mappedBase.Fields().Select(f => f.Name), StringComparer.Ordinal) + : null; + var validateMethod = isProjection ? null : CrossFieldValidateMethod(entity, validatingBase, absent); + if (validateMethod is not null && validatingBase is null) 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); + var tphAncestor = EmittedTphAncestor(entity, ctx); + var validatingBase = HasEffectiveCrossField(tphAncestor) ? tphAncestor : null; + var validateMethod = CrossFieldValidateMethod(entity, validatingBase); + if (validateMethod is not null && validatingBase is null) 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,235 @@ 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: its effective set, minus the ones the + // emitted base class's Validate already evaluates. Subtracting the base's effective set (not + // reading own validators) keeps a validator declared on an un-emitted intermediate level. + private static List CrossFieldValidatorsFor(MetaObject entity, MetaObject? validatingBase) + { + var inherited = validatingBase is null + ? new HashSet(ReferenceEqualityComparer.Instance) + : new HashSet(validatingBase.Validators(), ReferenceEqualityComparer.Instance); + return entity.Validators().Where(v => IsCrossFieldValidator(v) && !inherited.Contains(v)).ToList(); + } + + // A class whose effective validators include a cross-field one always declares or inherits + // Validate (CrossFieldValidateMethod emits it even when every rule is skipped), so a subclass + // may override it on exactly this test. + 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, MetaObject? validatingBase, ISet? absentProps = null) + { + var validators = CrossFieldValidatorsFor(entity, validatingBase); + if (validators.Count == 0) return null; + var baseHasValidate = validatingBase is not null; + var generatedKey = entity.PrimaryIdentity() is MetaPrimaryIdentity + { + Generation: MetaObjects.Core.Identity.IdentityConstants.GENERATION_INCREMENT + or MetaObjects.Core.Identity.IdentityConstants.GENERATION_UUID, + } pk + ? new HashSet(pk.Fields, StringComparer.Ordinal) + : new HashSet(StringComparer.Ordinal); + + 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 (f.IsReadOnlyMutability || f.IsDerived() || generatedKey.Contains(f.Name)) 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) + { + var ls = CSharpNaming.ScalarForField(l); var rs = CSharpNaming.ScalarForField(r); + var la = ls != "decimal" && rs == "decimal" ? $"(decimal){a}" : a; + var rb = rs != "decimal" && ls == "decimal" ? $"(decimal){b}" : b; + cond = $"{la} {csOp} {rb}"; + } + 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) + { + if (baseHasValidate) return null; + body.AppendLine(" yield break;"); + } + + 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/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