From 52e0779c4abb4ce28de19e2500766a16cf8370e7 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Fri, 19 Jun 2026 14:31:27 -0400 Subject: [PATCH] feat(validator): semantic cross-field validators (comparison/requiredWhen/presentIff/atLeastOne) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds four entity-scoped validator subtypes that capture cross-field constraints by intent and reference sibling fields by name — instead of storing a raw, dialect-locked SQL expression. Backends derive the rule (SQL CHECK, cross-field assertion); no raw expression lives in the metadata. - validator.comparison (@left @op @right) — two-field relational order - validator.requiredWhen (@field @when @equals) — one-directional presence - validator.presentIff (@field @when @equals) — biconditional presence - validator.atLeastOne (@fields) — presence cardinality, reuses the @fields-by-name pattern from identity.* These follow the house style of many narrow, intent-named subtypes (ADR-0011 / ADR-0002) rather than one broad subtype with a discriminator, and mirror declarative precedents (JSON Schema dependentRequired; ORM/NIAM ring constraints derived to DDL). Wired across all ports + spec: - spec/metamodel/validator.json (+ embedded TS def, Python/C# embedded copies) - TS constants + migrate-ts derivation (entity.validators(), by-name column resolution, type-aware @equals literals, dialect-portable operators) - Java validator classes + provider; C# constants + schema; Python core_types - Conformance fixtures regenerated (registry manifest, metamodel docs, coverage) Registry-conformance green on all ports (TS canonical, Python, C# 18, Java 3); new TS round-trip tests cover each derivation + the missing-field skip. Co-Authored-By: Claude Opus 4.8 (1M context) --- fixtures/metamodel-docs/expected/INDEX.md | 4 + fixtures/metamodel-docs/expected/providers.md | 6 +- .../expected/types/validator.md | 78 +++++++++++ .../registry-conformance/coverage-report.json | 6 +- .../expected-registry.json | 106 +++++++++++++++ .../Core/Validator/ValidatorConstants.cs | 17 +++ .../Core/Validator/ValidatorSchema.cs | 34 +++++ .../MetaObjects/SpecMetamodel/validator.json | 42 ++++++ .../validator/AtLeastOneValidator.java | 55 ++++++++ .../validator/ComparisonValidator.java | 63 +++++++++ .../validator/PresentIffValidator.java | 63 +++++++++ .../validator/RequiredWhenValidator.java | 62 +++++++++ .../ValidatorTypesMetaDataProvider.java | 5 + server/python/src/metaobjects/core_types.py | 20 +++ .../core/validator/validator_constants.py | 13 ++ .../metaobjects/spec_metamodel/validator.json | 42 ++++++ .../src/core/validator/validator-constants.ts | 23 +++- .../validator-definition.embedded.ts | 121 ++++++++++++++++++ .../validator-definition-completeness.test.ts | 21 ++- .../migrate-ts/src/expected-schema.ts | 100 +++++++++++++++ .../test/validator-check/cross-field.test.ts | 104 +++++++++++++++ spec/metamodel/validator.json | 42 ++++++ 22 files changed, 1023 insertions(+), 4 deletions(-) create mode 100644 server/java/metadata/src/main/java/com/metaobjects/validator/AtLeastOneValidator.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/validator/ComparisonValidator.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/validator/PresentIffValidator.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/validator/RequiredWhenValidator.java create mode 100644 server/typescript/packages/migrate-ts/test/validator-check/cross-field.test.ts diff --git a/fixtures/metamodel-docs/expected/INDEX.md b/fixtures/metamodel-docs/expected/INDEX.md index a61562f86..8b07eeeb3 100644 --- a/fixtures/metamodel-docs/expected/INDEX.md +++ b/fixtures/metamodel-docs/expected/INDEX.md @@ -64,10 +64,14 @@ children, and cardinality of a subtype. Universal documentation attributes | `template.prompt` | An LLM-targeted renderable prompt template (FR-004). Carries the generic reference + governance attrs plus the LLM overlay (@maxTokens / @requiredSlots / @model / @responseRef). Its renderable body is required via @textRef. | [types/template.md#templateprompt](types/template.md#templateprompt) | | `template.toolcall` | A vendor-agnostic LLM tool-call envelope (ADR-0011). Unlike prompt/output it has NO renderable text body — the body IS the structured output schema resolved via @payloadRef. This is why toolcall is its own subtype rather than template.output + @toolName. Does NOT inherit the generic attrs. | [types/template.md#templatetoolcall](types/template.md#templatetoolcall) | | `validator.array` | Bounds the element count of an array-valued field via @min/@max. | [types/validator.md#validatorarray](types/validator.md#validatorarray) | +| `validator.atLeastOne` | Cardinality of presence: at least one of the named fields (@fields) must be present (NOT NULL). Entity-scoped; references fields by name (same @fields-by-name pattern as identity.*). | [types/validator.md#validatoratleastone](types/validator.md#validatoratleastone) | | `validator.base` | Abstract base validator — the shared root subtype concrete validators specialize. Carries the @min/@max bounds attrs but enforces no rule of its own. | [types/validator.md#validatorbase](types/validator.md#validatorbase) | +| `validator.comparison` | Cross-field ordering: requires two sibling fields of the owning entity stand in a relational order (@left @op @right), e.g. current_hp <= max_hp or expires_at > created_at. Entity-scoped; references fields by name. Backends derive the rule (CHECK constraint, cross-field assertion) — no raw expression is stored. | [types/validator.md#validatorcomparison](types/validator.md#validatorcomparison) | | `validator.length` | Bounds string length / collection size via @min/@max. | [types/validator.md#validatorlength](types/validator.md#validatorlength) | | `validator.numeric` | Bounds a numeric value's magnitude via @min/@max. | [types/validator.md#validatornumeric](types/validator.md#validatornumeric) | +| `validator.presentIff` | Biconditional presence: the target field (@field) is present (NOT NULL) if and only if the gating field (@when) equals @equals. Models paired flag/companion-column invariants, e.g. used_at present iff is_used=true. Entity-scoped; references fields by name. | [types/validator.md#validatorpresentiff](types/validator.md#validatorpresentiff) | | `validator.regex` | Requires the value match a regular expression (@pattern). | [types/validator.md#validatorregex](types/validator.md#validatorregex) | | `validator.required` | Fails when the value is null/empty (NOT NULL). Equivalent to @required on the owning field. | [types/validator.md#validatorrequired](types/validator.md#validatorrequired) | +| `validator.requiredWhen` | One-directional conditional presence: when the gating field (@when) equals @equals, the target field (@field) must be present (NOT NULL); otherwise @field is unconstrained. Mirrors JSON Schema dependentRequired / Rails validates_presence_of :x, if:. Entity-scoped; references fields by name. | [types/validator.md#validatorrequiredwhen](types/validator.md#validatorrequiredwhen) | | `view.base` | Abstract view base — the shared root subtype for field-level UI/render hints. A view declares how a field's value is rendered or edited; the base carries no attrs of its own. | [types/view.md#viewbase](types/view.md#viewbase) | | `view.currency` | Currency display formatting (locale-aware via @locale). | [types/view.md#viewcurrency](types/view.md#viewcurrency) | diff --git a/fixtures/metamodel-docs/expected/providers.md b/fixtures/metamodel-docs/expected/providers.md index c5bf1c5e0..e906e28f5 100644 --- a/fixtures/metamodel-docs/expected/providers.md +++ b/fixtures/metamodel-docs/expected/providers.md @@ -13,7 +13,7 @@ provider owns. This is the ownership lens over the same vocabulary Core metaobjects metamodel types and subtypes. -**Owns (registers):** `attr.base`, `attr.boolean`, `attr.class`, `attr.double`, `attr.filter`, `attr.int`, `attr.long`, `attr.properties`, `attr.string`, `field.base`, `field.boolean`, `field.currency`, `field.date`, `field.decimal`, `field.double`, `field.enum`, `field.float`, `field.int`, `field.long`, `field.object`, `field.string`, `field.time`, `field.timestamp`, `field.uuid`, `identity.primary`, `identity.reference`, `identity.secondary`, `layout.base`, `layout.dataGrid`, `object.base`, `object.entity`, `object.projection`, `object.value`, `origin.aggregate`, `origin.base`, `origin.collection`, `origin.passthrough`, `relationship.aggregation`, `relationship.association`, `relationship.base`, `relationship.composition`, `source.base`, `source.rdb`, `template.base`, `template.output`, `template.prompt`, `template.toolcall`, `validator.array`, `validator.base`, `validator.length`, `validator.numeric`, `validator.regex`, `validator.required`, `view.base`, `view.currency` +**Owns (registers):** `attr.base`, `attr.boolean`, `attr.class`, `attr.double`, `attr.filter`, `attr.int`, `attr.long`, `attr.properties`, `attr.string`, `field.base`, `field.boolean`, `field.currency`, `field.date`, `field.decimal`, `field.double`, `field.enum`, `field.float`, `field.int`, `field.long`, `field.object`, `field.string`, `field.time`, `field.timestamp`, `field.uuid`, `identity.primary`, `identity.reference`, `identity.secondary`, `layout.base`, `layout.dataGrid`, `object.base`, `object.entity`, `object.projection`, `object.value`, `origin.aggregate`, `origin.base`, `origin.collection`, `origin.passthrough`, `relationship.aggregation`, `relationship.association`, `relationship.base`, `relationship.composition`, `source.base`, `source.rdb`, `template.base`, `template.output`, `template.prompt`, `template.toolcall`, `validator.array`, `validator.atLeastOne`, `validator.base`, `validator.comparison`, `validator.length`, `validator.numeric`, `validator.presentIff`, `validator.regex`, `validator.required`, `validator.requiredWhen`, `view.base`, `view.currency` **Contributes attributes:** @@ -35,10 +35,14 @@ Core metaobjects metamodel types and subtypes. - `relationship.base`: `@cardinality`, `@objectRef`, `@onDelete`, `@onUpdate`, `@sourceRefField`, `@symmetric`, `@through` - `relationship.composition`: `@cardinality`, `@objectRef`, `@onDelete`, `@onUpdate`, `@sourceRefField`, `@symmetric`, `@through` - `validator.array`: `@max`, `@min` +- `validator.atLeastOne`: `@fields` - `validator.base`: `@max`, `@min` +- `validator.comparison`: `@left`, `@op`, `@right` - `validator.length`: `@max`, `@min` - `validator.numeric`: `@max`, `@min` +- `validator.presentIff`: `@equals`, `@field`, `@when` - `validator.regex`: `@max`, `@min`, `@pattern` +- `validator.requiredWhen`: `@equals`, `@field`, `@when` ## metaobjects-db diff --git a/fixtures/metamodel-docs/expected/types/validator.md b/fixtures/metamodel-docs/expected/types/validator.md index 31d02af06..a2f505333 100644 --- a/fixtures/metamodel-docs/expected/types/validator.md +++ b/fixtures/metamodel-docs/expected/types/validator.md @@ -27,6 +27,24 @@ Bounds the element count of an array-valued field via @min/@max. _No structural children._ +### validator.atLeastOne + +Cardinality of presence: at least one of the named fields (@fields) must be present (NOT NULL). Entity-scoped; references fields by name (same @fields-by-name pattern as identity.*). + +**Owning provider:** metaobjects-core-types + +**Rules:** @fields names two or more fields of the owning entity. Satisfied when any one of them is non-null. + +**Attributes** + +| Attribute | Type | Required | Default | Allowed values | Provider | Description | +| --- | --- | --- | --- | --- | --- | --- | +| `@fields` | string[] | yes | | | metaobjects-core-types | Names of the candidate fields; at least one must be present. | + +**Allowed children** + +_No structural children._ + ### validator.base Abstract base validator — the shared root subtype concrete validators specialize. Carries the @min/@max bounds attrs but enforces no rule of its own. @@ -44,6 +62,26 @@ Abstract base validator — the shared root subtype concrete validators speciali _No structural children._ +### validator.comparison + +Cross-field ordering: requires two sibling fields of the owning entity stand in a relational order (@left @op @right), e.g. current_hp <= max_hp or expires_at > created_at. Entity-scoped; references fields by name. Backends derive the rule (CHECK constraint, cross-field assertion) — no raw expression is stored. + +**Owning provider:** metaobjects-core-types + +**Rules:** @left and @right must name fields of the owning entity. @op is one of gt/gte/lt/lte/ne/eq. The comparison is null-tolerant where the backend's relational operator is (SQL: a NULL operand yields no violation). + +**Attributes** + +| Attribute | Type | Required | Default | Allowed values | Provider | Description | +| --- | --- | --- | --- | --- | --- | --- | +| `@left` | string | yes | | | metaobjects-core-types | Name of the left-hand field of the owning entity. | +| `@op` | string | yes | | `gt`, `gte`, `lt`, `lte`, `ne`, `eq` | metaobjects-core-types | Relational operator: gt (>), gte (>=), lt (<), lte (<=), ne (<>), eq (=). | +| `@right` | string | yes | | | metaobjects-core-types | Name of the right-hand field of the owning entity. | + +**Allowed children** + +_No structural children._ + ### validator.length Bounds string length / collection size via @min/@max. @@ -78,6 +116,26 @@ Bounds a numeric value's magnitude via @min/@max. _No structural children._ +### validator.presentIff + +Biconditional presence: the target field (@field) is present (NOT NULL) if and only if the gating field (@when) equals @equals. Models paired flag/companion-column invariants, e.g. used_at present iff is_used=true. Entity-scoped; references fields by name. + +**Owning provider:** metaobjects-core-types + +**Rules:** @field and @when must name fields of the owning entity. @equals is rendered per @when's field subtype. Stricter than requiredWhen — also forbids @field when the condition is false. + +**Attributes** + +| Attribute | Type | Required | Default | Allowed values | Provider | Description | +| --- | --- | --- | --- | --- | --- | --- | +| `@equals` | string | yes | | | metaobjects-core-types | The gating value; @field is present exactly when @when equals this. | +| `@field` | string | yes | | | metaobjects-core-types | Name of the field whose presence is governed by the condition. | +| `@when` | string | yes | | | metaobjects-core-types | Name of the gating field. | + +**Allowed children** + +_No structural children._ + ### validator.regex Requires the value match a regular expression (@pattern). @@ -110,3 +168,23 @@ _No subtype-specific attributes._ _No structural children._ +### validator.requiredWhen + +One-directional conditional presence: when the gating field (@when) equals @equals, the target field (@field) must be present (NOT NULL); otherwise @field is unconstrained. Mirrors JSON Schema dependentRequired / Rails validates_presence_of :x, if:. Entity-scoped; references fields by name. + +**Owning provider:** metaobjects-core-types + +**Rules:** @field and @when must name fields of the owning entity. @equals is the gating value, compared against @when's value (rendered per @when's field subtype — boolean true/false, enum/string literal, numeric literal). + +**Attributes** + +| Attribute | Type | Required | Default | Allowed values | Provider | Description | +| --- | --- | --- | --- | --- | --- | --- | +| `@equals` | string | yes | | | metaobjects-core-types | The gating value; when @when equals this, @field must be present. | +| `@field` | string | yes | | | metaobjects-core-types | Name of the field that becomes required when the condition holds. | +| `@when` | string | yes | | | metaobjects-core-types | Name of the gating field whose value triggers the requirement. | + +**Allowed children** + +_No structural children._ + diff --git a/fixtures/registry-conformance/coverage-report.json b/fixtures/registry-conformance/coverage-report.json index 6edceac83..5dbd9a712 100644 --- a/fixtures/registry-conformance/coverage-report.json +++ b/fixtures/registry-conformance/coverage-report.json @@ -1,5 +1,5 @@ { - "registeredSubTypeCount": 56, + "registeredSubTypeCount": 60, "exercisedSubTypeCount": 39, "untestedSubTypes": [ "attr.base", @@ -17,7 +17,11 @@ "relationship.base", "source.base", "template.base", + "validator.atLeastOne", "validator.base", + "validator.comparison", + "validator.presentIff", + "validator.requiredWhen", "view.base" ], "untestedAttrsByExercisedSubType": [ diff --git a/fixtures/registry-conformance/expected-registry.json b/fixtures/registry-conformance/expected-registry.json index cde334265..bacc23399 100644 --- a/fixtures/registry-conformance/expected-registry.json +++ b/fixtures/registry-conformance/expected-registry.json @@ -3016,6 +3016,22 @@ ], "children": [] }, + { + "type": "validator", + "subType": "atLeastOne", + "description": "Cardinality of presence: at least one of the named fields (@fields) must be present (NOT NULL). Entity-scoped; references fields by name (same @fields-by-name pattern as identity.*).", + "rules": "@fields names two or more fields of the owning entity. Satisfied when any one of them is non-null.", + "attrs": [ + { + "name": "fields", + "valueType": "string", + "isArray": true, + "required": true, + "description": "Names of the candidate fields; at least one must be present." + } + ], + "children": [] + }, { "type": "validator", "subType": "base", @@ -3038,6 +3054,36 @@ ], "children": [] }, + { + "type": "validator", + "subType": "comparison", + "description": "Cross-field ordering: requires two sibling fields of the owning entity stand in a relational order (@left @op @right), e.g. current_hp <= max_hp or expires_at > created_at. Entity-scoped; references fields by name. Backends derive the rule (CHECK constraint, cross-field assertion) — no raw expression is stored.", + "rules": "@left and @right must name fields of the owning entity. @op is one of gt/gte/lt/lte/ne/eq. The comparison is null-tolerant where the backend's relational operator is (SQL: a NULL operand yields no violation).", + "attrs": [ + { + "name": "left", + "valueType": "string", + "isArray": false, + "required": true, + "description": "Name of the left-hand field of the owning entity." + }, + { + "name": "op", + "valueType": "string", + "isArray": false, + "required": true, + "description": "Relational operator: gt (>), gte (>=), lt (<), lte (<=), ne (<>), eq (=)." + }, + { + "name": "right", + "valueType": "string", + "isArray": false, + "required": true, + "description": "Name of the right-hand field of the owning entity." + } + ], + "children": [] + }, { "type": "validator", "subType": "length", @@ -3082,6 +3128,36 @@ ], "children": [] }, + { + "type": "validator", + "subType": "presentIff", + "description": "Biconditional presence: the target field (@field) is present (NOT NULL) if and only if the gating field (@when) equals @equals. Models paired flag/companion-column invariants, e.g. used_at present iff is_used=true. Entity-scoped; references fields by name.", + "rules": "@field and @when must name fields of the owning entity. @equals is rendered per @when's field subtype. Stricter than requiredWhen — also forbids @field when the condition is false.", + "attrs": [ + { + "name": "equals", + "valueType": "string", + "isArray": false, + "required": true, + "description": "The gating value; @field is present exactly when @when equals this." + }, + { + "name": "field", + "valueType": "string", + "isArray": false, + "required": true, + "description": "Name of the field whose presence is governed by the condition." + }, + { + "name": "when", + "valueType": "string", + "isArray": false, + "required": true, + "description": "Name of the gating field." + } + ], + "children": [] + }, { "type": "validator", "subType": "regex", @@ -3118,6 +3194,36 @@ "attrs": [], "children": [] }, + { + "type": "validator", + "subType": "requiredWhen", + "description": "One-directional conditional presence: when the gating field (@when) equals @equals, the target field (@field) must be present (NOT NULL); otherwise @field is unconstrained. Mirrors JSON Schema dependentRequired / Rails validates_presence_of :x, if:. Entity-scoped; references fields by name.", + "rules": "@field and @when must name fields of the owning entity. @equals is the gating value, compared against @when's value (rendered per @when's field subtype — boolean true/false, enum/string literal, numeric literal).", + "attrs": [ + { + "name": "equals", + "valueType": "string", + "isArray": false, + "required": true, + "description": "The gating value; when @when equals this, @field must be present." + }, + { + "name": "field", + "valueType": "string", + "isArray": false, + "required": true, + "description": "Name of the field that becomes required when the condition holds." + }, + { + "name": "when", + "valueType": "string", + "isArray": false, + "required": true, + "description": "Name of the gating field whose value triggers the requirement." + } + ], + "children": [] + }, { "type": "view", "subType": "base", diff --git a/server/csharp/MetaObjects/Core/Validator/ValidatorConstants.cs b/server/csharp/MetaObjects/Core/Validator/ValidatorConstants.cs index d61a74a1f..5c1e4e889 100644 --- a/server/csharp/MetaObjects/Core/Validator/ValidatorConstants.cs +++ b/server/csharp/MetaObjects/Core/Validator/ValidatorConstants.cs @@ -17,6 +17,11 @@ public static class ValidatorConstants public const string VALIDATOR_SUBTYPE_REGEX = "regex"; public const string VALIDATOR_SUBTYPE_NUMERIC = "numeric"; public const string VALIDATOR_SUBTYPE_ARRAY = "array"; + // Cross-field validators — entity-scoped, reference sibling fields by name. + public const string VALIDATOR_SUBTYPE_COMPARISON = "comparison"; + public const string VALIDATOR_SUBTYPE_REQUIRED_WHEN = "requiredWhen"; + public const string VALIDATOR_SUBTYPE_PRESENT_IFF = "presentIff"; + public const string VALIDATOR_SUBTYPE_AT_LEAST_ONE = "atLeastOne"; public static readonly string[] VALIDATOR_SUBTYPES = [ @@ -26,10 +31,22 @@ public static class ValidatorConstants VALIDATOR_SUBTYPE_REGEX, VALIDATOR_SUBTYPE_NUMERIC, VALIDATOR_SUBTYPE_ARRAY, + VALIDATOR_SUBTYPE_COMPARISON, + VALIDATOR_SUBTYPE_REQUIRED_WHEN, + VALIDATOR_SUBTYPE_PRESENT_IFF, + VALIDATOR_SUBTYPE_AT_LEAST_ONE, ]; // Validator attr keys (used by codegen-ts when reading validator children) public const string VALIDATOR_ATTR_PATTERN = "pattern"; public const string VALIDATOR_ATTR_MIN = "min"; public const string VALIDATOR_ATTR_MAX = "max"; + // Cross-field validator attrs (field references by name + operator/value). + public const string VALIDATOR_ATTR_LEFT = "left"; + public const string VALIDATOR_ATTR_OP = "op"; + public const string VALIDATOR_ATTR_RIGHT = "right"; + public const string VALIDATOR_ATTR_FIELD = "field"; + public const string VALIDATOR_ATTR_WHEN = "when"; + public const string VALIDATOR_ATTR_EQUALS = "equals"; + public const string VALIDATOR_ATTR_FIELDS = "fields"; } diff --git a/server/csharp/MetaObjects/Core/Validator/ValidatorSchema.cs b/server/csharp/MetaObjects/Core/Validator/ValidatorSchema.cs index ec002edcf..42d3b756a 100644 --- a/server/csharp/MetaObjects/Core/Validator/ValidatorSchema.cs +++ b/server/csharp/MetaObjects/Core/Validator/ValidatorSchema.cs @@ -47,5 +47,39 @@ public static class ValidatorSchema ], [ValidatorConstants.VALIDATOR_SUBTYPE_NUMERIC] = [.. MinMaxValidatorAttrs], [ValidatorConstants.VALIDATOR_SUBTYPE_ARRAY] = [.. MinMaxValidatorAttrs], + // Cross-field validators — entity-scoped, reference sibling fields by name. + [ValidatorConstants.VALIDATOR_SUBTYPE_COMPARISON] = + [ + new AttrSchema(Name: ValidatorConstants.VALIDATOR_ATTR_LEFT, ValueType: AttrConstants.ATTR_SUBTYPE_STRING, + Required: true, Description: "Name of the left-hand field of the owning entity."), + new AttrSchema(Name: ValidatorConstants.VALIDATOR_ATTR_OP, ValueType: AttrConstants.ATTR_SUBTYPE_STRING, + Required: true, AllowedValues: ["gt", "gte", "lt", "lte", "ne", "eq"], + Description: "Relational operator: gt (>), gte (>=), lt (<), lte (<=), ne (<>), eq (=)."), + new AttrSchema(Name: ValidatorConstants.VALIDATOR_ATTR_RIGHT, ValueType: AttrConstants.ATTR_SUBTYPE_STRING, + Required: true, Description: "Name of the right-hand field of the owning entity."), + ], + [ValidatorConstants.VALIDATOR_SUBTYPE_REQUIRED_WHEN] = + [ + new AttrSchema(Name: ValidatorConstants.VALIDATOR_ATTR_FIELD, ValueType: AttrConstants.ATTR_SUBTYPE_STRING, + Required: true, Description: "Name of the field that becomes required when the condition holds."), + new AttrSchema(Name: ValidatorConstants.VALIDATOR_ATTR_WHEN, ValueType: AttrConstants.ATTR_SUBTYPE_STRING, + Required: true, Description: "Name of the gating field whose value triggers the requirement."), + new AttrSchema(Name: ValidatorConstants.VALIDATOR_ATTR_EQUALS, ValueType: AttrConstants.ATTR_SUBTYPE_STRING, + Required: true, Description: "The gating value; when @when equals this, @field must be present."), + ], + [ValidatorConstants.VALIDATOR_SUBTYPE_PRESENT_IFF] = + [ + new AttrSchema(Name: ValidatorConstants.VALIDATOR_ATTR_FIELD, ValueType: AttrConstants.ATTR_SUBTYPE_STRING, + Required: true, Description: "Name of the field whose presence is governed by the condition."), + new AttrSchema(Name: ValidatorConstants.VALIDATOR_ATTR_WHEN, ValueType: AttrConstants.ATTR_SUBTYPE_STRING, + Required: true, Description: "Name of the gating field."), + new AttrSchema(Name: ValidatorConstants.VALIDATOR_ATTR_EQUALS, ValueType: AttrConstants.ATTR_SUBTYPE_STRING, + Required: true, Description: "The gating value; @field is present exactly when @when equals this."), + ], + [ValidatorConstants.VALIDATOR_SUBTYPE_AT_LEAST_ONE] = + [ + new AttrSchema(Name: ValidatorConstants.VALIDATOR_ATTR_FIELDS, ValueType: AttrConstants.ATTR_SUBTYPE_STRING, + Required: true, IsArray: true, Description: "Names of the candidate fields; at least one must be present."), + ], }; } diff --git a/server/csharp/MetaObjects/SpecMetamodel/validator.json b/server/csharp/MetaObjects/SpecMetamodel/validator.json index aa85d796a..d4cd39949 100644 --- a/server/csharp/MetaObjects/SpecMetamodel/validator.json +++ b/server/csharp/MetaObjects/SpecMetamodel/validator.json @@ -51,6 +51,48 @@ { "type": "attr", "subType": "int", "name": "min", "min": 0, "max": 1, "description": "Minimum allowed value (length, numeric value, or array element count depending on the validator subtype)." }, { "type": "attr", "subType": "int", "name": "max", "min": 0, "max": 1, "description": "Maximum allowed value (length, numeric value, or array element count depending on the validator subtype)." } ] + }, + { + "type": "validator", + "subType": "comparison", + "description": "Cross-field ordering: requires two sibling fields of the owning entity stand in a relational order (@left @op @right), e.g. current_hp <= max_hp or expires_at > created_at. Entity-scoped; references fields by name. Backends derive the rule (CHECK constraint, cross-field assertion) — no raw expression is stored.", + "rules": "@left and @right must name fields of the owning entity. @op is one of gt/gte/lt/lte/ne/eq. The comparison is null-tolerant where the backend's relational operator is (SQL: a NULL operand yields no violation).", + "children": [ + { "type": "attr", "subType": "string", "name": "left", "min": 1, "max": 1, "description": "Name of the left-hand field of the owning entity." }, + { "type": "attr", "subType": "string", "name": "op", "min": 1, "max": 1, "allowedValues": ["gt", "gte", "lt", "lte", "ne", "eq"], "description": "Relational operator: gt (>), gte (>=), lt (<), lte (<=), ne (<>), eq (=)." }, + { "type": "attr", "subType": "string", "name": "right", "min": 1, "max": 1, "description": "Name of the right-hand field of the owning entity." } + ] + }, + { + "type": "validator", + "subType": "requiredWhen", + "description": "One-directional conditional presence: when the gating field (@when) equals @equals, the target field (@field) must be present (NOT NULL); otherwise @field is unconstrained. Mirrors JSON Schema dependentRequired / Rails validates_presence_of :x, if:. Entity-scoped; references fields by name.", + "rules": "@field and @when must name fields of the owning entity. @equals is the gating value, compared against @when's value (rendered per @when's field subtype — boolean true/false, enum/string literal, numeric literal).", + "children": [ + { "type": "attr", "subType": "string", "name": "field", "min": 1, "max": 1, "description": "Name of the field that becomes required when the condition holds." }, + { "type": "attr", "subType": "string", "name": "when", "min": 1, "max": 1, "description": "Name of the gating field whose value triggers the requirement." }, + { "type": "attr", "subType": "string", "name": "equals", "min": 1, "max": 1, "description": "The gating value; when @when equals this, @field must be present." } + ] + }, + { + "type": "validator", + "subType": "presentIff", + "description": "Biconditional presence: the target field (@field) is present (NOT NULL) if and only if the gating field (@when) equals @equals. Models paired flag/companion-column invariants, e.g. used_at present iff is_used=true. Entity-scoped; references fields by name.", + "rules": "@field and @when must name fields of the owning entity. @equals is rendered per @when's field subtype. Stricter than requiredWhen — also forbids @field when the condition is false.", + "children": [ + { "type": "attr", "subType": "string", "name": "field", "min": 1, "max": 1, "description": "Name of the field whose presence is governed by the condition." }, + { "type": "attr", "subType": "string", "name": "when", "min": 1, "max": 1, "description": "Name of the gating field." }, + { "type": "attr", "subType": "string", "name": "equals", "min": 1, "max": 1, "description": "The gating value; @field is present exactly when @when equals this." } + ] + }, + { + "type": "validator", + "subType": "atLeastOne", + "description": "Cardinality of presence: at least one of the named fields (@fields) must be present (NOT NULL). Entity-scoped; references fields by name (same @fields-by-name pattern as identity.*).", + "rules": "@fields names two or more fields of the owning entity. Satisfied when any one of them is non-null.", + "children": [ + { "type": "attr", "subType": "string", "name": "fields", "isArray": true, "min": 1, "max": 1, "description": "Names of the candidate fields; at least one must be present." } + ] } ] } diff --git a/server/java/metadata/src/main/java/com/metaobjects/validator/AtLeastOneValidator.java b/server/java/metadata/src/main/java/com/metaobjects/validator/AtLeastOneValidator.java new file mode 100644 index 000000000..ea7ceea0c --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/validator/AtLeastOneValidator.java @@ -0,0 +1,55 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.validator; + +import com.metaobjects.attr.StringAttribute; +import com.metaobjects.registry.MetaDataRegistry; + +import static com.metaobjects.validator.MetaValidator.TYPE_VALIDATOR; +import static com.metaobjects.validator.MetaValidator.SUBTYPE_BASE; + +/** + * Cardinality of presence — at least one of the named fields ({@code @fields}) + * must be present (NOT NULL). Entity-scoped; references fields by name (the same + * {@code @fields}-by-name pattern as {@code identity.*}). DB-enforced, so + * {@link #validate} is a no-op in the JVM. + */ +@SuppressWarnings("serial") +public class AtLeastOneValidator extends MetaValidator { + + public final static String SUBTYPE_AT_LEAST_ONE = "atLeastOne"; + + /** Candidate field names; at least one must be present. */ + public final static String ATTR_FIELDS = "fields"; + + public static void registerTypes(MetaDataRegistry registry) { + registry.registerType(AtLeastOneValidator.class, def -> { + def.type(TYPE_VALIDATOR).subType(SUBTYPE_AT_LEAST_ONE) + .description("Presence cardinality — at least one of @fields must be present") + .inheritsFrom(TYPE_VALIDATOR, SUBTYPE_BASE); + + def.requiredAttributeWithConstraints(ATTR_FIELDS).ofType(StringAttribute.SUBTYPE_STRING).asArray(); + }); + } + + public AtLeastOneValidator(String name) { + super(SUBTYPE_AT_LEAST_ONE, name); + } + + /** DB-enforced (CONSTRAINT CHECK); no JVM-side runtime validation. */ + public void validate(Object object, Object value) { + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/validator/ComparisonValidator.java b/server/java/metadata/src/main/java/com/metaobjects/validator/ComparisonValidator.java new file mode 100644 index 000000000..82ddc5689 --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/validator/ComparisonValidator.java @@ -0,0 +1,63 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.validator; + +import com.metaobjects.attr.StringAttribute; +import com.metaobjects.registry.MetaDataRegistry; + +import static com.metaobjects.validator.MetaValidator.TYPE_VALIDATOR; +import static com.metaobjects.validator.MetaValidator.SUBTYPE_BASE; + +/** + * Cross-field ordering validator — requires two sibling fields of the owning + * entity stand in a relational order ({@code @left @op @right}), e.g. + * {@code current_hp <= max_hp}. Entity-scoped; references fields by name. The + * rule is derived by each backend (SQL CHECK, cross-field assertion); nothing + * raw is stored. DB-enforced, so {@link #validate} is a no-op in the JVM. + */ +@SuppressWarnings("serial") +public class ComparisonValidator extends MetaValidator { + + public final static String SUBTYPE_COMPARISON = "comparison"; + + /** Name of the left-hand field of the owning entity. */ + public final static String ATTR_LEFT = "left"; + /** Relational operator: gt/gte/lt/lte/ne/eq. */ + public final static String ATTR_OP = "op"; + /** Name of the right-hand field of the owning entity. */ + public final static String ATTR_RIGHT = "right"; + + public static void registerTypes(MetaDataRegistry registry) { + registry.registerType(ComparisonValidator.class, def -> { + def.type(TYPE_VALIDATOR).subType(SUBTYPE_COMPARISON) + .description("Cross-field ordering validator (@left @op @right)") + .inheritsFrom(TYPE_VALIDATOR, SUBTYPE_BASE); + + def.requiredAttributeWithConstraints(ATTR_LEFT).ofType(StringAttribute.SUBTYPE_STRING).asSingle(); + def.requiredAttributeWithConstraints(ATTR_OP).ofType(StringAttribute.SUBTYPE_STRING) + .withEnum("gt", "gte", "lt", "lte", "ne", "eq"); + def.requiredAttributeWithConstraints(ATTR_RIGHT).ofType(StringAttribute.SUBTYPE_STRING).asSingle(); + }); + } + + public ComparisonValidator(String name) { + super(SUBTYPE_COMPARISON, name); + } + + /** DB-enforced (CONSTRAINT CHECK); no JVM-side runtime validation. */ + public void validate(Object object, Object value) { + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/validator/PresentIffValidator.java b/server/java/metadata/src/main/java/com/metaobjects/validator/PresentIffValidator.java new file mode 100644 index 000000000..198bf9257 --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/validator/PresentIffValidator.java @@ -0,0 +1,63 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.validator; + +import com.metaobjects.attr.StringAttribute; +import com.metaobjects.registry.MetaDataRegistry; + +import static com.metaobjects.validator.MetaValidator.TYPE_VALIDATOR; +import static com.metaobjects.validator.MetaValidator.SUBTYPE_BASE; + +/** + * Biconditional presence — the target field ({@code @field}) is present (NOT + * NULL) if and only if the gating field ({@code @when}) equals {@code @equals}. + * Models paired flag/companion-column invariants, e.g. used_at present iff + * is_used=true. Stricter than {@link RequiredWhenValidator} (also forbids the + * field when the condition is false). Entity-scoped; references fields by name. + * DB-enforced, so {@link #validate} is a no-op in the JVM. + */ +@SuppressWarnings("serial") +public class PresentIffValidator extends MetaValidator { + + public final static String SUBTYPE_PRESENT_IFF = "presentIff"; + + /** Field whose presence is governed by the condition. */ + public final static String ATTR_FIELD = "field"; + /** Gating field. */ + public final static String ATTR_WHEN = "when"; + /** Gating value; @field is present exactly when @when equals this. */ + public final static String ATTR_EQUALS = "equals"; + + public static void registerTypes(MetaDataRegistry registry) { + registry.registerType(PresentIffValidator.class, def -> { + def.type(TYPE_VALIDATOR).subType(SUBTYPE_PRESENT_IFF) + .description("Biconditional presence (@field present iff @when = @equals)") + .inheritsFrom(TYPE_VALIDATOR, SUBTYPE_BASE); + + def.requiredAttributeWithConstraints(ATTR_FIELD).ofType(StringAttribute.SUBTYPE_STRING).asSingle(); + def.requiredAttributeWithConstraints(ATTR_WHEN).ofType(StringAttribute.SUBTYPE_STRING).asSingle(); + def.requiredAttributeWithConstraints(ATTR_EQUALS).ofType(StringAttribute.SUBTYPE_STRING).asSingle(); + }); + } + + public PresentIffValidator(String name) { + super(SUBTYPE_PRESENT_IFF, name); + } + + /** DB-enforced (CONSTRAINT CHECK); no JVM-side runtime validation. */ + public void validate(Object object, Object value) { + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/validator/RequiredWhenValidator.java b/server/java/metadata/src/main/java/com/metaobjects/validator/RequiredWhenValidator.java new file mode 100644 index 000000000..c4b718d2f --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/validator/RequiredWhenValidator.java @@ -0,0 +1,62 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.validator; + +import com.metaobjects.attr.StringAttribute; +import com.metaobjects.registry.MetaDataRegistry; + +import static com.metaobjects.validator.MetaValidator.TYPE_VALIDATOR; +import static com.metaobjects.validator.MetaValidator.SUBTYPE_BASE; + +/** + * One-directional conditional presence — when the gating field ({@code @when}) + * equals {@code @equals}, the target field ({@code @field}) must be present + * (NOT NULL); otherwise it is unconstrained. Mirrors JSON Schema + * dependentRequired. Entity-scoped; references fields by name. DB-enforced, so + * {@link #validate} is a no-op in the JVM. + */ +@SuppressWarnings("serial") +public class RequiredWhenValidator extends MetaValidator { + + public final static String SUBTYPE_REQUIRED_WHEN = "requiredWhen"; + + /** Field that becomes required when the condition holds. */ + public final static String ATTR_FIELD = "field"; + /** Gating field whose value triggers the requirement. */ + public final static String ATTR_WHEN = "when"; + /** Gating value; when @when equals this, @field must be present. */ + public final static String ATTR_EQUALS = "equals"; + + public static void registerTypes(MetaDataRegistry registry) { + registry.registerType(RequiredWhenValidator.class, def -> { + def.type(TYPE_VALIDATOR).subType(SUBTYPE_REQUIRED_WHEN) + .description("One-directional conditional presence (required when @when = @equals)") + .inheritsFrom(TYPE_VALIDATOR, SUBTYPE_BASE); + + def.requiredAttributeWithConstraints(ATTR_FIELD).ofType(StringAttribute.SUBTYPE_STRING).asSingle(); + def.requiredAttributeWithConstraints(ATTR_WHEN).ofType(StringAttribute.SUBTYPE_STRING).asSingle(); + def.requiredAttributeWithConstraints(ATTR_EQUALS).ofType(StringAttribute.SUBTYPE_STRING).asSingle(); + }); + } + + public RequiredWhenValidator(String name) { + super(SUBTYPE_REQUIRED_WHEN, name); + } + + /** DB-enforced (CONSTRAINT CHECK); no JVM-side runtime validation. */ + public void validate(Object object, Object value) { + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/validator/ValidatorTypesMetaDataProvider.java b/server/java/metadata/src/main/java/com/metaobjects/validator/ValidatorTypesMetaDataProvider.java index 9a065a257..a353fc6a7 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/validator/ValidatorTypesMetaDataProvider.java +++ b/server/java/metadata/src/main/java/com/metaobjects/validator/ValidatorTypesMetaDataProvider.java @@ -41,6 +41,11 @@ public void registerTypes(MetaDataRegistry registry) { RegexValidator.registerTypes(registry); NumericValidator.registerTypes(registry); ArrayValidator.registerTypes(registry); + // Cross-field validators (entity-scoped, reference sibling fields by name). + ComparisonValidator.registerTypes(registry); + RequiredWhenValidator.registerTypes(registry); + PresentIffValidator.registerTypes(registry); + AtLeastOneValidator.registerTypes(registry); log.debug("Validator types registered via provider"); } diff --git a/server/python/src/metaobjects/core_types.py b/server/python/src/metaobjects/core_types.py index f12b3ea82..bd761a93b 100644 --- a/server/python/src/metaobjects/core_types.py +++ b/server/python/src/metaobjects/core_types.py @@ -628,6 +628,26 @@ def _register_subtypes( ], vc.VALIDATOR_SUBTYPE_NUMERIC: list(_VALIDATOR_MIN_MAX_ATTRS), vc.VALIDATOR_SUBTYPE_ARRAY: list(_VALIDATOR_MIN_MAX_ATTRS), + # Cross-field validators — entity-scoped, reference sibling fields by name. + vc.VALIDATOR_SUBTYPE_COMPARISON: [ + AttrSchema(name=vc.VALIDATOR_ATTR_LEFT, value_type=ATTR_SUBTYPE_STRING, required=True), + AttrSchema(name=vc.VALIDATOR_ATTR_OP, value_type=ATTR_SUBTYPE_STRING, required=True, + allowed_values=("gt", "gte", "lt", "lte", "ne", "eq")), + AttrSchema(name=vc.VALIDATOR_ATTR_RIGHT, value_type=ATTR_SUBTYPE_STRING, required=True), + ], + vc.VALIDATOR_SUBTYPE_REQUIRED_WHEN: [ + AttrSchema(name=vc.VALIDATOR_ATTR_FIELD, value_type=ATTR_SUBTYPE_STRING, required=True), + AttrSchema(name=vc.VALIDATOR_ATTR_WHEN, value_type=ATTR_SUBTYPE_STRING, required=True), + AttrSchema(name=vc.VALIDATOR_ATTR_EQUALS, value_type=ATTR_SUBTYPE_STRING, required=True), + ], + vc.VALIDATOR_SUBTYPE_PRESENT_IFF: [ + AttrSchema(name=vc.VALIDATOR_ATTR_FIELD, value_type=ATTR_SUBTYPE_STRING, required=True), + AttrSchema(name=vc.VALIDATOR_ATTR_WHEN, value_type=ATTR_SUBTYPE_STRING, required=True), + AttrSchema(name=vc.VALIDATOR_ATTR_EQUALS, value_type=ATTR_SUBTYPE_STRING, required=True), + ], + vc.VALIDATOR_SUBTYPE_AT_LEAST_ONE: [ + AttrSchema(name=vc.VALIDATOR_ATTR_FIELDS, value_type=ATTR_SUBTYPE_STRING, required=True, is_array=True), + ], } for _sub, _attrs in _VALIDATOR_ATTRS_BY_SUBTYPE.items(): core_provider.add( diff --git a/server/python/src/metaobjects/meta/core/validator/validator_constants.py b/server/python/src/metaobjects/meta/core/validator/validator_constants.py index aabaa2718..5c16eae8b 100644 --- a/server/python/src/metaobjects/meta/core/validator/validator_constants.py +++ b/server/python/src/metaobjects/meta/core/validator/validator_constants.py @@ -11,8 +11,21 @@ VALIDATOR_SUBTYPE_REGEX = "regex" VALIDATOR_SUBTYPE_NUMERIC = "numeric" VALIDATOR_SUBTYPE_ARRAY = "array" +# Cross-field validators — entity-scoped, reference sibling fields by name. +VALIDATOR_SUBTYPE_COMPARISON = "comparison" +VALIDATOR_SUBTYPE_REQUIRED_WHEN = "requiredWhen" +VALIDATOR_SUBTYPE_PRESENT_IFF = "presentIff" +VALIDATOR_SUBTYPE_AT_LEAST_ONE = "atLeastOne" # Validator attr keys (read by codegen when lowering validator children). VALIDATOR_ATTR_MIN = "min" VALIDATOR_ATTR_MAX = "max" VALIDATOR_ATTR_PATTERN = "pattern" +# Cross-field validator attrs (field references by name + operator/value). +VALIDATOR_ATTR_LEFT = "left" +VALIDATOR_ATTR_OP = "op" +VALIDATOR_ATTR_RIGHT = "right" +VALIDATOR_ATTR_FIELD = "field" +VALIDATOR_ATTR_WHEN = "when" +VALIDATOR_ATTR_EQUALS = "equals" +VALIDATOR_ATTR_FIELDS = "fields" diff --git a/server/python/src/metaobjects/spec_metamodel/validator.json b/server/python/src/metaobjects/spec_metamodel/validator.json index aa85d796a..d4cd39949 100644 --- a/server/python/src/metaobjects/spec_metamodel/validator.json +++ b/server/python/src/metaobjects/spec_metamodel/validator.json @@ -51,6 +51,48 @@ { "type": "attr", "subType": "int", "name": "min", "min": 0, "max": 1, "description": "Minimum allowed value (length, numeric value, or array element count depending on the validator subtype)." }, { "type": "attr", "subType": "int", "name": "max", "min": 0, "max": 1, "description": "Maximum allowed value (length, numeric value, or array element count depending on the validator subtype)." } ] + }, + { + "type": "validator", + "subType": "comparison", + "description": "Cross-field ordering: requires two sibling fields of the owning entity stand in a relational order (@left @op @right), e.g. current_hp <= max_hp or expires_at > created_at. Entity-scoped; references fields by name. Backends derive the rule (CHECK constraint, cross-field assertion) — no raw expression is stored.", + "rules": "@left and @right must name fields of the owning entity. @op is one of gt/gte/lt/lte/ne/eq. The comparison is null-tolerant where the backend's relational operator is (SQL: a NULL operand yields no violation).", + "children": [ + { "type": "attr", "subType": "string", "name": "left", "min": 1, "max": 1, "description": "Name of the left-hand field of the owning entity." }, + { "type": "attr", "subType": "string", "name": "op", "min": 1, "max": 1, "allowedValues": ["gt", "gte", "lt", "lte", "ne", "eq"], "description": "Relational operator: gt (>), gte (>=), lt (<), lte (<=), ne (<>), eq (=)." }, + { "type": "attr", "subType": "string", "name": "right", "min": 1, "max": 1, "description": "Name of the right-hand field of the owning entity." } + ] + }, + { + "type": "validator", + "subType": "requiredWhen", + "description": "One-directional conditional presence: when the gating field (@when) equals @equals, the target field (@field) must be present (NOT NULL); otherwise @field is unconstrained. Mirrors JSON Schema dependentRequired / Rails validates_presence_of :x, if:. Entity-scoped; references fields by name.", + "rules": "@field and @when must name fields of the owning entity. @equals is the gating value, compared against @when's value (rendered per @when's field subtype — boolean true/false, enum/string literal, numeric literal).", + "children": [ + { "type": "attr", "subType": "string", "name": "field", "min": 1, "max": 1, "description": "Name of the field that becomes required when the condition holds." }, + { "type": "attr", "subType": "string", "name": "when", "min": 1, "max": 1, "description": "Name of the gating field whose value triggers the requirement." }, + { "type": "attr", "subType": "string", "name": "equals", "min": 1, "max": 1, "description": "The gating value; when @when equals this, @field must be present." } + ] + }, + { + "type": "validator", + "subType": "presentIff", + "description": "Biconditional presence: the target field (@field) is present (NOT NULL) if and only if the gating field (@when) equals @equals. Models paired flag/companion-column invariants, e.g. used_at present iff is_used=true. Entity-scoped; references fields by name.", + "rules": "@field and @when must name fields of the owning entity. @equals is rendered per @when's field subtype. Stricter than requiredWhen — also forbids @field when the condition is false.", + "children": [ + { "type": "attr", "subType": "string", "name": "field", "min": 1, "max": 1, "description": "Name of the field whose presence is governed by the condition." }, + { "type": "attr", "subType": "string", "name": "when", "min": 1, "max": 1, "description": "Name of the gating field." }, + { "type": "attr", "subType": "string", "name": "equals", "min": 1, "max": 1, "description": "The gating value; @field is present exactly when @when equals this." } + ] + }, + { + "type": "validator", + "subType": "atLeastOne", + "description": "Cardinality of presence: at least one of the named fields (@fields) must be present (NOT NULL). Entity-scoped; references fields by name (same @fields-by-name pattern as identity.*).", + "rules": "@fields names two or more fields of the owning entity. Satisfied when any one of them is non-null.", + "children": [ + { "type": "attr", "subType": "string", "name": "fields", "isArray": true, "min": 1, "max": 1, "description": "Names of the candidate fields; at least one must be present." } + ] } ] } diff --git a/server/typescript/packages/metadata/src/core/validator/validator-constants.ts b/server/typescript/packages/metadata/src/core/validator/validator-constants.ts index 93dad6e57..719828e1f 100644 --- a/server/typescript/packages/metadata/src/core/validator/validator-constants.ts +++ b/server/typescript/packages/metadata/src/core/validator/validator-constants.ts @@ -3,7 +3,7 @@ import { SUBTYPE_BASE } from "../../shared/base-types.js"; // --------------------------------------------------------------------------- -// Validator subtypes (6) +// Validator subtypes (10) // --------------------------------------------------------------------------- export const VALIDATOR_SUBTYPE_REQUIRED = "required"; @@ -11,6 +11,11 @@ export const VALIDATOR_SUBTYPE_LENGTH = "length"; export const VALIDATOR_SUBTYPE_REGEX = "regex"; export const VALIDATOR_SUBTYPE_NUMERIC = "numeric"; export const VALIDATOR_SUBTYPE_ARRAY = "array"; +// Cross-field validators — entity-scoped, reference sibling fields by name. +export const VALIDATOR_SUBTYPE_COMPARISON = "comparison"; +export const VALIDATOR_SUBTYPE_REQUIRED_WHEN = "requiredWhen"; +export const VALIDATOR_SUBTYPE_PRESENT_IFF = "presentIff"; +export const VALIDATOR_SUBTYPE_AT_LEAST_ONE = "atLeastOne"; export const VALIDATOR_SUBTYPES = [ SUBTYPE_BASE, @@ -19,6 +24,10 @@ export const VALIDATOR_SUBTYPES = [ VALIDATOR_SUBTYPE_REGEX, VALIDATOR_SUBTYPE_NUMERIC, VALIDATOR_SUBTYPE_ARRAY, + VALIDATOR_SUBTYPE_COMPARISON, + VALIDATOR_SUBTYPE_REQUIRED_WHEN, + VALIDATOR_SUBTYPE_PRESENT_IFF, + VALIDATOR_SUBTYPE_AT_LEAST_ONE, ] as const; export type ValidatorSubType = (typeof VALIDATOR_SUBTYPES)[number]; @@ -29,3 +38,15 @@ export type ValidatorSubType = (typeof VALIDATOR_SUBTYPES)[number]; export const VALIDATOR_ATTR_PATTERN = "pattern"; export const VALIDATOR_ATTR_MIN = "min"; export const VALIDATOR_ATTR_MAX = "max"; +// Cross-field validator attrs (field references by name + operator/value). +export const VALIDATOR_ATTR_LEFT = "left"; +export const VALIDATOR_ATTR_OP = "op"; +export const VALIDATOR_ATTR_RIGHT = "right"; +export const VALIDATOR_ATTR_FIELD = "field"; +export const VALIDATOR_ATTR_WHEN = "when"; +export const VALIDATOR_ATTR_EQUALS = "equals"; +export const VALIDATOR_ATTR_FIELDS = "fields"; + +// Comparison operators (@op allowed values) → relational operator. +export const VALIDATOR_COMPARISON_OPS = ["gt", "gte", "lt", "lte", "ne", "eq"] as const; +export type ComparisonOp = (typeof VALIDATOR_COMPARISON_OPS)[number]; diff --git a/server/typescript/packages/metadata/src/core/validator/validator-definition.embedded.ts b/server/typescript/packages/metadata/src/core/validator/validator-definition.embedded.ts index 7787a0863..230b17865 100644 --- a/server/typescript/packages/metadata/src/core/validator/validator-definition.embedded.ts +++ b/server/typescript/packages/metadata/src/core/validator/validator-definition.embedded.ts @@ -136,6 +136,127 @@ export const VALIDATOR_DEFINITION: ProviderDefinition = { "description": "Maximum allowed value (length, numeric value, or array element count depending on the validator subtype)." } ] + }, + { + "type": "validator", + "subType": "comparison", + "description": "Cross-field ordering: requires two sibling fields of the owning entity stand in a relational order (@left @op @right), e.g. current_hp <= max_hp or expires_at > created_at. Entity-scoped; references fields by name. Backends derive the rule (CHECK constraint, cross-field assertion) — no raw expression is stored.", + "rules": "@left and @right must name fields of the owning entity. @op is one of gt/gte/lt/lte/ne/eq. The comparison is null-tolerant where the backend's relational operator is (SQL: a NULL operand yields no violation).", + "children": [ + { + "type": "attr", + "subType": "string", + "name": "left", + "min": 1, + "max": 1, + "description": "Name of the left-hand field of the owning entity." + }, + { + "type": "attr", + "subType": "string", + "name": "op", + "min": 1, + "max": 1, + "allowedValues": [ + "gt", + "gte", + "lt", + "lte", + "ne", + "eq" + ], + "description": "Relational operator: gt (>), gte (>=), lt (<), lte (<=), ne (<>), eq (=)." + }, + { + "type": "attr", + "subType": "string", + "name": "right", + "min": 1, + "max": 1, + "description": "Name of the right-hand field of the owning entity." + } + ] + }, + { + "type": "validator", + "subType": "requiredWhen", + "description": "One-directional conditional presence: when the gating field (@when) equals @equals, the target field (@field) must be present (NOT NULL); otherwise @field is unconstrained. Mirrors JSON Schema dependentRequired / Rails validates_presence_of :x, if:. Entity-scoped; references fields by name.", + "rules": "@field and @when must name fields of the owning entity. @equals is the gating value, compared against @when's value (rendered per @when's field subtype — boolean true/false, enum/string literal, numeric literal).", + "children": [ + { + "type": "attr", + "subType": "string", + "name": "field", + "min": 1, + "max": 1, + "description": "Name of the field that becomes required when the condition holds." + }, + { + "type": "attr", + "subType": "string", + "name": "when", + "min": 1, + "max": 1, + "description": "Name of the gating field whose value triggers the requirement." + }, + { + "type": "attr", + "subType": "string", + "name": "equals", + "min": 1, + "max": 1, + "description": "The gating value; when @when equals this, @field must be present." + } + ] + }, + { + "type": "validator", + "subType": "presentIff", + "description": "Biconditional presence: the target field (@field) is present (NOT NULL) if and only if the gating field (@when) equals @equals. Models paired flag/companion-column invariants, e.g. used_at present iff is_used=true. Entity-scoped; references fields by name.", + "rules": "@field and @when must name fields of the owning entity. @equals is rendered per @when's field subtype. Stricter than requiredWhen — also forbids @field when the condition is false.", + "children": [ + { + "type": "attr", + "subType": "string", + "name": "field", + "min": 1, + "max": 1, + "description": "Name of the field whose presence is governed by the condition." + }, + { + "type": "attr", + "subType": "string", + "name": "when", + "min": 1, + "max": 1, + "description": "Name of the gating field." + }, + { + "type": "attr", + "subType": "string", + "name": "equals", + "min": 1, + "max": 1, + "description": "The gating value; @field is present exactly when @when equals this." + } + ] + }, + { + "type": "validator", + "subType": "atLeastOne", + "description": "Cardinality of presence: at least one of the named fields (@fields) must be present (NOT NULL). Entity-scoped; references fields by name (same @fields-by-name pattern as identity.*).", + "rules": "@fields names two or more fields of the owning entity. Satisfied when any one of them is non-null.", + "children": [ + { + "type": "attr", + "subType": "string", + "name": "fields", + "isArray": true, + "min": 1, + "max": 1, + "description": "Names of the candidate fields; at least one must be present." + } + ] } ] }; diff --git a/server/typescript/packages/metadata/test/validator-definition-completeness.test.ts b/server/typescript/packages/metadata/test/validator-definition-completeness.test.ts index 154e1a593..0e2c24725 100644 --- a/server/typescript/packages/metadata/test/validator-definition-completeness.test.ts +++ b/server/typescript/packages/metadata/test/validator-definition-completeness.test.ts @@ -36,10 +36,29 @@ const EXPECTED: Record { - test("registers all 6 validator subtypes", () => { + test("registers all validator subtypes", () => { const registered = registry.allSubTypesOf(TYPE_VALIDATOR).sort(); expect(registered).toEqual([...VALIDATOR_SUBTYPES].sort()); }); diff --git a/server/typescript/packages/migrate-ts/src/expected-schema.ts b/server/typescript/packages/migrate-ts/src/expected-schema.ts index 3f537710c..20b52ef60 100644 --- a/server/typescript/packages/migrate-ts/src/expected-schema.ts +++ b/server/typescript/packages/migrate-ts/src/expected-schema.ts @@ -1,7 +1,11 @@ import type { ColumnNamingStrategy, MetaData, MetaObject, MetaRoot, MetaValidator } from "@metaobjectsdev/metadata"; import { VALIDATOR_SUBTYPE_NUMERIC, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX, + VALIDATOR_SUBTYPE_COMPARISON, VALIDATOR_SUBTYPE_REQUIRED_WHEN, + VALIDATOR_SUBTYPE_PRESENT_IFF, VALIDATOR_SUBTYPE_AT_LEAST_ONE, VALIDATOR_ATTR_PATTERN, + VALIDATOR_ATTR_LEFT, VALIDATOR_ATTR_OP, VALIDATOR_ATTR_RIGHT, + VALIDATOR_ATTR_FIELD, VALIDATOR_ATTR_WHEN, VALIDATOR_ATTR_EQUALS, VALIDATOR_ATTR_FIELDS, TYPE_OBJECT, TYPE_FIELD, OBJECT_ATTR_DISCRIMINATOR, @@ -439,9 +443,105 @@ function buildChecks( if (check) checks.push(check); } } + // Entity-scoped cross-field validators (comparison / requiredWhen / presentIff / atLeastOne). + for (const v of entity.validators()) { + const check = crossFieldCheck(v, entity, tableName, strategy, dialect); + if (check) checks.push(check); + } return checks; } +const COMPARISON_SQL_OP: Record = { + gt: ">", gte: ">=", lt: "<", lte: "<=", ne: "<>", eq: "=", +}; + +/** Resolve a by-name field reference to its physical column (quoted) + the MetaField, or null. */ +function resolveRef( + entity: MetaObject, name: unknown, strategy: ColumnNamingStrategy, +): { qcol: string; field: ReturnType[number] } | null { + if (typeof name !== "string" || name.length === 0) return null; + const field = entity.fields().find((f) => f.name === name); + if (!field) return null; + return { qcol: quoteCheckCol(resolveColumnName(field, strategy)), field }; +} + +/** + * Render an @equals gating value as a SQL literal, typed by the gating field's + * subtype: boolean → TRUE/FALSE (1/0 on sqlite/d1), numeric → bare number, + * everything else → quoted string. + */ +function renderEquals( + raw: unknown, whenField: ReturnType[number], dialect: Dialect | undefined, +): string { + const s = String(raw); + if (whenField.subType === FIELD_SUBTYPE_BOOLEAN) { + const truthy = s === "true" || s === "1" || s === "TRUE"; + if (dialect === "sqlite" || dialect === "d1") return truthy ? "1" : "0"; + return truthy ? "TRUE" : "FALSE"; + } + const numeric = whenField.subType === FIELD_SUBTYPE_INT || whenField.subType === FIELD_SUBTYPE_LONG + || whenField.subType === FIELD_SUBTYPE_DOUBLE || whenField.subType === FIELD_SUBTYPE_FLOAT + || whenField.subType === FIELD_SUBTYPE_DECIMAL || whenField.subType === FIELD_SUBTYPE_CURRENCY; + if (numeric && /^-?\d+(\.\d+)?$/.test(s)) return s; + return `'${s.replace(/'/g, "''")}'`; +} + +/** + * Derive a CHECK from an entity-scoped cross-field validator. Every reference is + * resolved to its physical column by name; nothing raw is read from metadata. + * Returns null (skips the check) if any referenced field is missing. + */ +function crossFieldCheck( + v: MetaValidator, entity: MetaObject, tableName: string, + strategy: ColumnNamingStrategy, dialect: Dialect | undefined, +): CheckDescriptor | null { + switch (v.subType) { + case VALIDATOR_SUBTYPE_COMPARISON: { + const left = resolveRef(entity, v.ownAttr(VALIDATOR_ATTR_LEFT), strategy); + const right = resolveRef(entity, v.ownAttr(VALIDATOR_ATTR_RIGHT), strategy); + const op = COMPARISON_SQL_OP[String(v.ownAttr(VALIDATOR_ATTR_OP))]; + if (!left || !right || !op) return null; + const lc = resolveColumnName(left.field, strategy); + return { name: `${tableName}_${lc}_cmp_chk`, expression: `${left.qcol} ${op} ${right.qcol}` }; + } + case VALIDATOR_SUBTYPE_REQUIRED_WHEN: { + const target = resolveRef(entity, v.ownAttr(VALIDATOR_ATTR_FIELD), strategy); + const when = resolveRef(entity, v.ownAttr(VALIDATOR_ATTR_WHEN), strategy); + if (!target || !when) return null; + const lit = renderEquals(v.ownAttr(VALIDATOR_ATTR_EQUALS), when.field, dialect); + const fc = resolveColumnName(target.field, strategy); + return { + name: `${tableName}_${fc}_reqwhen_chk`, + expression: `(${when.qcol} IS DISTINCT FROM ${lit}) OR (${target.qcol} IS NOT NULL)`, + }; + } + case VALIDATOR_SUBTYPE_PRESENT_IFF: { + const target = resolveRef(entity, v.ownAttr(VALIDATOR_ATTR_FIELD), strategy); + const when = resolveRef(entity, v.ownAttr(VALIDATOR_ATTR_WHEN), strategy); + if (!target || !when) return null; + const lit = renderEquals(v.ownAttr(VALIDATOR_ATTR_EQUALS), when.field, dialect); + const fc = resolveColumnName(target.field, strategy); + return { + name: `${tableName}_${fc}_presentiff_chk`, + expression: `(${target.qcol} IS NOT NULL) = (${when.qcol} IS NOT DISTINCT FROM ${lit})`, + }; + } + case VALIDATOR_SUBTYPE_AT_LEAST_ONE: { + const raw = v.ownAttr(VALIDATOR_ATTR_FIELDS); + const names = Array.isArray(raw) ? raw : (typeof raw === "string" ? [raw] : []); + const refs = names.map((n) => resolveRef(entity, n, strategy)); + if (refs.length === 0 || refs.some((r) => r === null)) return null; + const firstCol = resolveColumnName(refs[0]!.field, strategy); + return { + name: `${tableName}_${firstCol}_atleastone_chk`, + expression: refs.map((r) => `${r!.qcol} IS NOT NULL`).join(" OR "), + }; + } + default: + return null; + } +} + function buildForeignKeys( entity: MetaObject, tableName: string, diff --git a/server/typescript/packages/migrate-ts/test/validator-check/cross-field.test.ts b/server/typescript/packages/migrate-ts/test/validator-check/cross-field.test.ts new file mode 100644 index 000000000..3d1204f5a --- /dev/null +++ b/server/typescript/packages/migrate-ts/test/validator-check/cross-field.test.ts @@ -0,0 +1,104 @@ +// test/validator-check/cross-field.test.ts +// Entity-scoped cross-field validators derive CHECK constraints from semantic +// intent — every field reference is resolved by name to its physical column; +// no raw SQL is read from metadata. +import { describe, test, expect } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import type { MetaData } from "@metaobjectsdev/metadata"; +import { buildExpectedSchema } from "../../src/expected-schema.js"; +import { diff } from "../../src/diff/index.js"; +import { emit } from "../../src/emit/index.js"; + +async function load(json: string): Promise { + return (await new MetaDataLoader().load([new InMemoryStringSource(json)])).root; +} + +const ENTITY = JSON.stringify({ + "metadata.root": { children: [{ + "object.entity": { name: "Subscription", children: [ + { "field.long": { name: "id" } }, + { "field.int": { name: "current_hp" } }, + { "field.int": { name: "max_hp" } }, + { "field.timestamp": { name: "created_at" } }, + { "field.timestamp": { name: "expires_at" } }, + { "field.boolean": { name: "is_used" } }, + { "field.timestamp": { name: "used_at" } }, + { "field.enum": { name: "status", "@values": ["OPEN", "RESOLVED"] } }, + { "field.timestamp": { name: "resolved_at" } }, + { "field.long": { name: "limit_a" } }, + { "field.long": { name: "limit_b" } }, + { "validator.comparison": { name: "hp", "@left": "current_hp", "@op": "lte", "@right": "max_hp" } }, + { "validator.comparison": { name: "exp", "@left": "expires_at", "@op": "gt", "@right": "created_at" } }, + { "validator.presentIff": { name: "used", "@field": "used_at", "@when": "is_used", "@equals": "true" } }, + { "validator.requiredWhen": { name: "res", "@field": "resolved_at", "@when": "status", "@equals": "RESOLVED" } }, + { "validator.atLeastOne": { name: "lims", "@fields": ["limit_a", "limit_b"] } }, + { "source.rdb": { name: "src", "@table": "subscriptions" } }, + { "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } }, + ] }, + }] }, +}); + +function checksByName(t: ReturnType["tables"][number]) { + return new Map(t.checks.map((c) => [c.name, c.expression])); +} + +describe("e2e: entity-scoped cross-field validators", () => { + test("comparison derives the relational operator between two resolved columns", async () => { + const t = buildExpectedSchema(await load(ENTITY), { dialect: "postgres" }).tables[0]!; + const c = checksByName(t); + expect(c.get("subscriptions_current_hp_cmp_chk")).toBe(`"current_hp" <= "max_hp"`); + expect(c.get("subscriptions_expires_at_cmp_chk")).toBe(`"expires_at" > "created_at"`); + }); + + test("requiredWhen is one-directional (null-safe gate, enum literal)", async () => { + const t = buildExpectedSchema(await load(ENTITY), { dialect: "postgres" }).tables[0]!; + const c = checksByName(t); + expect(c.get("subscriptions_resolved_at_reqwhen_chk")) + .toBe(`("status" IS DISTINCT FROM 'RESOLVED') OR ("resolved_at" IS NOT NULL)`); + }); + + test("presentIff is biconditional with a boolean literal typed from the gating field", async () => { + const t = buildExpectedSchema(await load(ENTITY), { dialect: "postgres" }).tables[0]!; + const c = checksByName(t); + expect(c.get("subscriptions_used_at_presentiff_chk")) + .toBe(`("used_at" IS NOT NULL) = ("is_used" IS NOT DISTINCT FROM TRUE)`); + }); + + test("atLeastOne ORs the presence of every named column", async () => { + const t = buildExpectedSchema(await load(ENTITY), { dialect: "postgres" }).tables[0]!; + const c = checksByName(t); + expect(c.get("subscriptions_limit_a_atleastone_chk")) + .toBe(`"limit_a" IS NOT NULL OR "limit_b" IS NOT NULL`); + }); + + test("boolean literal renders 1/0 on sqlite", async () => { + const t = buildExpectedSchema(await load(ENTITY), { dialect: "sqlite" }).tables[0]!; + const c = checksByName(t); + expect(c.get("subscriptions_used_at_presentiff_chk")) + .toBe(`("used_at" IS NOT NULL) = ("is_used" IS NOT DISTINCT FROM 1)`); + }); + + test("a missing referenced field skips the check rather than emitting bad SQL", async () => { + const bad = JSON.stringify({ + "metadata.root": { children: [{ + "object.entity": { name: "E", children: [ + { "field.long": { name: "id" } }, + { "field.int": { name: "a" } }, + { "validator.comparison": { name: "x", "@left": "a", "@op": "lte", "@right": "nonexistent" } }, + { "source.rdb": { name: "src", "@table": "e" } }, + { "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } }, + ] }, + }] }, + }); + const t = buildExpectedSchema(await load(bad), { dialect: "postgres" }).tables[0]!; + expect(t.checks.find((c) => c.name.includes("cmp"))).toBeUndefined(); + }); + + test("the derived checks appear in the emitted CREATE TABLE", async () => { + const expected = buildExpectedSchema(await load(ENTITY), { dialect: "postgres" }); + const r = await diff({ expected, actual: { tables: [], views: [] } }); + const { up } = emit(r.changes, { dialect: "postgres" }); + expect(up).toContain(`CONSTRAINT "subscriptions_current_hp_cmp_chk" CHECK ("current_hp" <= "max_hp")`); + expect(up).toContain(`CONSTRAINT "subscriptions_limit_a_atleastone_chk"`); + }); +}); diff --git a/spec/metamodel/validator.json b/spec/metamodel/validator.json index aa85d796a..d4cd39949 100644 --- a/spec/metamodel/validator.json +++ b/spec/metamodel/validator.json @@ -51,6 +51,48 @@ { "type": "attr", "subType": "int", "name": "min", "min": 0, "max": 1, "description": "Minimum allowed value (length, numeric value, or array element count depending on the validator subtype)." }, { "type": "attr", "subType": "int", "name": "max", "min": 0, "max": 1, "description": "Maximum allowed value (length, numeric value, or array element count depending on the validator subtype)." } ] + }, + { + "type": "validator", + "subType": "comparison", + "description": "Cross-field ordering: requires two sibling fields of the owning entity stand in a relational order (@left @op @right), e.g. current_hp <= max_hp or expires_at > created_at. Entity-scoped; references fields by name. Backends derive the rule (CHECK constraint, cross-field assertion) — no raw expression is stored.", + "rules": "@left and @right must name fields of the owning entity. @op is one of gt/gte/lt/lte/ne/eq. The comparison is null-tolerant where the backend's relational operator is (SQL: a NULL operand yields no violation).", + "children": [ + { "type": "attr", "subType": "string", "name": "left", "min": 1, "max": 1, "description": "Name of the left-hand field of the owning entity." }, + { "type": "attr", "subType": "string", "name": "op", "min": 1, "max": 1, "allowedValues": ["gt", "gte", "lt", "lte", "ne", "eq"], "description": "Relational operator: gt (>), gte (>=), lt (<), lte (<=), ne (<>), eq (=)." }, + { "type": "attr", "subType": "string", "name": "right", "min": 1, "max": 1, "description": "Name of the right-hand field of the owning entity." } + ] + }, + { + "type": "validator", + "subType": "requiredWhen", + "description": "One-directional conditional presence: when the gating field (@when) equals @equals, the target field (@field) must be present (NOT NULL); otherwise @field is unconstrained. Mirrors JSON Schema dependentRequired / Rails validates_presence_of :x, if:. Entity-scoped; references fields by name.", + "rules": "@field and @when must name fields of the owning entity. @equals is the gating value, compared against @when's value (rendered per @when's field subtype — boolean true/false, enum/string literal, numeric literal).", + "children": [ + { "type": "attr", "subType": "string", "name": "field", "min": 1, "max": 1, "description": "Name of the field that becomes required when the condition holds." }, + { "type": "attr", "subType": "string", "name": "when", "min": 1, "max": 1, "description": "Name of the gating field whose value triggers the requirement." }, + { "type": "attr", "subType": "string", "name": "equals", "min": 1, "max": 1, "description": "The gating value; when @when equals this, @field must be present." } + ] + }, + { + "type": "validator", + "subType": "presentIff", + "description": "Biconditional presence: the target field (@field) is present (NOT NULL) if and only if the gating field (@when) equals @equals. Models paired flag/companion-column invariants, e.g. used_at present iff is_used=true. Entity-scoped; references fields by name.", + "rules": "@field and @when must name fields of the owning entity. @equals is rendered per @when's field subtype. Stricter than requiredWhen — also forbids @field when the condition is false.", + "children": [ + { "type": "attr", "subType": "string", "name": "field", "min": 1, "max": 1, "description": "Name of the field whose presence is governed by the condition." }, + { "type": "attr", "subType": "string", "name": "when", "min": 1, "max": 1, "description": "Name of the gating field." }, + { "type": "attr", "subType": "string", "name": "equals", "min": 1, "max": 1, "description": "The gating value; @field is present exactly when @when equals this." } + ] + }, + { + "type": "validator", + "subType": "atLeastOne", + "description": "Cardinality of presence: at least one of the named fields (@fields) must be present (NOT NULL). Entity-scoped; references fields by name (same @fields-by-name pattern as identity.*).", + "rules": "@fields names two or more fields of the owning entity. Satisfied when any one of them is non-null.", + "children": [ + { "type": "attr", "subType": "string", "name": "fields", "isArray": true, "min": 1, "max": 1, "description": "Names of the candidate fields; at least one must be present." } + ] } ] }