Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,46 @@ here.**
column, instead of suggesting a `@column` they already have. The error code is unchanged. New
conformance fixtures: `column-name-shared-in-sql-view-ok`, `error-column-name-collision-derived-view` and
`error-column-name-collision-table-with-sql-view`.
- **C# codegen: an entity that extends another no longer loses the parent's properties.** The member
loop skipped every inherited field but the class declaration never named the base, so the fields
appeared nowhere. A child of a **concrete** parent now declares the parent's properties itself and
names no base: each concrete entity owns a table with all its columns (what `meta migrate` creates
and the other ports' models carry), and `class Child : Parent` between two mapped entities is an EF
inheritance hierarchy (one shared table, or a child table holding only its own columns). A child of
an **abstract** parent with `EmitAbstractShapes` on now emits `class Child : Base`, as the
generator always meant to, and declares only its own members. Regenerating changes those two shapes
of C# output only. An adopter override of `EmitClassDeclarationLine` that names a concrete parent as
the base now gets that parent's properties twice; drop the base from the override.
- **Test flakes fixed at their cause.** `GenCommandTests.Gen_writes_the_hash_manifest_beside_the_metadata_not_in_cwd`
(C# Cli) failed about one run in five because `DependencyCliTests` runs `gen` in process, which
changes the process-wide working directory, without joining the serial collection every other
such class is in; the two classes ran in parallel and one read the other's directory as the cwd.
The codegen-ts tests that type-check generated output with the real TypeScript compiler (28 files,
the slowest case measured at 3.3s against bun's 5s default) now run under a 30s timeout whose value
lives in one place, `test/compile-timeout.ts`. Each of those files calls `setDefaultTimeout` with
it, because bun applies that call only to the file that makes it, so the timeout holds in a plain
`bun test` as well as in CI, and a loaded machine no longer reports a timeout for correct code.
- **A cross-field `validator.comparison` between fields of different kinds is skipped in every
port, never rejected and never a 500.** Comparing a `field.date` with a `field.timestamp` (or any
two strings or temporals of different subtype) has no ordering. Java and Kotlin cast both sides
to `Comparable` inside the class-level validity check, so the cast threw `ClassCastException` and
a create answered 500 instead of 400; TypeScript compared the two as strings and Python raised a
`TypeError` that surfaced as an invalid body. All five ports now follow one rule: numerics compare
with each other, strings and temporals only with their own subtype and `@localTime` flag (a
`field.timestamp` is an instant and a `@localTime` one a wall clock, which is a different native
type in every port), boolean and enum fields test
`eq` / `ne` only (also on two `uuid`, `uri` or `inet` fields), and any other pair emits no rule, so an
ordering test on two booleans or two enums is no longer enforced by the generated validators (C# never
did). The database `CHECK` still covers the pair where the database can compare it. New shared cases
`comparison-across-kinds-is-skipped`, `comparison-instant-vs-local-timestamp-is-skipped`,
`uuid-ne-rejects-equal-ids` and `uuid-ne-accepts-different-ids`
in `fixtures/validation-conformance/`, run by every port.
- **Test coverage for cross-field validators.** A subtype form on a TPH hierarchy that declares a
cross-field rule now has a test proving the generated form binds the refined body schema (the rule
itself was enforced by the cross-field build). The Java check that a Patch is not held to cross-field
validators now runs the generated classes (the violating body is rejected by the Dto and accepted
by the Patch) instead of comparing generated source text.

- **`meta migrate` refuses, at plan time, to drop a column a view it does not own still reads.**
With `--from-db`, a declared-`@unmanaged` view or one outside the scoped schemas whose live body
names the dropped column now blocks the `drop-column`, and the message names the view and the
Expand Down
2 changes: 1 addition & 1 deletion agent-context/skills/metaobjects-authoring/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ store with no `CHECK` support is still covered. PATCH and partial-update models

| Subtype | Rule | Key attrs |
|---|---|---|
| `validator.comparison` | two fields stand in a relational order (`@left @op @right`) | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` |
| `validator.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings and temporals only with the same subtype and `@localTime`; boolean, enum, uuid, uri and inet only `eq`/`ne`. Any other pair is skipped, never rejected. | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` |
| `validator.requiredWhen` | `@field` is required when `@when` equals `@equals` | `@field`, `@when`, `@equals` |
| `validator.presentIff` | `@field` is present **iff** `@when` equals `@equals` (biconditional) | `@field`, `@when`, `@equals` |
| `validator.atLeastOne` | at least one of `@fields` (2+) is present | `@fields` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -307,7 +307,9 @@ plus finer hooks — `EmitClassHeader` / `EmitClassDeclarationLine` (class decla
`partial`, marker interfaces), `EmitPropertyAttributes` (per-property C# attributes),
`EmitFileUsings` (extra usings), `EmitClassBodyTrailer` (extra members). Subclass a
generator and override only the seam you need rather than forking. `@default` on a
scalar emits a literal initializer; an `object.value`'s default storage is jsonb.
scalar emits a literal initializer; an override of `EmitClassDeclarationLine` must not name a
concrete parent as the C# base (the child already declares the parent's properties, so a base would
redeclare them; only an emitted abstract shape is a base); an `object.value`'s default storage is jsonb.

**Shared + externally-provided enums (FR-019).** A package-level abstract
`field.enum` (`abstract: true`, `@values`) extended by concrete entity fields is
Expand Down
7 changes: 7 additions & 0 deletions docs/features/entities.md
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,13 @@ side is null; `requiredWhen` is violated only when the gate equals `@equals` and
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.

One difference: a `comparison` orders only fields of the same kind. Numerics compare with each other;
strings and temporals compare only with their own subtype and `@localTime` flag. `eq` and `ne` also
apply to two booleans, enums, `uuid`, `uri` or `inet` fields, and ordering on a boolean or enum is not
enforced. Any other pair is skipped in the generated model, never rejected, but the DB `CHECK` still
covers it where the database can compare the two columns. The full rule is in
`fixtures/validation-conformance/README.md`.

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.

Expand Down
28 changes: 26 additions & 2 deletions examples/advanced-modeling/codegen/templates/zod-validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ import {
FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_CURRENCY,
FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_DECIMAL, FIELD_ATTR_PRECISION, FIELD_ATTR_SCALE, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT,
FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP,
FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_MAP, FIELD_SUBTYPE_UUID,
FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET,
FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_MAP, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET,
VALIDATOR_SUBTYPE_REQUIRED, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX,
VALIDATOR_SUBTYPE_NUMERIC, VALIDATOR_SUBTYPE_ARRAY,
IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION,
Expand Down Expand Up @@ -278,6 +277,30 @@ const NUMERIC_SUBTYPES: ReadonlySet<string> = new Set([
FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_CURRENCY,
]);

const ORDERED_SUBTYPES: ReadonlySet<string> = new Set([
FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP,
]);

/** Subtypes with equality but no ordering: a pair of the same one may be tested with eq / ne. */
const EQUALITY_SUBTYPES: ReadonlySet<string> = new Set([
FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET,
]);

/** Whether a comparison between these two fields has a sound meaning, as in every port:
* numerics compare with each other, strings and temporals only with their own subtype and
* @localTime flag (a date against a timestamp, or an instant against a wall clock, compares
* two differently shaped strings, which is meaningless),
* and boolean, enum, uuid, uri and inet fields only test equality. Anything else is skipped, never rejected. */
function crossComparable(left: MetaField, right: MetaField, opName: string): boolean {
const a = left.subType;
const b = right.subType;
if (NUMERIC_SUBTYPES.has(a) && NUMERIC_SUBTYPES.has(b)) return true;
if (a !== b) return false;
if ((left.attr(FIELD_ATTR_LOCAL_TIME) === true) !== (right.attr(FIELD_ATTR_LOCAL_TIME) === true)) return false;
if (ORDERED_SUBTYPES.has(a)) return true;
return (opName === "eq" || opName === "ne") && EQUALITY_SUBTYPES.has(a);
}

/** Names of the fields the INSERT-shaped schemas carry (same skip rules as the emitters). */
function insertSchemaFieldNameSet(obj: MetaObject): Set<string> {
const autoGen = autoGenPkFieldNames(obj);
Expand Down Expand Up @@ -337,6 +360,7 @@ function crossFieldRefinementBody(obj: MetaObject): string[] {
const opName = String(v.attr(VALIDATOR_ATTR_OP));
const op = CROSS_FIELD_OPS[opName];
if (!left || !right || !op || left.resolvedIsArray() || right.resolvedIsArray()) break;
if (!crossComparable(left, right, opName)) break;
const numeric = NUMERIC_SUBTYPES.has(left.subType);
const c = numeric
? "Number(l) - Number(r)"
Expand Down
28 changes: 26 additions & 2 deletions examples/showcase/codegen/templates/zod-validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ import {
FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_CURRENCY,
FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_DECIMAL, FIELD_ATTR_PRECISION, FIELD_ATTR_SCALE, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT,
FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP,
FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_MAP, FIELD_SUBTYPE_UUID,
FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET,
FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_MAP, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET,
VALIDATOR_SUBTYPE_REQUIRED, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX,
VALIDATOR_SUBTYPE_NUMERIC, VALIDATOR_SUBTYPE_ARRAY,
IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION,
Expand Down Expand Up @@ -278,6 +277,30 @@ const NUMERIC_SUBTYPES: ReadonlySet<string> = new Set([
FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_CURRENCY,
]);

const ORDERED_SUBTYPES: ReadonlySet<string> = new Set([
FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP,
]);

/** Subtypes with equality but no ordering: a pair of the same one may be tested with eq / ne. */
const EQUALITY_SUBTYPES: ReadonlySet<string> = new Set([
FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET,
]);

/** Whether a comparison between these two fields has a sound meaning, as in every port:
* numerics compare with each other, strings and temporals only with their own subtype and
* @localTime flag (a date against a timestamp, or an instant against a wall clock, compares
* two differently shaped strings, which is meaningless),
* and boolean, enum, uuid, uri and inet fields only test equality. Anything else is skipped, never rejected. */
function crossComparable(left: MetaField, right: MetaField, opName: string): boolean {
const a = left.subType;
const b = right.subType;
if (NUMERIC_SUBTYPES.has(a) && NUMERIC_SUBTYPES.has(b)) return true;
if (a !== b) return false;
if ((left.attr(FIELD_ATTR_LOCAL_TIME) === true) !== (right.attr(FIELD_ATTR_LOCAL_TIME) === true)) return false;
if (ORDERED_SUBTYPES.has(a)) return true;
return (opName === "eq" || opName === "ne") && EQUALITY_SUBTYPES.has(a);
}

/** Names of the fields the INSERT-shaped schemas carry (same skip rules as the emitters). */
function insertSchemaFieldNameSet(obj: MetaObject): Set<string> {
const autoGen = autoGenPkFieldNames(obj);
Expand Down Expand Up @@ -337,6 +360,7 @@ function crossFieldRefinementBody(obj: MetaObject): string[] {
const opName = String(v.attr(VALIDATOR_ATTR_OP));
const op = CROSS_FIELD_OPS[opName];
if (!left || !right || !op || left.resolvedIsArray() || right.resolvedIsArray()) break;
if (!crossComparable(left, right, opName)) break;
const numeric = NUMERIC_SUBTYPES.has(left.subType);
const c = numeric
? "Number(l) - Number(r)"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ store with no `CHECK` support is still covered. PATCH and partial-update models

| Subtype | Rule | Key attrs |
|---|---|---|
| `validator.comparison` | two fields stand in a relational order (`@left @op @right`) | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` |
| `validator.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings and temporals only with the same subtype and `@localTime`; boolean, enum, uuid, uri and inet only `eq`/`ne`. Any other pair is skipped, never rejected. | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` |
| `validator.requiredWhen` | `@field` is required when `@when` equals `@equals` | `@field`, `@when`, `@equals` |
| `validator.presentIff` | `@field` is present **iff** `@when` equals `@equals` (biconditional) | `@field`, `@when`, `@equals` |
| `validator.atLeastOne` | at least one of `@fields` (2+) is present | `@fields` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ store with no `CHECK` support is still covered. PATCH and partial-update models

| Subtype | Rule | Key attrs |
|---|---|---|
| `validator.comparison` | two fields stand in a relational order (`@left @op @right`) | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` |
| `validator.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings and temporals only with the same subtype and `@localTime`; boolean, enum, uuid, uri and inet only `eq`/`ne`. Any other pair is skipped, never rejected. | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` |
| `validator.requiredWhen` | `@field` is required when `@when` equals `@equals` | `@field`, `@when`, `@equals` |
| `validator.presentIff` | `@field` is present **iff** `@when` equals `@equals` (biconditional) | `@field`, `@when`, `@equals` |
| `validator.atLeastOne` | at least one of `@fields` (2+) is present | `@fields` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ store with no `CHECK` support is still covered. PATCH and partial-update models

| Subtype | Rule | Key attrs |
|---|---|---|
| `validator.comparison` | two fields stand in a relational order (`@left @op @right`) | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` |
| `validator.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings and temporals only with the same subtype and `@localTime`; boolean, enum, uuid, uri and inet only `eq`/`ne`. Any other pair is skipped, never rejected. | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` |
| `validator.requiredWhen` | `@field` is required when `@when` equals `@equals` | `@field`, `@when`, `@equals` |
| `validator.presentIff` | `@field` is present **iff** `@when` equals `@equals` (biconditional) | `@field`, `@when`, `@equals` |
| `validator.atLeastOne` | at least one of `@fields` (2+) is present | `@fields` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ store with no `CHECK` support is still covered. PATCH and partial-update models

| Subtype | Rule | Key attrs |
|---|---|---|
| `validator.comparison` | two fields stand in a relational order (`@left @op @right`) | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` |
| `validator.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings and temporals only with the same subtype and `@localTime`; boolean, enum, uuid, uri and inet only `eq`/`ne`. Any other pair is skipped, never rejected. | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` |
| `validator.requiredWhen` | `@field` is required when `@when` equals `@equals` | `@field`, `@when`, `@equals` |
| `validator.presentIff` | `@field` is present **iff** `@when` equals `@equals` (biconditional) | `@field`, `@when`, `@equals` |
| `validator.atLeastOne` | at least one of `@fields` (2+) is present | `@fields` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -434,7 +434,7 @@ store with no `CHECK` support is still covered. PATCH and partial-update models

| Subtype | Rule | Key attrs |
|---|---|---|
| `validator.comparison` | two fields stand in a relational order (`@left @op @right`) | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` |
| `validator.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings and temporals only with the same subtype and `@localTime`; boolean, enum, uuid, uri and inet only `eq`/`ne`. Any other pair is skipped, never rejected. | `@left`, `@op` (`gt`/`gte`/`lt`/`lte`/`ne`/`eq`), `@right` |
| `validator.requiredWhen` | `@field` is required when `@when` equals `@equals` | `@field`, `@when`, `@equals` |
| `validator.presentIff` | `@field` is present **iff** `@when` equals `@equals` (biconditional) | `@field`, `@when`, `@equals` |
| `validator.atLeastOne` | at least one of `@fields` (2+) is present | `@fields` |
Expand Down
14 changes: 11 additions & 3 deletions fixtures/validation-conformance/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,17 @@ so the run-time runners, which read `cases.json`, are unaffected. The entity is
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.
or null value standing for SQL `NULL` (and `""` NOT null). One deliberate difference: a comparison the
ports skip (two fields of different kinds) still gets its `CHECK`, which the database enforces if it
can compare the two column types:

- `comparison` (`startsAt lte endsAt`): passes when either side is null. Fields of different kinds
have no ordering and the rule is SKIPPED in every port, never an error (`eventDate lte loggedAt`, a
date against a timestamp, is declared on `Event` and must accept a body where the date is later). Two temporals of
the same subtype are still different kinds when only one carries `@localTime` (`loggedAt lte wallClock`, an
instant against a wall clock, is skipped the same way). Ordering applies to numerics with each other and to
strings and temporals of the same subtype; `eq` / `ne` also apply to two fields of the same boolean,
enum, uuid, uri or inet subtype (`ownerId ne approverId` rejects two equal ids).
- `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
Expand Down
Loading
Loading