From 7a8af7bbc7c0bb24460276da64687cbd347f94af Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 02:47:30 -0500 Subject: [PATCH 01/12] feat(metadata): schema-naming, duration and cross-field vocabulary in every port (metamodelVersion 1.3) Registers the 1.3 vocabulary in all five ports, with the loader checks and registry-conformance coverage: - field.duration, a fixed-length span of time. - validator.exactlyOne (@fields, two or more). It replaces atLeastOne @max, which is removed with no shim. - @condition (attr.expression) on all five cross-field validators. - @constraintName on validators and on field.enum. - @flattenedColumnPrefix on a flattened field.object. - @sequenceStart and @sequenceName on identity.primary. - @maxLength on field.enum, plus the enums.maxLength project setting. - The upperFirst column naming rule. Every port's config reader also reads wire.durations (native by default, or iso) beside decimals and nullMembers. ADR-0023 Amendment 1 records why each new attribute cannot be computed, and each registered description points to it. The loader now checks flattened member columns for collisions. --- fixtures/conformance/ERROR-CODES.json | 5 + .../expected.json | 65 +++ .../input/meta.orders.json | 63 +++ .../providers.json | 4 + .../conformance/enum-max-length/expected.json | 38 ++ .../enum-max-length/input/meta.enums.json | 23 + .../expected-errors.json | 25 ++ .../input/meta.orders.json | 63 +++ .../providers.json | 4 + .../expected-errors.json | 15 + .../input/meta.orders.json | 60 +++ .../providers.json | 4 + .../expected-errors.json | 15 + .../input/meta.enums.json | 24 + .../expected-errors.json | 25 ++ .../input/meta.enums.json | 18 + .../expected-errors.json | 15 + .../input/meta.enums.json | 23 + .../expected-errors.json | 15 + .../input/meta.storage.json | 57 +++ .../providers.json | 1 + .../expected-errors.json | 15 + .../input/meta.storage.json | 58 +++ .../providers.json | 1 + .../expected-errors.json | 25 ++ .../input/meta.seq.json | 53 +++ .../expected-errors.json | 15 + .../input/meta.seq.json | 32 ++ .../expected-errors.json | 15 + .../input/meta.a.json | 55 +++ .../input/meta.b.json | 22 + .../providers.json | 4 + .../expected-errors.json | 15 + .../input/meta.checks.json | 62 +++ .../expected-errors.json | 55 +++ .../input/meta.checks.json | 136 ++++++ .../expected-errors.json | 15 + .../input/meta.checks.json | 53 +++ .../expected-errors.json | 35 ++ .../input/meta.checks.json | 105 +++++ .../expected-errors.json | 15 + .../input/meta.checks.json | 46 ++ .../field-duration-basic/expected.json | 50 +++ .../input/meta.timers.json | 20 + .../filter-ops-matrix/input/meta.matrix.json | 3 + .../conformance/filter-ops-matrix/script.json | 9 + .../identity-primary-sequence/expected.json | 35 ++ .../input/meta.seq.json | 25 ++ .../expected.json | 151 +++++++ .../input/meta.checks.json | 149 +++++++ fixtures/enum-length-conformance/README.md | 30 ++ fixtures/enum-length-conformance/cases.json | 45 ++ .../enum-length-conformance/meta.enums.json | 30 ++ fixtures/metamodel-docs/expected/INDEX.md | 2 + fixtures/metamodel-docs/expected/providers.md | 29 +- .../metamodel-docs/expected/types/field.md | 36 ++ .../metamodel-docs/expected/types/identity.md | 2 + .../expected/types/validator.md | 33 ++ .../physical-names/cases.json | 53 ++- .../physical-names/meta.naming.json | 26 ++ .../registry-conformance/coverage-report.json | 50 ++- .../expected-registry.json | 287 +++++++++++- fixtures/registry-conformance/providers.json | 12 +- .../csharp/RequirementHarnessTests.cs | 10 +- .../java/RequirementHarnessTest.java | 12 +- .../kotlin/RequirementHarnessTest.kt | 12 +- .../python/test_requirement_harness.py | 12 +- .../typescript/requirement-harness.test.ts | 12 +- metaobjects/meta.requirements.yaml | 23 + .../MetaObjects/Config/MetadataLocation.cs | 12 +- .../MetaObjects/Config/NeutralConfig.cs | 58 ++- .../Core/Attr/ExpressionGrammar.cs | 17 + .../MetaObjects/Core/Field/FieldConstants.cs | 11 + .../MetaObjects/Core/Query/QueryConstants.cs | 8 + .../Core/Validator/ValidatorConstants.cs | 15 + .../Core/Validator/ValidatorSchema.cs | 15 + server/csharp/MetaObjects/CoreTypes.cs | 5 +- server/csharp/MetaObjects/Errors.cs | 11 + .../MetaObjects/Loader/MetaDataLoader.cs | 4 + .../MetaObjects/Loader/ValidationPasses.cs | 328 +++++++++++++- server/csharp/MetaObjects/Meta/EnumLength.cs | 54 +++ server/csharp/MetaObjects/Meta/MetaField.cs | 12 + server/csharp/MetaObjects/Meta/MetaRoot.cs | 7 + .../csharp/MetaObjects/Meta/PhysicalNaming.cs | 45 +- server/csharp/MetaObjects/Meta/WireFormat.cs | 24 +- server/csharp/MetaObjects/Parser.cs | 10 +- server/csharp/MetaObjects/RegistryManifest.cs | 2 +- .../csharp/MetaObjects/SpecMetamodel/db.json | 23 + .../MetaObjects/SpecMetamodel/field.json | 9 + .../MetaObjects/SpecMetamodel/validator.json | 23 +- .../main/java/com/metaobjects/ErrorCode.java | 7 + .../main/java/com/metaobjects/MetaRoot.java | 16 + .../com/metaobjects/config/EnumDefaults.java | 35 ++ .../com/metaobjects/config/NeutralConfig.java | 54 ++- .../metaobjects/config/SourceResolver.java | 5 + .../com/metaobjects/config/WireFormat.java | 40 +- .../metaobjects/database/ColumnNaming.java | 53 ++- .../database/CoreDBMetaDataProvider.java | 28 +- .../com/metaobjects/database/EnumLength.java | 66 +++ .../metaobjects/database/PhysicalNaming.java | 13 +- .../com/metaobjects/field/DurationField.java | 78 ++++ .../com/metaobjects/field/DurationWire.java | 256 +++++++++++ .../java/com/metaobjects/field/EnumField.java | 10 + .../field/FieldTypesMetaDataProvider.java | 1 + .../com/metaobjects/field/ObjectField.java | 9 + .../metaobjects/identity/PrimaryIdentity.java | 10 + .../metaobjects/loader/MetaDataLoader.java | 5 + .../metaobjects/loader/ValidationPhase.java | 409 +++++++++++++++++- .../parser/json/CanonicalJsonParser.java | 20 +- .../java/com/metaobjects/query/FilterOps.java | 3 + .../registry/RegistryManifest.java | 2 +- .../util/ErrorMessageConstants.java | 36 ++ .../validator/AtLeastOneValidator.java | 2 + .../validator/ComparisonValidator.java | 2 + .../validator/ExactlyOneValidator.java | 57 +++ .../metaobjects/validator/MetaValidator.java | 6 + .../validator/PresentIffValidator.java | 2 + .../validator/RequiredWhenValidator.java | 2 + .../ValidatorTypesMetaDataProvider.java | 1 + .../config/WireConformanceTest.java | 5 +- .../database/ColumnNamingTest.java | 2 +- .../database/EnumLengthConformanceTest.java | 112 +++++ .../PhysicalNamingConformanceTest.java | 13 +- .../field/DurationWireVectorsTest.java | 179 ++++++++ .../src/metaobjects/config/neutral_config.py | 49 ++- server/python/src/metaobjects/core_types.py | 10 + server/python/src/metaobjects/errors.py | 7 + server/python/src/metaobjects/loader/merge.py | 14 +- .../metaobjects/loader/validation_passes.py | 402 +++++++++++++++-- .../meta/core/field/field_constants.py | 17 +- .../metaobjects/meta/core/field/meta_field.py | 3 + .../core/validator/validator_constants.py | 13 + .../meta/persistence/db/db_constants.py | 4 + .../meta/persistence/db/db_provider.py | 76 +++- server/python/src/metaobjects/naming.py | 51 ++- .../src/metaobjects/registry_manifest.py | 2 +- .../src/metaobjects/spec_metamodel/db.json | 23 + .../src/metaobjects/spec_metamodel/field.json | 9 + .../metaobjects/spec_metamodel/validator.json | 23 +- .../tests/config/test_neutral_config.py | 20 + .../conformance/test_naming_conformance.py | 19 +- .../conformance/test_wire_conformance.py | 5 +- .../tests/unit/test_column_naming_strategy.py | 11 +- .../integration-tests/src/load-metadata.ts | 6 +- .../test/load-metadata-config.test.ts | 30 ++ .../metadata/src/core/field/duration-wire.ts | 182 ++++++++ .../src/core/field/field-constants.ts | 7 + .../core/field/field-definition.embedded.ts | 16 + .../metadata/src/core/field/meta-field.ts | 4 + .../src/core/query/query-constants.ts | 5 +- .../src/core/validator/validator-constants.ts | 15 + .../validator-definition.embedded.ts | 58 +++ .../packages/metadata/src/enum-length.ts | 66 +++ .../packages/metadata/src/errors.ts | 8 + .../typescript/packages/metadata/src/index.ts | 14 +- .../metadata/src/loader/meta-data-loader.ts | 12 +- .../metadata/src/loader/validation-passes.ts | 324 +++++++++++++- .../packages/metadata/src/naming.ts | 57 ++- .../packages/metadata/src/parser-core.ts | 12 +- .../src/persistence/db/db-constants.ts | 10 + .../persistence/db/db-definition.embedded.ts | 67 +++ .../metadata/src/registry-manifest.ts | 2 +- .../packages/metadata/src/shared/meta-root.ts | 7 + .../typescript/packages/metadata/src/wire.ts | 14 + .../metadata/test/enum-max-length.test.ts | 70 +++ .../field-definition-completeness.test.ts | 16 +- .../packages/metadata/test/naming.test.ts | 15 + .../validator-definition-completeness.test.ts | 8 + .../typescript/packages/sdk/src/collection.ts | 21 +- server/typescript/packages/sdk/src/config.ts | 39 +- server/typescript/packages/sdk/src/index.ts | 2 +- server/typescript/packages/sdk/src/memory.ts | 11 +- .../sdk/test/enum-length-conformance.test.ts | 81 ++++ .../test/physical-naming-conformance.test.ts | 16 +- .../test/schema-conventions-config.test.ts | 80 ++++ .../sdk/test/wire-conformance.test.ts | 14 +- .../ADR-0019-runtime-return-type-contract.md | 11 + .../ADR-0023-strict-metadata-provenance.md | 43 ++ .../ADR-0058-one-physical-naming-setting.md | 4 +- spec/metamodel/db.json | 23 + spec/metamodel/field.json | 9 + spec/metamodel/validator.json | 23 +- 182 files changed, 6698 insertions(+), 246 deletions(-) create mode 100644 fixtures/conformance/column-name-flattened-prefixes-ok/expected.json create mode 100644 fixtures/conformance/column-name-flattened-prefixes-ok/input/meta.orders.json create mode 100644 fixtures/conformance/column-name-flattened-prefixes-ok/providers.json create mode 100644 fixtures/conformance/enum-max-length/expected.json create mode 100644 fixtures/conformance/enum-max-length/input/meta.enums.json create mode 100644 fixtures/conformance/error-column-name-collision-flattened-empty-prefix/expected-errors.json create mode 100644 fixtures/conformance/error-column-name-collision-flattened-empty-prefix/input/meta.orders.json create mode 100644 fixtures/conformance/error-column-name-collision-flattened-empty-prefix/providers.json create mode 100644 fixtures/conformance/error-column-name-collision-flattened-member-vs-field/expected-errors.json create mode 100644 fixtures/conformance/error-column-name-collision-flattened-member-vs-field/input/meta.orders.json create mode 100644 fixtures/conformance/error-column-name-collision-flattened-member-vs-field/providers.json create mode 100644 fixtures/conformance/error-enum-max-length-int-value-map/expected-errors.json create mode 100644 fixtures/conformance/error-enum-max-length-int-value-map/input/meta.enums.json create mode 100644 fixtures/conformance/error-enum-value-exceeds-max-length-every-field/expected-errors.json create mode 100644 fixtures/conformance/error-enum-value-exceeds-max-length-every-field/input/meta.enums.json create mode 100644 fixtures/conformance/error-enum-value-exceeds-max-length/expected-errors.json create mode 100644 fixtures/conformance/error-enum-value-exceeds-max-length/input/meta.enums.json create mode 100644 fixtures/conformance/error-field-object-flattened-column-prefix-default-storage/expected-errors.json create mode 100644 fixtures/conformance/error-field-object-flattened-column-prefix-default-storage/input/meta.storage.json create mode 100644 fixtures/conformance/error-field-object-flattened-column-prefix-default-storage/providers.json create mode 100644 fixtures/conformance/error-field-object-flattened-column-prefix-without-flattened-storage/expected-errors.json create mode 100644 fixtures/conformance/error-field-object-flattened-column-prefix-without-flattened-storage/input/meta.storage.json create mode 100644 fixtures/conformance/error-field-object-flattened-column-prefix-without-flattened-storage/providers.json create mode 100644 fixtures/conformance/error-identity-sequence-start-out-of-range/expected-errors.json create mode 100644 fixtures/conformance/error-identity-sequence-start-out-of-range/input/meta.seq.json create mode 100644 fixtures/conformance/error-identity-sequence-without-increment/expected-errors.json create mode 100644 fixtures/conformance/error-identity-sequence-without-increment/input/meta.seq.json create mode 100644 fixtures/conformance/error-merge-conflict-flattened-column-prefix-empty/expected-errors.json create mode 100644 fixtures/conformance/error-merge-conflict-flattened-column-prefix-empty/input/meta.a.json create mode 100644 fixtures/conformance/error-merge-conflict-flattened-column-prefix-empty/input/meta.b.json create mode 100644 fixtures/conformance/error-merge-conflict-flattened-column-prefix-empty/providers.json create mode 100644 fixtures/conformance/error-validator-at-least-one-max-unknown/expected-errors.json create mode 100644 fixtures/conformance/error-validator-at-least-one-max-unknown/input/meta.checks.json create mode 100644 fixtures/conformance/error-validator-condition-type/expected-errors.json create mode 100644 fixtures/conformance/error-validator-condition-type/input/meta.checks.json create mode 100644 fixtures/conformance/error-validator-condition-unknown-field/expected-errors.json create mode 100644 fixtures/conformance/error-validator-condition-unknown-field/input/meta.checks.json create mode 100644 fixtures/conformance/error-validator-condition-unknown-fields/expected-errors.json create mode 100644 fixtures/conformance/error-validator-condition-unknown-fields/input/meta.checks.json create mode 100644 fixtures/conformance/error-validator-exactly-one-one-field/expected-errors.json create mode 100644 fixtures/conformance/error-validator-exactly-one-one-field/input/meta.checks.json create mode 100644 fixtures/conformance/field-duration-basic/expected.json create mode 100644 fixtures/conformance/field-duration-basic/input/meta.timers.json create mode 100644 fixtures/conformance/identity-primary-sequence/expected.json create mode 100644 fixtures/conformance/identity-primary-sequence/input/meta.seq.json create mode 100644 fixtures/conformance/validator-constraint-name-condition-exactly-one/expected.json create mode 100644 fixtures/conformance/validator-constraint-name-condition-exactly-one/input/meta.checks.json create mode 100644 fixtures/enum-length-conformance/README.md create mode 100644 fixtures/enum-length-conformance/cases.json create mode 100644 fixtures/enum-length-conformance/meta.enums.json create mode 100644 server/csharp/MetaObjects/Meta/EnumLength.cs create mode 100644 server/java/metadata/src/main/java/com/metaobjects/config/EnumDefaults.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/database/EnumLength.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/field/DurationField.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/field/DurationWire.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/validator/ExactlyOneValidator.java create mode 100644 server/java/metadata/src/test/java/com/metaobjects/database/EnumLengthConformanceTest.java create mode 100644 server/java/metadata/src/test/java/com/metaobjects/field/DurationWireVectorsTest.java create mode 100644 server/typescript/packages/integration-tests/test/load-metadata-config.test.ts create mode 100644 server/typescript/packages/metadata/src/core/field/duration-wire.ts create mode 100644 server/typescript/packages/metadata/src/enum-length.ts create mode 100644 server/typescript/packages/metadata/test/enum-max-length.test.ts create mode 100644 server/typescript/packages/sdk/test/enum-length-conformance.test.ts create mode 100644 server/typescript/packages/sdk/test/schema-conventions-config.test.ts diff --git a/fixtures/conformance/ERROR-CODES.json b/fixtures/conformance/ERROR-CODES.json index 25ae559688..e9ad9cce50 100644 --- a/fixtures/conformance/ERROR-CODES.json +++ b/fixtures/conformance/ERROR-CODES.json @@ -54,6 +54,11 @@ "ERR_TEMPLATE_ENGINE_UNSUPPORTED": "Build-time verify and render (ADR-0061): a template body is written for an engine this port does not ship — a Jinja body (.jinja / .j2 / .jinja2) in the Java, Kotlin or C# port, which render Mustache only. path = the template ref.", "ERR_BAD_ATTR_FILTER": "An attr.filter references a field that is not addressable in that context or uses a disallowed op: a dataGrid @filter over a non-filterable field or an op disallowed for the field's subtype, or a projection row-scope @filter over a non-declared (dangling) or aggregate-derived field (#207). FR-044: a segment / measure.aggregate / object.report @filter names a field its entity (or @from) lacks or an op illegal for the field (S1); a relative-date value { \"now\": \"\" } appears outside the @filter of a segment, measure.aggregate or object.report (F1); or a relative-date value sits on a field that is not field.date/field.timestamp, under an op other than gt/gte/lt/lte, carries a value that is not an ISO-8601 duration, or has a startOf other than day, week or month, or a key besides now and startOf (F2).", "ERR_STORAGE_FLATTENED_ARRAY": "@storage \"flattened\" cannot be combined with isArray=true.", + "ERR_SEQUENCE_START_OUT_OF_RANGE": "An identity.primary @sequenceStart is below 1, or above the largest value its key field can hold (2147483647 for a field.int). Postgres would refuse the sequence when the migration is applied, so the model is refused at load.", + "ERR_SEQUENCE_WITHOUT_INCREMENT": "An identity.primary sets @sequenceStart or @sequenceName, but its @generation is not \"increment\". Only an increment key draws from a sequence, so the attribute would change nothing: the identity is refused rather than silently ignored.", + "ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH": "A string-stored field.enum declares @maxLength (its column length) shorter than one of its @values members, which could not be stored in the column. The longest offending member is named.", + "ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP": "A field.enum sets both @maxLength and @intValueMap. An @intValueMap enum is stored as an integer, so a column length would change nothing: the field is refused rather than silently ignored.", + "ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE": "@flattenedColumnPrefix is set on a field.object whose @storage is not \"flattened\" (jsonb, subdocument, or absent, which is jsonb). Only a flattened value object puts its members' columns in the owning table, so the prefix would change nothing: the field is refused rather than silently ignored.", "ERR_STORAGE_WITHOUT_OBJECT_REF": "@storage was set on a field that has no @objectRef.", "ERR_OBJECT_FIELD_WITHOUT_OBJECT_REF": "ADR-0013: a field.object declares no @objectRef. A field.object models a typed nested value and REQUIRES @objectRef. The message states the whole ladder rather than only the escape hatch: a known key set is an object.value the field @objectRefs; dynamic keys over a known value type are a field.map (@objectRef for a value object, @valueType for a scalar); only a bag no reader pins a key in is @dbColumnType: jsonb on a field.string. Naming the escape hatch alone is what taught adopters to reach for the bag by default.", "ERR_UNRESOLVED_OBJECT_REF": "ADR-0042: a field.object / field.map @objectRef does not resolve to any object in the loaded tree (a dangling target). The ref resolves package-locally when bare (referrer's package, else root-level) and exactly when FQN — a bare cross-package ref no longer binds elsewhere. The error names same-short-name objects in other packages so the author can qualify it.", diff --git a/fixtures/conformance/column-name-flattened-prefixes-ok/expected.json b/fixtures/conformance/column-name-flattened-prefixes-ok/expected.json new file mode 100644 index 0000000000..11a209760f --- /dev/null +++ b/fixtures/conformance/column-name-flattened-prefixes-ok/expected.json @@ -0,0 +1,65 @@ +{ + "metadata.root": { + "package": "test", + "children": [ + { + "object.value": { + "name": "Address", + "children": [ + { + "field.string": { + "name": "street" + } + }, + { + "field.string": { + "name": "city" + } + } + ] + } + }, + { + "object.entity": { + "name": "Order", + "children": [ + { + "source.rdb": { + "@table": "orders" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.object": { + "name": "billTo", + "@flattenedColumnPrefix": "", + "@objectRef": "test::Address", + "@storage": "flattened" + } + }, + { + "field.object": { + "name": "shipTo", + "@flattenedColumnPrefix": "ship_", + "@objectRef": "test::Address", + "@storage": "flattened" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/column-name-flattened-prefixes-ok/input/meta.orders.json b/fixtures/conformance/column-name-flattened-prefixes-ok/input/meta.orders.json new file mode 100644 index 0000000000..9ba446ee6d --- /dev/null +++ b/fixtures/conformance/column-name-flattened-prefixes-ok/input/meta.orders.json @@ -0,0 +1,63 @@ +{ + "metadata.root": { + "package": "test", + "children": [ + { + "object.value": { + "name": "Address", + "children": [ + { + "field.string": { + "name": "street" + } + }, + { + "field.string": { + "name": "city" + } + } + ] + } + }, + { + "object.entity": { + "name": "Order", + "children": [ + { + "source.rdb": { + "@table": "orders" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.object": { + "name": "billTo", + "@objectRef": "test::Address", + "@storage": "flattened", + "@flattenedColumnPrefix": "" + } + }, + { + "field.object": { + "name": "shipTo", + "@objectRef": "test::Address", + "@storage": "flattened", + "@flattenedColumnPrefix": "ship_" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": "id" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/column-name-flattened-prefixes-ok/providers.json b/fixtures/conformance/column-name-flattened-prefixes-ok/providers.json new file mode 100644 index 0000000000..350e327ab5 --- /dev/null +++ b/fixtures/conformance/column-name-flattened-prefixes-ok/providers.json @@ -0,0 +1,4 @@ +[ + "metaobjects-core-types", + "metaobjects-db" +] diff --git a/fixtures/conformance/enum-max-length/expected.json b/fixtures/conformance/enum-max-length/expected.json new file mode 100644 index 0000000000..f48725ddb3 --- /dev/null +++ b/fixtures/conformance/enum-max-length/expected.json @@ -0,0 +1,38 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "Order", + "children": [ + { + "field.long": { + "name": "id" + } + }, + { + "field.enum": { + "name": "status", + "@maxLength": 9, + "@values": [ + "DRAFT", + "PUBLISHED", + "ARCHIVED" + ] + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/enum-max-length/input/meta.enums.json b/fixtures/conformance/enum-max-length/input/meta.enums.json new file mode 100644 index 0000000000..fad79bf83b --- /dev/null +++ b/fixtures/conformance/enum-max-length/input/meta.enums.json @@ -0,0 +1,23 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "Order", + "children": [ + { "field.long": { "name": "id" } }, + { + "field.enum": { + "name": "status", + "@values": ["DRAFT", "PUBLISHED", "ARCHIVED"], + "@maxLength": 9 + } + }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-column-name-collision-flattened-empty-prefix/expected-errors.json b/fixtures/conformance/error-column-name-collision-flattened-empty-prefix/expected-errors.json new file mode 100644 index 0000000000..e3930c61b4 --- /dev/null +++ b/fixtures/conformance/error-column-name-collision-flattened-empty-prefix/expected-errors.json @@ -0,0 +1,25 @@ +{ + "errors": [ + { + "code": "ERR_COLUMN_NAME_COLLISION", + "source": { + "format": "json", + "files": [ + "meta.orders.json" + ], + "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[3]['field.object']" + } + }, + { + "code": "ERR_COLUMN_NAME_COLLISION", + "source": { + "format": "json", + "files": [ + "meta.orders.json" + ], + "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[3]['field.object']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-column-name-collision-flattened-empty-prefix/input/meta.orders.json b/fixtures/conformance/error-column-name-collision-flattened-empty-prefix/input/meta.orders.json new file mode 100644 index 0000000000..fa9c4c31ac --- /dev/null +++ b/fixtures/conformance/error-column-name-collision-flattened-empty-prefix/input/meta.orders.json @@ -0,0 +1,63 @@ +{ + "metadata.root": { + "package": "test", + "children": [ + { + "object.value": { + "name": "Address", + "children": [ + { + "field.string": { + "name": "street" + } + }, + { + "field.string": { + "name": "city" + } + } + ] + } + }, + { + "object.entity": { + "name": "Order", + "children": [ + { + "source.rdb": { + "@table": "orders" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.object": { + "name": "billTo", + "@objectRef": "test::Address", + "@storage": "flattened", + "@flattenedColumnPrefix": "" + } + }, + { + "field.object": { + "name": "shipTo", + "@objectRef": "test::Address", + "@storage": "flattened", + "@flattenedColumnPrefix": "" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": "id" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-column-name-collision-flattened-empty-prefix/providers.json b/fixtures/conformance/error-column-name-collision-flattened-empty-prefix/providers.json new file mode 100644 index 0000000000..350e327ab5 --- /dev/null +++ b/fixtures/conformance/error-column-name-collision-flattened-empty-prefix/providers.json @@ -0,0 +1,4 @@ +[ + "metaobjects-core-types", + "metaobjects-db" +] diff --git a/fixtures/conformance/error-column-name-collision-flattened-member-vs-field/expected-errors.json b/fixtures/conformance/error-column-name-collision-flattened-member-vs-field/expected-errors.json new file mode 100644 index 0000000000..ff210c30a7 --- /dev/null +++ b/fixtures/conformance/error-column-name-collision-flattened-member-vs-field/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_COLUMN_NAME_COLLISION", + "source": { + "format": "json", + "files": [ + "meta.orders.json" + ], + "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[3]['field.object']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-column-name-collision-flattened-member-vs-field/input/meta.orders.json b/fixtures/conformance/error-column-name-collision-flattened-member-vs-field/input/meta.orders.json new file mode 100644 index 0000000000..07cdff7559 --- /dev/null +++ b/fixtures/conformance/error-column-name-collision-flattened-member-vs-field/input/meta.orders.json @@ -0,0 +1,60 @@ +{ + "metadata.root": { + "package": "test", + "children": [ + { + "object.value": { + "name": "Address", + "children": [ + { + "field.string": { + "name": "street" + } + }, + { + "field.string": { + "name": "city" + } + } + ] + } + }, + { + "object.entity": { + "name": "Order", + "children": [ + { + "source.rdb": { + "@table": "orders" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "street" + } + }, + { + "field.object": { + "name": "home", + "@objectRef": "test::Address", + "@storage": "flattened", + "@flattenedColumnPrefix": "" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": "id" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-column-name-collision-flattened-member-vs-field/providers.json b/fixtures/conformance/error-column-name-collision-flattened-member-vs-field/providers.json new file mode 100644 index 0000000000..350e327ab5 --- /dev/null +++ b/fixtures/conformance/error-column-name-collision-flattened-member-vs-field/providers.json @@ -0,0 +1,4 @@ +[ + "metaobjects-core-types", + "metaobjects-db" +] diff --git a/fixtures/conformance/error-enum-max-length-int-value-map/expected-errors.json b/fixtures/conformance/error-enum-max-length-int-value-map/expected-errors.json new file mode 100644 index 0000000000..412a138aba --- /dev/null +++ b/fixtures/conformance/error-enum-max-length-int-value-map/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP", + "source": { + "format": "json", + "files": [ + "meta.enums.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[1]['field.enum']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-enum-max-length-int-value-map/input/meta.enums.json b/fixtures/conformance/error-enum-max-length-int-value-map/input/meta.enums.json new file mode 100644 index 0000000000..b455875959 --- /dev/null +++ b/fixtures/conformance/error-enum-max-length-int-value-map/input/meta.enums.json @@ -0,0 +1,24 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "Order", + "children": [ + { "field.long": { "name": "id" } }, + { + "field.enum": { + "name": "status", + "@values": ["DRAFT", "PUBLISHED"], + "@intValueMap": { "DRAFT": 0, "PUBLISHED": 1 }, + "@maxLength": 9 + } + }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-enum-value-exceeds-max-length-every-field/expected-errors.json b/fixtures/conformance/error-enum-value-exceeds-max-length-every-field/expected-errors.json new file mode 100644 index 0000000000..166e3a509e --- /dev/null +++ b/fixtures/conformance/error-enum-value-exceeds-max-length-every-field/expected-errors.json @@ -0,0 +1,25 @@ +{ + "errors": [ + { + "code": "ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH", + "source": { + "format": "json", + "files": [ + "meta.enums.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[1]['field.enum']" + } + }, + { + "code": "ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH", + "source": { + "format": "json", + "files": [ + "meta.enums.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[2]['field.enum']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-enum-value-exceeds-max-length-every-field/input/meta.enums.json b/fixtures/conformance/error-enum-value-exceeds-max-length-every-field/input/meta.enums.json new file mode 100644 index 0000000000..6069a85876 --- /dev/null +++ b/fixtures/conformance/error-enum-value-exceeds-max-length-every-field/input/meta.enums.json @@ -0,0 +1,18 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "Order", + "children": [ + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "status", "@values": ["DRAFT", "AWAITING_REVIEW"], "@maxLength": 9 } }, + { "field.enum": { "name": "channel", "@values": ["EMAIL", "TELEPHONE"], "@maxLength": 5 } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-enum-value-exceeds-max-length/expected-errors.json b/fixtures/conformance/error-enum-value-exceeds-max-length/expected-errors.json new file mode 100644 index 0000000000..caf054c0c3 --- /dev/null +++ b/fixtures/conformance/error-enum-value-exceeds-max-length/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH", + "source": { + "format": "json", + "files": [ + "meta.enums.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[1]['field.enum']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-enum-value-exceeds-max-length/input/meta.enums.json b/fixtures/conformance/error-enum-value-exceeds-max-length/input/meta.enums.json new file mode 100644 index 0000000000..d6f75adf1f --- /dev/null +++ b/fixtures/conformance/error-enum-value-exceeds-max-length/input/meta.enums.json @@ -0,0 +1,23 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "Order", + "children": [ + { "field.long": { "name": "id" } }, + { + "field.enum": { + "name": "status", + "@values": ["DRAFT", "PUBLISHED", "AWAITING_REVIEW"], + "@maxLength": 9 + } + }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-field-object-flattened-column-prefix-default-storage/expected-errors.json b/fixtures/conformance/error-field-object-flattened-column-prefix-default-storage/expected-errors.json new file mode 100644 index 0000000000..9c0ece68e4 --- /dev/null +++ b/fixtures/conformance/error-field-object-flattened-column-prefix-default-storage/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE", + "source": { + "format": "json", + "files": [ + "meta.storage.json" + ], + "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[1]['field.object']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-field-object-flattened-column-prefix-default-storage/input/meta.storage.json b/fixtures/conformance/error-field-object-flattened-column-prefix-default-storage/input/meta.storage.json new file mode 100644 index 0000000000..098810c27b --- /dev/null +++ b/fixtures/conformance/error-field-object-flattened-column-prefix-default-storage/input/meta.storage.json @@ -0,0 +1,57 @@ +{ + "metadata.root": { + "package": "test", + "children": [ + { + "object.value": { + "name": "Address", + "children": [ + { + "field.string": { + "name": "street" + } + } + ] + } + }, + { + "object.entity": { + "name": "Order", + "children": [ + { + "source.rdb": { + "@table": "orders" + } + }, + { + "field.object": { + "name": "billTo", + "@objectRef": "test::Address", + "@flattenedColumnPrefix": "" + } + }, + { + "field.object": { + "name": "returnTo", + "@objectRef": "test::Address", + "@storage": "flattened", + "@flattenedColumnPrefix": "ret_" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": "id" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-field-object-flattened-column-prefix-default-storage/providers.json b/fixtures/conformance/error-field-object-flattened-column-prefix-default-storage/providers.json new file mode 100644 index 0000000000..f9bb435a37 --- /dev/null +++ b/fixtures/conformance/error-field-object-flattened-column-prefix-default-storage/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-field-object-flattened-column-prefix-without-flattened-storage/expected-errors.json b/fixtures/conformance/error-field-object-flattened-column-prefix-without-flattened-storage/expected-errors.json new file mode 100644 index 0000000000..9c0ece68e4 --- /dev/null +++ b/fixtures/conformance/error-field-object-flattened-column-prefix-without-flattened-storage/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE", + "source": { + "format": "json", + "files": [ + "meta.storage.json" + ], + "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[1]['field.object']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-field-object-flattened-column-prefix-without-flattened-storage/input/meta.storage.json b/fixtures/conformance/error-field-object-flattened-column-prefix-without-flattened-storage/input/meta.storage.json new file mode 100644 index 0000000000..720dae57a1 --- /dev/null +++ b/fixtures/conformance/error-field-object-flattened-column-prefix-without-flattened-storage/input/meta.storage.json @@ -0,0 +1,58 @@ +{ + "metadata.root": { + "package": "test", + "children": [ + { + "object.value": { + "name": "Address", + "children": [ + { + "field.string": { + "name": "street" + } + } + ] + } + }, + { + "object.entity": { + "name": "Order", + "children": [ + { + "source.rdb": { + "@table": "orders" + } + }, + { + "field.object": { + "name": "shipTo", + "@objectRef": "test::Address", + "@storage": "jsonb", + "@flattenedColumnPrefix": "ship_" + } + }, + { + "field.object": { + "name": "returnTo", + "@objectRef": "test::Address", + "@storage": "flattened", + "@flattenedColumnPrefix": "ret_" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": "id" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-field-object-flattened-column-prefix-without-flattened-storage/providers.json b/fixtures/conformance/error-field-object-flattened-column-prefix-without-flattened-storage/providers.json new file mode 100644 index 0000000000..f9bb435a37 --- /dev/null +++ b/fixtures/conformance/error-field-object-flattened-column-prefix-without-flattened-storage/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/error-identity-sequence-start-out-of-range/expected-errors.json b/fixtures/conformance/error-identity-sequence-start-out-of-range/expected-errors.json new file mode 100644 index 0000000000..6479c9d69c --- /dev/null +++ b/fixtures/conformance/error-identity-sequence-start-out-of-range/expected-errors.json @@ -0,0 +1,25 @@ +{ + "errors": [ + { + "code": "ERR_SEQUENCE_START_OUT_OF_RANGE", + "source": { + "format": "json", + "files": [ + "meta.seq.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[2]['identity.primary']" + } + }, + { + "code": "ERR_SEQUENCE_START_OUT_OF_RANGE", + "source": { + "format": "json", + "files": [ + "meta.seq.json" + ], + "jsonPath": "$['metadata.root'].children[1]['object.entity'].children[2]['identity.primary']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-identity-sequence-start-out-of-range/input/meta.seq.json b/fixtures/conformance/error-identity-sequence-start-out-of-range/input/meta.seq.json new file mode 100644 index 0000000000..276ebac20b --- /dev/null +++ b/fixtures/conformance/error-identity-sequence-start-out-of-range/input/meta.seq.json @@ -0,0 +1,53 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "Small", + "children": [ + { + "source.rdb": {} + }, + { + "field.int": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": "id", + "@generation": "increment", + "@sequenceStart": 3000000000 + } + } + ] + } + }, + { + "object.entity": { + "name": "Zero", + "children": [ + { + "source.rdb": {} + }, + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": "id", + "@generation": "increment", + "@sequenceStart": 0 + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-identity-sequence-without-increment/expected-errors.json b/fixtures/conformance/error-identity-sequence-without-increment/expected-errors.json new file mode 100644 index 0000000000..57289aed65 --- /dev/null +++ b/fixtures/conformance/error-identity-sequence-without-increment/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_SEQUENCE_WITHOUT_INCREMENT", + "source": { + "format": "json", + "files": [ + "meta.seq.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[2]['identity.primary']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-identity-sequence-without-increment/input/meta.seq.json b/fixtures/conformance/error-identity-sequence-without-increment/input/meta.seq.json new file mode 100644 index 0000000000..53ce09e514 --- /dev/null +++ b/fixtures/conformance/error-identity-sequence-without-increment/input/meta.seq.json @@ -0,0 +1,32 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "Ticket", + "children": [ + { + "source.rdb": { + "@table": "tickets" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": "id", + "@generation": "assigned", + "@sequenceStart": 1000000000 + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-merge-conflict-flattened-column-prefix-empty/expected-errors.json b/fixtures/conformance/error-merge-conflict-flattened-column-prefix-empty/expected-errors.json new file mode 100644 index 0000000000..5de76c39a7 --- /dev/null +++ b/fixtures/conformance/error-merge-conflict-flattened-column-prefix-empty/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_MERGE_CONFLICT", + "source": { + "format": "merged", + "files": [ + "meta.a.json", + "meta.b.json" + ] + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-merge-conflict-flattened-column-prefix-empty/input/meta.a.json b/fixtures/conformance/error-merge-conflict-flattened-column-prefix-empty/input/meta.a.json new file mode 100644 index 0000000000..3b999009c4 --- /dev/null +++ b/fixtures/conformance/error-merge-conflict-flattened-column-prefix-empty/input/meta.a.json @@ -0,0 +1,55 @@ +{ + "metadata.root": { + "package": "test", + "children": [ + { + "object.value": { + "name": "Address", + "children": [ + { + "field.string": { + "name": "street" + } + }, + { + "field.string": { + "name": "city" + } + } + ] + } + }, + { + "object.entity": { + "name": "Order", + "children": [ + { + "source.rdb": { + "@table": "orders" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.object": { + "name": "home", + "@objectRef": "test::Address", + "@storage": "flattened", + "@flattenedColumnPrefix": "" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": "id" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-merge-conflict-flattened-column-prefix-empty/input/meta.b.json b/fixtures/conformance/error-merge-conflict-flattened-column-prefix-empty/input/meta.b.json new file mode 100644 index 0000000000..3c69b752da --- /dev/null +++ b/fixtures/conformance/error-merge-conflict-flattened-column-prefix-empty/input/meta.b.json @@ -0,0 +1,22 @@ +{ + "metadata.root": { + "package": "test", + "children": [ + { + "object.entity": { + "name": "Order", + "children": [ + { + "field.object": { + "name": "home", + "@objectRef": "test::Address", + "@storage": "flattened", + "@flattenedColumnPrefix": "home_" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-merge-conflict-flattened-column-prefix-empty/providers.json b/fixtures/conformance/error-merge-conflict-flattened-column-prefix-empty/providers.json new file mode 100644 index 0000000000..350e327ab5 --- /dev/null +++ b/fixtures/conformance/error-merge-conflict-flattened-column-prefix-empty/providers.json @@ -0,0 +1,4 @@ +[ + "metaobjects-core-types", + "metaobjects-db" +] diff --git a/fixtures/conformance/error-validator-at-least-one-max-unknown/expected-errors.json b/fixtures/conformance/error-validator-at-least-one-max-unknown/expected-errors.json new file mode 100644 index 0000000000..4bb7627e76 --- /dev/null +++ b/fixtures/conformance/error-validator-at-least-one-max-unknown/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_UNKNOWN_ATTR", + "source": { + "format": "json", + "files": [ + "meta.checks.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[7]['validator.atLeastOne']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-validator-at-least-one-max-unknown/input/meta.checks.json b/fixtures/conformance/error-validator-at-least-one-max-unknown/input/meta.checks.json new file mode 100644 index 0000000000..04158bd523 --- /dev/null +++ b/fixtures/conformance/error-validator-at-least-one-max-unknown/input/meta.checks.json @@ -0,0 +1,62 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "Visit", + "children": [ + { + "source.rdb": {} + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "storeId" + } + }, + { + "field.long": { + "name": "kioskId" + } + }, + { + "field.boolean": { + "name": "active" + } + }, + { + "field.enum": { + "name": "kind", + "@values": [ + "Owned", + "Shared" + ] + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": "id" + } + }, + { + "validator.atLeastOne": { + "name": "none", + "@fields": [ + "storeId", + "kioskId" + ], + "@max": 0 + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-validator-condition-type/expected-errors.json b/fixtures/conformance/error-validator-condition-type/expected-errors.json new file mode 100644 index 0000000000..1d444732f3 --- /dev/null +++ b/fixtures/conformance/error-validator-condition-type/expected-errors.json @@ -0,0 +1,55 @@ +{ + "errors": [ + { + "code": "ERR_BAD_ATTR_VALUE", + "source": { + "format": "json", + "files": [ + "meta.checks.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[7]['validator.atLeastOne']" + } + }, + { + "code": "ERR_BAD_ATTR_VALUE", + "source": { + "format": "json", + "files": [ + "meta.checks.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[8]['validator.atLeastOne']" + } + }, + { + "code": "ERR_BAD_ATTR_VALUE", + "source": { + "format": "json", + "files": [ + "meta.checks.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[9]['validator.atLeastOne']" + } + }, + { + "code": "ERR_BAD_ATTR_VALUE", + "source": { + "format": "json", + "files": [ + "meta.checks.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[10]['validator.atLeastOne']" + } + }, + { + "code": "ERR_BAD_ATTR_VALUE", + "source": { + "format": "json", + "files": [ + "meta.checks.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[11]['validator.atLeastOne']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-validator-condition-type/input/meta.checks.json b/fixtures/conformance/error-validator-condition-type/input/meta.checks.json new file mode 100644 index 0000000000..d900247e36 --- /dev/null +++ b/fixtures/conformance/error-validator-condition-type/input/meta.checks.json @@ -0,0 +1,136 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "Visit", + "children": [ + { + "source.rdb": {} + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "storeId" + } + }, + { + "field.long": { + "name": "kioskId" + } + }, + { + "field.boolean": { + "name": "active" + } + }, + { + "field.enum": { + "name": "kind", + "@values": [ + "Owned", + "Shared" + ] + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": "id" + } + }, + { + "validator.atLeastOne": { + "name": "notBoolean", + "@fields": [ + "storeId", + "kioskId" + ], + "@condition": { + "field": "storeId" + } + } + }, + { + "validator.atLeastOne": { + "name": "booleanAsString", + "@fields": [ + "storeId", + "kioskId" + ], + "@condition": { + "op": "eq", + "left": { + "field": "active" + }, + "right": { + "value": "true" + } + } + } + }, + { + "validator.atLeastOne": { + "name": "nullLiteral", + "@fields": [ + "storeId", + "kioskId" + ], + "@condition": { + "op": "ne", + "left": { + "field": "storeId" + }, + "right": { + "value": null + } + } + } + }, + { + "validator.atLeastOne": { + "name": "notAMember", + "@fields": [ + "storeId", + "kioskId" + ], + "@condition": { + "op": "eq", + "left": { + "field": "kind" + }, + "right": { + "value": "Nope" + } + } + } + }, + { + "validator.atLeastOne": { + "name": "numberAsString", + "@fields": [ + "storeId", + "kioskId" + ], + "@condition": { + "op": "eq", + "left": { + "value": "5" + }, + "right": { + "field": "storeId" + } + } + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-validator-condition-unknown-field/expected-errors.json b/fixtures/conformance/error-validator-condition-unknown-field/expected-errors.json new file mode 100644 index 0000000000..a9b446bf90 --- /dev/null +++ b/fixtures/conformance/error-validator-condition-unknown-field/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_BAD_ATTR_VALUE", + "source": { + "format": "json", + "files": [ + "meta.checks.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[5]['validator.atLeastOne']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-validator-condition-unknown-field/input/meta.checks.json b/fixtures/conformance/error-validator-condition-unknown-field/input/meta.checks.json new file mode 100644 index 0000000000..d5c768ea92 --- /dev/null +++ b/fixtures/conformance/error-validator-condition-unknown-field/input/meta.checks.json @@ -0,0 +1,53 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "Visit", + "children": [ + { + "source.rdb": {} + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "storeId" + } + }, + { + "field.long": { + "name": "kioskId" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": "id" + } + }, + { + "validator.atLeastOne": { + "name": "oneSource", + "@fields": [ + "storeId", + "kioskId" + ], + "@condition": { + "op": "isNotNull", + "arg": { + "field": "nosuch" + } + } + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-validator-condition-unknown-fields/expected-errors.json b/fixtures/conformance/error-validator-condition-unknown-fields/expected-errors.json new file mode 100644 index 0000000000..d0e328844e --- /dev/null +++ b/fixtures/conformance/error-validator-condition-unknown-fields/expected-errors.json @@ -0,0 +1,35 @@ +{ + "errors": [ + { + "code": "ERR_BAD_ATTR_VALUE", + "source": { + "format": "json", + "files": [ + "meta.checks.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[7]['validator.requiredWhen']" + } + }, + { + "code": "ERR_BAD_ATTR_VALUE", + "source": { + "format": "json", + "files": [ + "meta.checks.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[8]['validator.presentIff']" + } + }, + { + "code": "ERR_BAD_ATTR_VALUE", + "source": { + "format": "json", + "files": [ + "meta.checks.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[9]['validator.comparison']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-validator-condition-unknown-fields/input/meta.checks.json b/fixtures/conformance/error-validator-condition-unknown-fields/input/meta.checks.json new file mode 100644 index 0000000000..b6e333729a --- /dev/null +++ b/fixtures/conformance/error-validator-condition-unknown-fields/input/meta.checks.json @@ -0,0 +1,105 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "Visit", + "children": [ + { + "source.rdb": {} + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "storeId" + } + }, + { + "field.long": { + "name": "kioskId" + } + }, + { + "field.boolean": { + "name": "active" + } + }, + { + "field.enum": { + "name": "kind", + "@values": [ + "Owned", + "Shared" + ] + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": "id" + } + }, + { + "validator.requiredWhen": { + "name": "rw", + "@field": "storeId", + "@when": "kind", + "@equals": "Owned", + "@condition": { + "op": "and", + "args": [ + { + "op": "isNull", + "arg": { + "field": "nosuch" + } + }, + { + "op": "isNull", + "arg": { + "field": "other" + } + } + ] + } + } + }, + { + "validator.presentIff": { + "name": "pi", + "@field": "kioskId", + "@when": "kind", + "@equals": "Shared", + "@condition": { + "op": "isNotNull", + "arg": { + "field": "gone" + } + } + } + }, + { + "validator.comparison": { + "name": "cmp", + "@left": "storeId", + "@op": "lt", + "@right": "kioskId", + "@condition": { + "op": "isNotNull", + "arg": { + "field": "missing" + } + } + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/error-validator-exactly-one-one-field/expected-errors.json b/fixtures/conformance/error-validator-exactly-one-one-field/expected-errors.json new file mode 100644 index 0000000000..4fbd98187a --- /dev/null +++ b/fixtures/conformance/error-validator-exactly-one-one-field/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_BAD_ATTR_VALUE", + "source": { + "format": "json", + "files": [ + "meta.checks.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[5]['validator.exactlyOne']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-validator-exactly-one-one-field/input/meta.checks.json b/fixtures/conformance/error-validator-exactly-one-one-field/input/meta.checks.json new file mode 100644 index 0000000000..eabd77def0 --- /dev/null +++ b/fixtures/conformance/error-validator-exactly-one-one-field/input/meta.checks.json @@ -0,0 +1,46 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "Visit", + "children": [ + { + "source.rdb": {} + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "storeId" + } + }, + { + "field.long": { + "name": "kioskId" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": "id" + } + }, + { + "validator.exactlyOne": { + "name": "oneSource", + "@fields": [ + "storeId" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/field-duration-basic/expected.json b/fixtures/conformance/field-duration-basic/expected.json new file mode 100644 index 0000000000..f2873e60d0 --- /dev/null +++ b/fixtures/conformance/field-duration-basic/expected.json @@ -0,0 +1,50 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "Timer", + "children": [ + { + "source.rdb": { + "@table": "timers" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.duration": { + "name": "timeout", + "@required": true + } + }, + { + "field.duration": { + "name": "grace" + } + }, + { + "field.duration": { + "name": "backoff", + "isArray": true + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ], + "@generation": "increment" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/field-duration-basic/input/meta.timers.json b/fixtures/conformance/field-duration-basic/input/meta.timers.json new file mode 100644 index 0000000000..f2270b4812 --- /dev/null +++ b/fixtures/conformance/field-duration-basic/input/meta.timers.json @@ -0,0 +1,20 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "Timer", + "children": [ + { "source.rdb": { "@table": "timers" } }, + { "field.long": { "name": "id" } }, + { "field.duration": { "name": "timeout", "@required": true } }, + { "field.duration": { "name": "grace" } }, + { "field.duration": { "name": "backoff", "isArray": true } }, + { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "increment" } } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/filter-ops-matrix/input/meta.matrix.json b/fixtures/conformance/filter-ops-matrix/input/meta.matrix.json index e305a32cfd..79eeccf8be 100644 --- a/fixtures/conformance/filter-ops-matrix/input/meta.matrix.json +++ b/fixtures/conformance/filter-ops-matrix/input/meta.matrix.json @@ -17,10 +17,13 @@ { "field.boolean": { "name": "fBoolean", "@filterable": true } }, { "field.date": { "name": "fDate", "@filterable": true } }, { "field.time": { "name": "fTime", "@filterable": true } }, + { "field.duration": { "name": "fDuration", "@filterable": true } }, { "field.timestamp": { "name": "fTimestamp", "@filterable": true } }, { "field.enum": { "name": "fEnum", "@values": ["a", "b", "c"], "@filterable": true } }, { "field.enum": { "name": "fEnumInt", "@values": ["a", "b", "c"], "@intValueMap": { "a": 0, "b": 1, "c": 2 }, "@filterable": true } }, { "field.uuid": { "name": "fUuid", "@filterable": true } }, + { "field.uri": { "name": "fUri", "@filterable": true } }, + { "field.inet": { "name": "fInet", "@filterable": true } }, { "source.rdb": { "@table": "matrix" } }, { "identity.primary": { "name": "pk", "@fields": ["id"] } }, { "identity.secondary": { "name": "byString", "@fields": ["fString"] } }, diff --git a/fixtures/conformance/filter-ops-matrix/script.json b/fixtures/conformance/filter-ops-matrix/script.json index acd7fe8414..e3aa56bf4c 100644 --- a/fixtures/conformance/filter-ops-matrix/script.json +++ b/fixtures/conformance/filter-ops-matrix/script.json @@ -12,6 +12,12 @@ { "navigate": ["object:Matrix", "field:fUuid"], "invoke": "field.filter-ops", "expect": { "names": ["eq", "ne", "in", "isNull"] } }, + { "navigate": ["object:Matrix", "field:fUri"], + "invoke": "field.filter-ops", + "expect": { "names": ["eq", "ne", "in", "like", "isNull"] } }, + { "navigate": ["object:Matrix", "field:fInet"], + "invoke": "field.filter-ops", + "expect": { "names": ["eq", "ne", "in", "isNull"] } }, { "navigate": ["object:Matrix", "field:fInt"], "invoke": "field.filter-ops", "expect": { "names": ["eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull"] } }, @@ -36,6 +42,9 @@ { "navigate": ["object:Matrix", "field:fTime"], "invoke": "field.filter-ops", "expect": { "names": ["eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull"] } }, + { "navigate": ["object:Matrix", "field:fDuration"], + "invoke": "field.filter-ops", + "expect": { "names": ["eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull"] } }, { "navigate": ["object:Matrix", "field:fTimestamp"], "invoke": "field.filter-ops", "expect": { "names": ["eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull"] } }, diff --git a/fixtures/conformance/identity-primary-sequence/expected.json b/fixtures/conformance/identity-primary-sequence/expected.json new file mode 100644 index 0000000000..2819942a8a --- /dev/null +++ b/fixtures/conformance/identity-primary-sequence/expected.json @@ -0,0 +1,35 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "Ticket", + "children": [ + { + "source.rdb": { + "@table": "tickets" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ], + "@generation": "increment", + "@sequenceName": "Ticket_Id_seq", + "@sequenceStart": 1000000000 + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/identity-primary-sequence/input/meta.seq.json b/fixtures/conformance/identity-primary-sequence/input/meta.seq.json new file mode 100644 index 0000000000..86a6973911 --- /dev/null +++ b/fixtures/conformance/identity-primary-sequence/input/meta.seq.json @@ -0,0 +1,25 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "Ticket", + "children": [ + { "source.rdb": { "@table": "tickets" } }, + { "field.long": { "name": "id" } }, + { + "identity.primary": { + "name": "pk", + "@fields": "id", + "@generation": "increment", + "@sequenceStart": 1000000000, + "@sequenceName": "Ticket_Id_seq" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/validator-constraint-name-condition-exactly-one/expected.json b/fixtures/conformance/validator-constraint-name-condition-exactly-one/expected.json new file mode 100644 index 0000000000..11eb6910f8 --- /dev/null +++ b/fixtures/conformance/validator-constraint-name-condition-exactly-one/expected.json @@ -0,0 +1,151 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "StoreHours", + "children": [ + { + "source.rdb": { + "@table": "StoreHours" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.enum": { + "name": "kind", + "@constraintName": "CK_StoreHours_Kind", + "@values": [ + "InStore", + "Phone" + ] + } + }, + { + "field.int": { + "name": "startDay", + "children": [ + { + "validator.numeric": { + "name": "weekday", + "@constraintName": "CK_StoreHours_StartDay", + "@max": 6, + "@min": 0 + } + } + ] + } + }, + { + "field.int": { + "name": "endDay" + } + }, + { + "field.time": { + "name": "startTime" + } + }, + { + "field.time": { + "name": "endTime" + } + }, + { + "field.string": { + "name": "code", + "children": [ + { + "validator.length": { + "name": "len", + "@constraintName": "CK_StoreHours_Code", + "@max": 8 + } + }, + { + "validator.regex": { + "name": "shape", + "@constraintName": "CK_StoreHours_CodeShape", + "@pattern": "^[A-Z0-9]+$" + } + } + ] + } + }, + { + "field.long": { + "name": "storeId" + } + }, + { + "field.long": { + "name": "kioskId" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ], + "@generation": "increment" + } + }, + { + "validator.comparison": { + "name": "sameDayTimes", + "@condition": { + "op": "eq", + "left": { + "field": "startDay" + }, + "right": { + "field": "endDay" + } + }, + "@constraintName": "CK_StoreHours_SameDayTimes", + "@left": "endTime", + "@op": "gt", + "@right": "startTime" + } + }, + { + "validator.exactlyOne": { + "name": "oneSource", + "@condition": { + "op": "eq", + "left": { + "field": "kind" + }, + "right": { + "value": "InStore" + } + }, + "@constraintName": "CK_StoreHours_ExactlyOneLocation", + "@fields": [ + "storeId", + "kioskId" + ] + } + }, + { + "validator.atLeastOne": { + "name": "anyTime", + "@constraintName": "CK_StoreHours_AnyTime", + "@fields": [ + "startTime", + "endTime" + ] + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/validator-constraint-name-condition-exactly-one/input/meta.checks.json b/fixtures/conformance/validator-constraint-name-condition-exactly-one/input/meta.checks.json new file mode 100644 index 0000000000..54722be071 --- /dev/null +++ b/fixtures/conformance/validator-constraint-name-condition-exactly-one/input/meta.checks.json @@ -0,0 +1,149 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "StoreHours", + "children": [ + { + "source.rdb": { + "@table": "StoreHours" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.enum": { + "name": "kind", + "@values": [ + "InStore", + "Phone" + ], + "@constraintName": "CK_StoreHours_Kind" + } + }, + { + "field.int": { + "name": "startDay", + "children": [ + { + "validator.numeric": { + "name": "weekday", + "@min": 0, + "@max": 6, + "@constraintName": "CK_StoreHours_StartDay" + } + } + ] + } + }, + { + "field.int": { + "name": "endDay" + } + }, + { + "field.time": { + "name": "startTime" + } + }, + { + "field.time": { + "name": "endTime" + } + }, + { + "field.string": { + "name": "code", + "children": [ + { + "validator.length": { + "name": "len", + "@max": 8, + "@constraintName": "CK_StoreHours_Code" + } + }, + { + "validator.regex": { + "name": "shape", + "@pattern": "^[A-Z0-9]+$", + "@constraintName": "CK_StoreHours_CodeShape" + } + } + ] + } + }, + { + "field.long": { + "name": "storeId" + } + }, + { + "field.long": { + "name": "kioskId" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": "id", + "@generation": "increment" + } + }, + { + "validator.comparison": { + "name": "sameDayTimes", + "@left": "endTime", + "@op": "gt", + "@right": "startTime", + "@constraintName": "CK_StoreHours_SameDayTimes", + "@condition": { + "op": "eq", + "left": { + "field": "startDay" + }, + "right": { + "field": "endDay" + } + } + } + }, + { + "validator.exactlyOne": { + "name": "oneSource", + "@fields": [ + "storeId", + "kioskId" + ], + "@constraintName": "CK_StoreHours_ExactlyOneLocation", + "@condition": { + "op": "eq", + "left": { + "field": "kind" + }, + "right": { + "value": "InStore" + } + } + } + }, + { + "validator.atLeastOne": { + "name": "anyTime", + "@fields": [ + "startTime", + "endTime" + ], + "@constraintName": "CK_StoreHours_AnyTime" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/enum-length-conformance/README.md b/fixtures/enum-length-conformance/README.md new file mode 100644 index 0000000000..593a0b2862 --- /dev/null +++ b/fixtures/enum-length-conformance/README.md @@ -0,0 +1,30 @@ +# Enum-length conformance + +A string-stored `field.enum` is a `VARCHAR(n)` column when it has a length: its own `@maxLength`, +else the project's default, `"enums": { "maxLength": n }` in `.metaobjects/config.json`. With +neither it stays an unbounded text column, as before. An `@intValueMap` enum is an integer +column and takes no length. Every member must fit: the loader refuses an `@maxLength` a member +is longer than (`ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH`, pinned in `fixtures/conformance/`), and the +core resolver refuses a member longer than the project default, so every consumer that sizes +the column refuses the same model. The `@maxLength` refusals are metamodel rules every port's +loader applies; the corpus here is for a port that sizes the column. + +[`cases.json`](./cases.json) holds the `enums` blocks a port must accept, with the length +each enum in [`meta.enums.json`](./meta.enums.json) resolves to under them, the overflow a +port must refuse, and the blocks a port must refuse when it reads the file. A whole +number is accepted however JSON spells it (`32`, `32.0`, `1e2`), because a JSON reader that +parses numbers as doubles cannot tell those apart. Each port that sizes the column runs it +through its own config reader and core resolver: + +| Port | Test | +|---|---| +| TypeScript | `server/typescript/packages/sdk/test/enum-length-conformance.test.ts` | +| C# | `server/csharp/MetaObjects.Codegen.Tests/EnumLengthConformanceTests.cs` | +| Java / Kotlin | `server/java/metadata/src/test/java/com/metaobjects/database/EnumLengthConformanceTest.java` | + +Python does not run it: no Python generator or run-time path emits a column type or DDL, so +nothing there has a column to size. Its config reader still accepts an `enums` block (a project +shared with another port has one) and refuses a malformed one, and reads nothing from it. + +The column each port's bindings emit is described in +[`docs/features/field-types.md`](../../docs/features/field-types.md). diff --git a/fixtures/enum-length-conformance/cases.json b/fixtures/enum-length-conformance/cases.json new file mode 100644 index 0000000000..203a457ce4 --- /dev/null +++ b/fixtures/enum-length-conformance/cases.json @@ -0,0 +1,45 @@ +{ + "_comment": "The column length of a string-stored field.enum, per port. Each case is the `enums` block of a project's .metaobjects/config.json (null: no block). Each port reads it with its own config reader, puts it on the loaded meta.enums.json root, and resolves every Ticket enum's length through its core resolver (resolveEnumMaxLength and its per-port equivalents). null in `lengths` means no length: an unbounded text column, or for an array enum a text[] column, whose elements carry no length (so an array enum's members are never checked against the default). `overflow` cases must be refused when the named field's length is resolved, with ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH. Each `invalid` block must be refused when the config is read.", + "cases": [ + { + "name": "no enums block: only an explicit @maxLength sizes a column", + "enums": null, + "lengths": { "status": null, "channel": 20, "priority": 8, "rank": null, "tags": null } + }, + { + "name": "an empty block is the same as none", + "enums": {}, + "lengths": { "status": null, "channel": 20, "priority": 8, "rank": null, "tags": null } + }, + { + "name": "the project default sizes every enum without its own @maxLength", + "enums": { "maxLength": 32 }, + "lengths": { "status": 32, "channel": 20, "priority": 8, "rank": null, "tags": null } + }, + { + "name": "an integer written with a decimal point is that integer", + "enums": { "maxLength": 32.0 }, + "lengths": { "status": 32, "channel": 20, "priority": 8, "rank": null, "tags": null } + }, + { + "name": "an integer written with an exponent is that integer", + "enums": { "maxLength": 1e2 }, + "lengths": { "status": 100, "channel": 20, "priority": 8, "rank": null, "tags": null } + } + ], + "overflow": [ + { + "name": "a member longer than the project default is refused", + "enums": { "maxLength": 10 }, + "field": "status", + "member": "IN_PROGRESS" + } + ], + "invalid": [ + { "name": "maxLength is a positive integer, not zero", "enums": { "maxLength": 0 } }, + { "name": "maxLength is an integer", "enums": { "maxLength": 12.5 } }, + { "name": "maxLength is a number, not a string", "enums": { "maxLength": "32" } }, + { "name": "an unknown key is refused", "enums": { "length": 32 } }, + { "name": "enums is an object", "enums": 32 } + ] +} diff --git a/fixtures/enum-length-conformance/meta.enums.json b/fixtures/enum-length-conformance/meta.enums.json new file mode 100644 index 0000000000..6df2d37984 --- /dev/null +++ b/fixtures/enum-length-conformance/meta.enums.json @@ -0,0 +1,30 @@ +{ + "metadata.root": { + "package": "acme::enums", + "children": [ + { + "field.enum": { + "name": "Priority", + "abstract": true, + "@values": ["LOW", "HIGH"], + "@maxLength": 8 + } + }, + { + "object.entity": { + "name": "Ticket", + "children": [ + { "source.rdb": {} }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "status", "@values": ["OPEN", "IN_PROGRESS", "CLOSED"] } }, + { "field.enum": { "name": "channel", "@values": ["EMAIL", "PHONE"], "@maxLength": 20 } }, + { "field.enum": { "name": "priority", "extends": "acme::enums::Priority" } }, + { "field.enum": { "name": "rank", "@values": ["A", "B"], "@intValueMap": { "A": 1, "B": 2 } } }, + { "field.enum": { "name": "tags", "isArray": true, "@values": ["URGENT", "FOLLOW_UP_NEEDED"] } }, + { "identity.primary": { "name": "pk", "@fields": "id" } } + ] + } + } + ] + } +} diff --git a/fixtures/metamodel-docs/expected/INDEX.md b/fixtures/metamodel-docs/expected/INDEX.md index 46ff01d311..1d5bb0f58f 100644 --- a/fixtures/metamodel-docs/expected/INDEX.md +++ b/fixtures/metamodel-docs/expected/INDEX.md @@ -34,6 +34,7 @@ children and the cardinality of that subtype. Documentation attributes | `field.date` | Calendar date (no time-of-day). Binds to the native date/temporal type; DB column is DATE. | [types/field.md#fielddate](types/field.md#fielddate) | | `field.decimal` | Precision-exact decimal (use @precision/@scale). Native TS binding is string (lossless); DB column is NUMERIC(p,s); the wire form is a string. Classified DATA_TYPE_STRING so an exact decimal is never silently rounded through a double. | [types/field.md#fielddecimal](types/field.md#fielddecimal) | | `field.double` | Double-precision (64-bit) IEEE-754 floating point. Binds to the native double/number type; DB column is DOUBLE PRECISION. Not for money — use field.currency or field.decimal. | [types/field.md#fielddouble](types/field.md#fielddouble) | +| `field.duration` | A fixed-length span of time: days, hours, minutes and seconds (ADR-0036/0037 — a concept with a native type + behavior). Binds to TS string (TS has no native duration type); other ports bind to their native type (C# System.TimeSpan, Java and Kotlin java.time.Duration, Python datetime.timedelta). DB column is the Postgres-native interval type; SQLite, D1 and MySQL store a 64-bit integer count of microseconds, which orders and compares as the span does. The wire form is an ISO-8601 duration: every port writes [-]PT[nH][nM][n[.f]S] (hours never folded into days, as many fractional digits as its native type holds, zero is PT0S) and reads that form plus leading week and day counts (nW, exactly 7 days; nD, exactly 24 hours) and a fraction of any length, truncated toward zero. Years and months are refused: a calendar span is not a fixed length. Justified in ADR-0023 Amendment 1. | [types/field.md#fieldduration](types/field.md#fieldduration) | | `field.enum` | String-backed enumeration constrained to a closed set of member symbols (@values). Each member is its own stored string with no name/value divergence. | [types/field.md#fieldenum](types/field.md#fieldenum) | | `field.float` | Single-precision floating point. Binds to the native double/number type (TS has no distinct float); DB column is REAL. Not for money. | [types/field.md#fieldfloat](types/field.md#fieldfloat) | | `field.inet` | An IP-address string (IPv4 or IPv6) (ADR-0036/0037 — a concept with a native type + behavior, so a subtype not a @stringFormat). Binds to TS string (TS has no native IP type, same as field.uuid); other ports bind to their native IP type (Java java.net.InetAddress, C# System.Net.IPAddress, Python ipaddress, Kotlin java.net.InetAddress). DB column is the Postgres-native inet type. Codegen emits an IPv4-or-IPv6 literal validator (a Zod regex union in TS — z.string().ip() was removed in Zod 4 — native IP-type parsing in the other ports); add @lenient to bind a plain string with no validator. v1 ships WITHOUT @kind — the native type subsumes ipv4/ipv6; @kind is reserved for a later ipv4\|ipv6 value-constraint. | [types/field.md#fieldinet](types/field.md#fieldinet) | @@ -82,6 +83,7 @@ children and the cardinality of that subtype. Documentation attributes | `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. Not authored directly: a `validator.base` node fails to load (ERR_ABSTRACT_SUBTYPE_AUTHORED) — this subtype is a registry anchor concrete subtypes inherit from, never a node in a document. | [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.exactlyOne` | Cardinality of presence: exactly one of the named fields (@fields) is present (NOT NULL), and the others are null. Entity-scoped; references fields by name (same @fields-by-name pattern as identity.*). Justified in ADR-0023 Amendment 1. | [types/validator.md#validatorexactlyone](types/validator.md#validatorexactlyone) | | `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) | diff --git a/fixtures/metamodel-docs/expected/providers.md b/fixtures/metamodel-docs/expected/providers.md index ee43c8bddc..3e7f4ec5a2 100644 --- a/fixtures/metamodel-docs/expected/providers.md +++ b/fixtures/metamodel-docs/expected/providers.md @@ -13,14 +13,14 @@ 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.expression`, `attr.filter`, `attr.int`, `attr.intMap`, `attr.long`, `attr.properties`, `attr.string`, `field.base`, `field.boolean`, `field.currency`, `field.date`, `field.decimal`, `field.double`, `field.enum`, `field.float`, `field.inet`, `field.int`, `field.long`, `field.map`, `field.object`, `field.string`, `field.time`, `field.timestamp`, `field.uri`, `field.uuid`, `identity.primary`, `identity.reference`, `identity.secondary`, `layout.base`, `layout.dataGrid`, `object.base`, `object.entity`, `object.projection`, `object.report`, `object.value`, `origin.aggregate`, `origin.base`, `origin.computed`, `origin.first`, `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` +**Owns (registers):** `attr.base`, `attr.boolean`, `attr.class`, `attr.double`, `attr.expression`, `attr.filter`, `attr.int`, `attr.intMap`, `attr.long`, `attr.properties`, `attr.string`, `field.base`, `field.boolean`, `field.currency`, `field.date`, `field.decimal`, `field.double`, `field.duration`, `field.enum`, `field.float`, `field.inet`, `field.int`, `field.long`, `field.map`, `field.object`, `field.string`, `field.time`, `field.timestamp`, `field.uri`, `field.uuid`, `identity.primary`, `identity.reference`, `identity.secondary`, `layout.base`, `layout.dataGrid`, `object.base`, `object.entity`, `object.projection`, `object.report`, `object.value`, `origin.aggregate`, `origin.base`, `origin.computed`, `origin.first`, `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.exactlyOne`, `validator.length`, `validator.numeric`, `validator.presentIff`, `validator.regex`, `validator.required`, `validator.requiredWhen`, `view.base`, `view.currency` **Contributes attributes:** - `field.base`: `@default`, `@mutability`, `@required`, `@unique` - `field.currency`: `@currency` - `field.decimal`: `@precision`, `@scale` -- `field.enum`: `@intValueMap`, `@provided`, `@values` +- `field.enum`: `@intValueMap`, `@maxLength`, `@provided`, `@values` - `field.inet`: `@lenient` - `field.map`: `@objectRef`, `@valueType` - `field.object`: `@objectRef` @@ -44,14 +44,15 @@ Core metaobjects metamodel types and subtypes. - `template.prompt`: `@format`, `@maxChars`, `@maxTokens`, `@model`, `@owner`, `@payloadRef`, `@promptStyle`, `@requiredSlots`, `@requiredTags`, `@responseFormat`, `@responseRef`, `@since`, `@textRef` - `template.toolcall`: `@maxTokens`, `@owner`, `@payloadRef`, `@since`, `@toolName` - `validator.array`: `@max`, `@min` -- `validator.atLeastOne`: `@fields` +- `validator.atLeastOne`: `@condition`, `@fields` - `validator.base`: `@max`, `@min` -- `validator.comparison`: `@left`, `@op`, `@right` +- `validator.comparison`: `@condition`, `@left`, `@op`, `@right` +- `validator.exactlyOne`: `@condition`, `@fields` - `validator.length`: `@max`, `@min` - `validator.numeric`: `@max`, `@min` -- `validator.presentIff`: `@equals`, `@field`, `@when` +- `validator.presentIff`: `@condition`, `@equals`, `@field`, `@when` - `validator.regex`: `@max`, `@min`, `@pattern` -- `validator.requiredWhen`: `@equals`, `@field`, `@when` +- `validator.requiredWhen`: `@condition`, `@equals`, `@field`, `@when` ## metaobjects-db @@ -65,22 +66,32 @@ DB-domain attributes — @column / @db.indexed / @dbColumnType on every field, @ - `field.date`: `@autoSet`, `@column`, `@db.indexed`, `@dbColumnType` - `field.decimal`: `@column`, `@db.indexed`, `@dbColumnType` - `field.double`: `@column`, `@db.indexed`, `@dbColumnType` -- `field.enum`: `@column`, `@db.indexed`, `@dbColumnType` +- `field.duration`: `@column`, `@db.indexed`, `@dbColumnType` +- `field.enum`: `@column`, `@constraintName`, `@db.indexed`, `@dbColumnType` - `field.float`: `@column`, `@db.indexed`, `@dbColumnType` - `field.inet`: `@column`, `@db.indexed`, `@dbColumnType` - `field.int`: `@column`, `@db.indexed`, `@dbColumnType` - `field.long`: `@column`, `@db.indexed`, `@dbColumnType` - `field.map`: `@column`, `@db.indexed`, `@dbColumnType` -- `field.object`: `@column`, `@db.indexed`, `@dbColumnType`, `@storage` +- `field.object`: `@column`, `@db.indexed`, `@dbColumnType`, `@flattenedColumnPrefix`, `@storage` - `field.string`: `@column`, `@db.indexed`, `@dbColumnType` - `field.time`: `@autoSet`, `@column`, `@db.indexed`, `@dbColumnType` - `field.timestamp`: `@autoSet`, `@column`, `@db.indexed`, `@dbColumnType`, `@localTime` - `field.uri`: `@column`, `@db.indexed`, `@dbColumnType` - `field.uuid`: `@column`, `@db.indexed`, `@dbColumnType` +- `identity.primary`: `@sequenceName`, `@sequenceStart` - `identity.reference`: `@constraintName`, `@onDelete`, `@onUpdate` - `identity.secondary`: `@expr`, `@orders`, `@using`, `@where` - `index.lookup`: `@expr`, `@orders`, `@using`, `@where` - `source.rdb`: `@function`, `@kind`, `@materializedView`, `@parameterRef`, `@proc`, `@role`, `@schema`, `@sql`, `@table`, `@unmanaged`, `@view` +- `validator.atLeastOne`: `@constraintName` +- `validator.comparison`: `@constraintName` +- `validator.exactlyOne`: `@constraintName` +- `validator.length`: `@constraintName` +- `validator.numeric`: `@constraintName` +- `validator.presentIff`: `@constraintName` +- `validator.regex`: `@constraintName` +- `validator.requiredWhen`: `@constraintName` ## metaobjects-documentation @@ -100,6 +111,7 @@ Prompt / AI + serialization domain — @xmlText / @example / @instruction field - `field.date`: `@example`, `@instruction`, `@xmlText` - `field.decimal`: `@example`, `@instruction`, `@xmlText` - `field.double`: `@example`, `@instruction`, `@xmlText` +- `field.duration`: `@example`, `@instruction`, `@xmlText` - `field.enum`: `@coerceDefault`, `@enumAlias`, `@enumDoc`, `@example`, `@instruction`, `@normalize`, `@xmlText` - `field.float`: `@example`, `@instruction`, `@xmlText` - `field.inet`: `@example`, `@instruction`, `@xmlText` @@ -126,6 +138,7 @@ UI/query-surface domain — @filterable / @sortable / @sortableDefaultOrder fiel - `field.date`: `@filterable`, `@formExclude`, `@sortable`, `@sortableDefaultOrder` - `field.decimal`: `@filterable`, `@formExclude`, `@sortable`, `@sortableDefaultOrder` - `field.double`: `@filterable`, `@formExclude`, `@sortable`, `@sortableDefaultOrder` +- `field.duration`: `@filterable`, `@formExclude`, `@sortable`, `@sortableDefaultOrder` - `field.enum`: `@filterable`, `@formExclude`, `@sortable`, `@sortableDefaultOrder` - `field.float`: `@filterable`, `@formExclude`, `@sortable`, `@sortableDefaultOrder` - `field.inet`: `@filterable`, `@formExclude`, `@sortable`, `@sortableDefaultOrder` diff --git a/fixtures/metamodel-docs/expected/types/field.md b/fixtures/metamodel-docs/expected/types/field.md index f087e964f3..1d03426759 100644 --- a/fixtures/metamodel-docs/expected/types/field.md +++ b/fixtures/metamodel-docs/expected/types/field.md @@ -214,6 +214,39 @@ Double-precision (64-bit) IEEE-754 floating point. Binds to the native double/nu - `validator.*` — 0..* - `view.*` — 0..* +### field.duration + +A fixed-length span of time: days, hours, minutes and seconds (ADR-0036/0037 — a concept with a native type + behavior). Binds to TS string (TS has no native duration type); other ports bind to their native type (C# System.TimeSpan, Java and Kotlin java.time.Duration, Python datetime.timedelta). DB column is the Postgres-native interval type; SQLite, D1 and MySQL store a 64-bit integer count of microseconds, which orders and compares as the span does. The wire form is an ISO-8601 duration: every port writes [-]PT[nH][nM][n[.f]S] (hours never folded into days, as many fractional digits as its native type holds, zero is PT0S) and reads that form plus leading week and day counts (nW, exactly 7 days; nD, exactly 24 hours) and a fraction of any length, truncated toward zero. Years and months are refused: a calendar span is not a fixed length. Justified in ADR-0023 Amendment 1. + +**Owning provider:** metaobjects-core-types + +**When to use:** A field holds an elapsed time, a timeout or a delay. Use it for a native Postgres interval column and a native duration type instead of a string or a number of seconds. A calendar span ("every 3 months") is not a duration: model it as an int and a unit. + +**Attributes** + +| Attribute | Type | Required | Default | Allowed values | Provider | Description | +| --- | --- | --- | --- | --- | --- | --- | +| `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | +| `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | +| `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | +| `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | +| `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | +| `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | +| `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | +| `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | +| `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | +| `@required` | boolean | no | | | metaobjects-core-types | When true, the field is NOT NULL. Equivalent to attaching a validator.required child. On a non-array string, generated wire-tier input validation (create/patch) additionally rejects the empty string by default — whitespace is accepted — unless an explicit validator.length @min: 0 opts back to presence-only. In-process read models never enforce this at construction. | +| `@sortable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD sort allowlists. Inherits from @filterable by default; set false to opt out. | +| `@sortableDefaultOrder` | string | no | | `asc`, `desc` | metaobjects-ui | Direction a sort takes when it names this field but omits the order: a ?sort= with no :order, and a layout.dataGrid whose @defaultSortField names it with no @defaultSortOrder. Defaults to 'asc'. | +| `@unique` | boolean | no | | | metaobjects-core-types | When true, the field gets a column-level UNIQUE constraint. | +| `@xmlText` | boolean | no | | | metaobjects-prompt | When true, this field receives its element's XML TEXT CONTENT during tolerant extract (JAXB @XmlValue / Jackson @JacksonXmlText / .NET [XmlText]) instead of a same-named child. No effect when the eliciting prompt's @responseFormat is json. | + +**Allowed children** + +- `origin.*` — 0..* +- `validator.*` — 0..* +- `view.*` — 0..* + ### field.enum String-backed enumeration constrained to a closed set of member symbols (@values). Each member is its own stored string with no name/value divergence. @@ -230,6 +263,7 @@ String-backed enumeration constrained to a closed set of member symbols (@values | --- | --- | --- | --- | --- | --- | --- | | `@coerceDefault` | string | no | | | metaobjects-prompt | Fallback enum member used by tolerant extract when a present value cannot be coerced; must be one of the field's @values. | | `@column` | string | no | | | metaobjects-db | Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). | +| `@constraintName` | string | no | | | metaobjects-db | The database name of the CHECK constraint this enum's member list generates. Absent: __chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. | | `@db.indexed` | boolean | no | | | metaobjects-db | When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means). | | `@dbColumnType` | string | no | | `uuid`, `jsonb` | metaobjects-db | Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid \| jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2. | | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | @@ -240,6 +274,7 @@ String-backed enumeration constrained to a closed set of member symbols (@values | `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@intValueMap` | intMap | no | | | metaobjects-core-types | Optional per-member int values ({member: int}) switching this enum field's DB persistence from string+CHECK to integer+CHECK. Keys must exactly match @values; values must be unique integers. The generated native type and wire format are unchanged in every language. | +| `@maxLength` | int | no | | | metaobjects-core-types | Column length of a string-stored enum (drives VARCHAR(n)); every member must fit. Overrides the project's enums.maxLength. Refused with @intValueMap, whose column is an integer. Justified in ADR-0023 Amendment 1. | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | | `@normalize` | string | no | `strip` | `none`, `collapse`, `strip` | metaobjects-prompt | ASCII normalization mode for tolerant enum extract (none\|collapse\|strip, default strip). On field.enum it is per-field; on object.value it is the default for the object's enum fields. | | `@provided` | boolean | no | | | metaobjects-core-types | FR-019: marks an abstract package-level field.enum as externally provided — codegen references the type (resolved via per-port codegen config) instead of materializing it. Default false. Not a field attr — it lives on the type declaration. | @@ -444,6 +479,7 @@ A nested structured value (set @objectRef to the target object). Storage is gove | `@default` | any | no | | | metaobjects-core-types | Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue(). | | `@example` | string | no | | | metaobjects-prompt | FR-010: an example value for this field, shown in the generated output-format prompt fragment. | | `@filterable` | boolean | no | | | metaobjects-ui | When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer). | +| `@flattenedColumnPrefix` | string | no | | | metaobjects-db | On a field.object with @storage "flattened": the text put before each member's column, verbatim ("" allowed, so the members keep their own column names). Absent: this field's resolved column name and "_" (with @column "bill", the members are bill_). A fact about the owning table, so it lives on the embedding field, never on the value object. Refused on any other storage (ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE). Justified in ADR-0023 Amendment 1. | | `@formExclude` | boolean | no | | | metaobjects-ui | When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields). | | `@instruction` | string | no | | | metaobjects-prompt | FR-010: a short instruction for this field, shown in the generated output-format prompt fragment. | | `@mutability` | string | no | | `readWrite`, `writeOnce`, `readOnly` | metaobjects-core-types | FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it. | diff --git a/fixtures/metamodel-docs/expected/types/identity.md b/fixtures/metamodel-docs/expected/types/identity.md index 74456e20fb..35dbe622dd 100644 --- a/fixtures/metamodel-docs/expected/types/identity.md +++ b/fixtures/metamodel-docs/expected/types/identity.md @@ -24,6 +24,8 @@ The primary key — one per entity; @fields names its column(s), @generation the | --- | --- | --- | --- | --- | --- | --- | | `@fields` | string[] | yes | | | metaobjects-core-types | The field name(s) composing this identity. Single-element for a simple PK/index, multiple for a composite. | | `@generation` | string | no | | `increment`, `uuid`, `assigned` | metaobjects-core-types | Primary-key value generation strategy: 'increment' (auto-increment), 'uuid', or 'assigned' (caller-supplied). | +| `@sequenceName` | string | no | | | metaobjects-db | The physical name of an @generation "increment" key's sequence (Postgres: GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME ...)). Absent: the database's own default,
__seq on Postgres. Lets a model adopt a sequence that kept its name across a table rename. Refused without @generation "increment" (ERR_SEQUENCE_WITHOUT_INCREMENT). RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. | +| `@sequenceStart` | long | no | | | metaobjects-db | The first value an @generation "increment" key's sequence hands out (Postgres: GENERATED BY DEFAULT AS IDENTITY (START WITH n)). Absent: 1. Lets a model reproduce a table that keeps a low id range for seeded rows. Refused without @generation "increment" (ERR_SEQUENCE_WITHOUT_INCREMENT). RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. | **Allowed children** diff --git a/fixtures/metamodel-docs/expected/types/validator.md b/fixtures/metamodel-docs/expected/types/validator.md index 22dfddab1c..5857bd44da 100644 --- a/fixtures/metamodel-docs/expected/types/validator.md +++ b/fixtures/metamodel-docs/expected/types/validator.md @@ -43,6 +43,8 @@ Cardinality of presence: at least one of the named fields (@fields) must be pres | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | +| `@condition` | expression | no | | | metaobjects-core-types | The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1. | +| `@constraintName` | string | no | | | metaobjects-db | The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. | | `@fields` | string[] | yes | | | metaobjects-core-types | Names of the candidate fields; at least one must be present. | **Allowed children** @@ -80,6 +82,8 @@ Cross-field ordering: requires two sibling fields of the owning entity stand in | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | +| `@condition` | expression | no | | | metaobjects-core-types | The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1. | +| `@constraintName` | string | no | | | metaobjects-db | The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. | | `@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. | @@ -88,6 +92,28 @@ Cross-field ordering: requires two sibling fields of the owning entity stand in _No structural children._ +### validator.exactlyOne + +Cardinality of presence: exactly one of the named fields (@fields) is present (NOT NULL), and the others are null. Entity-scoped; references fields by name (same @fields-by-name pattern as identity.*). Justified in ADR-0023 Amendment 1. + +**Owning provider:** metaobjects-core-types + +**Rules:** @fields names two or more fields of the owning entity (ERR_BAD_ATTR_VALUE): one field would just be a required field. Satisfied when exactly one of them is non-null. + +**When to use:** A row belongs to exactly one of several alternatives (a store or a kiosk, never both, never neither). Declare it instead of hand-written count-of-non-nulls checks. + +**Attributes** + +| Attribute | Type | Required | Default | Allowed values | Provider | Description | +| --- | --- | --- | --- | --- | --- | --- | +| `@condition` | expression | no | | | metaobjects-core-types | The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1. | +| `@constraintName` | string | no | | | metaobjects-db | The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. | +| `@fields` | string[] | yes | | | metaobjects-core-types | Names of the alternative fields; exactly one must be present. | + +**Allowed children** + +_No structural children._ + ### validator.length Bounds string length / collection size via @min/@max. @@ -100,6 +126,7 @@ Bounds string length / collection size via @min/@max. | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | +| `@constraintName` | string | no | | | metaobjects-db | The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. | | `@max` | int | no | | | metaobjects-core-types | Maximum allowed value (length, numeric value, or array element count depending on the validator subtype). | | `@min` | int | no | | | metaobjects-core-types | Minimum allowed value (length, numeric value, or array element count depending on the validator subtype). | @@ -119,6 +146,7 @@ Bounds a numeric value's magnitude via @min/@max. | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | +| `@constraintName` | string | no | | | metaobjects-db | The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. | | `@max` | int | no | | | metaobjects-core-types | Maximum allowed value (length, numeric value, or array element count depending on the validator subtype). | | `@min` | int | no | | | metaobjects-core-types | Minimum allowed value (length, numeric value, or array element count depending on the validator subtype). | @@ -140,6 +168,8 @@ Biconditional presence: the target field (@field) is present (NOT NULL) if and o | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | +| `@condition` | expression | no | | | metaobjects-core-types | The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1. | +| `@constraintName` | string | no | | | metaobjects-db | The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. | | `@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. | @@ -160,6 +190,7 @@ Requires the value match a regular expression (@pattern). | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | +| `@constraintName` | string | no | | | metaobjects-db | The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. | | `@max` | int | no | | | metaobjects-core-types | Maximum allowed value (length, numeric value, or array element count depending on the validator subtype). | | `@min` | int | no | | | metaobjects-core-types | Minimum allowed value (length, numeric value, or array element count depending on the validator subtype). | | `@pattern` | string | no | | | metaobjects-core-types | Regular expression the value must match. | @@ -198,6 +229,8 @@ One-directional conditional presence: when the gating field (@when) equals @equa | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | +| `@condition` | expression | no | | | metaobjects-core-types | The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1. | +| `@constraintName` | string | no | | | metaobjects-db | The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. | | `@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. | diff --git a/fixtures/naming-conformance/physical-names/cases.json b/fixtures/naming-conformance/physical-names/cases.json index fbc0c77920..4a8444d1d5 100644 --- a/fixtures/naming-conformance/physical-names/cases.json +++ b/fixtures/naming-conformance/physical-names/cases.json @@ -1,61 +1,90 @@ { - "_comment": "Cross-port physical naming (ADR-0058). Each case is the `naming` block of a project's .metaobjects/config.json. A port parses it with the reader it uses for that file, loads meta.naming.json, and resolves every table and column below through the resolver its codegen, migrate (TypeScript), runtime and verify use. `tables` keys are object names; `columns` keys are `.`. Overrides (@table, a source's structural name, @column) win under every rule; the entity-name fallback keeps the frozen legacy pluralizer under every rule. `invalid` blocks must be refused when the config is read.", + "_comment": "Cross-port physical naming (ADR-0058). Each case is the `naming` block of a project's .metaobjects/config.json. A port parses it with the reader it uses for that file, loads meta.naming.json, and resolves every table and column below through the resolver its codegen, migrate (TypeScript), runtime and verify use. `tables` keys are object names; `columns` keys are `.`. Overrides (@table, a source's structural name, @column) win under every rule; the entity-name fallback keeps the frozen legacy pluralizer under every rule. `invalid` blocks must be refused when the config is read. `upperFirst` upper-cases the field name's first letter and changes nothing else (`requestURL` → `RequestURL`). A key `..` names a member of a `@storage: flattened` value object: its column is the flattened prefix (`@flattenedColumnPrefix` on the embedding field, verbatim, `\"\"` allowed; else that field's resolved column and `_`) followed by the member's resolved column; a prefix inherited through `extends` counts.", "cases": [ { "name": "default", "naming": null, - "tables": { "OrderItem": "order_items", "Person": "people", "AuditEntry": "audit_log", "Category": "categories", "HTTPRequest": "http_requests" }, + "tables": { "OrderItem": "order_items", "Person": "people", "AuditEntry": "audit_log", "Category": "categories", "HTTPRequest": "http_requests", "Shipment": "shipments" }, "columns": { "OrderItem.id": "id", "OrderItem.createdAt": "created_at", "OrderItem.unitPriceCents": "unit_price_cents", "OrderItem.sku": "SKU_CODE", "Person.firstName": "first_name", "Person.lastName": "family_name", - "AuditEntry.eventType": "event_type", "Category.displayName": "display_name", "HTTPRequest.requestURL": "request_url" + "AuditEntry.eventType": "event_type", + "Category.displayName": "display_name", + "HTTPRequest.requestURL": "request_url", + "Shipment.id": "id", "Shipment.shipTo.street": "ship_to_street", "Shipment.shipTo.postalCode": "ship_to_postal_code", "Shipment.shipTo.country": "ship_to_CTRY", "Shipment.billTo.postalCode": "bill_postal_code", "Shipment.returnTo.postalCode": "postal_code", "Shipment.giftTo.postalCode": "g_postal_code", "Shipment.giftTo.country": "g_CTRY", "Shipment.homeTo.postalCode": "addr_postal_code" } }, { "name": "empty block is the default", "naming": {}, - "tables": { "OrderItem": "order_items", "Person": "people", "AuditEntry": "audit_log", "Category": "categories", "HTTPRequest": "http_requests" }, + "tables": { "OrderItem": "order_items", "Person": "people", "AuditEntry": "audit_log", "Category": "categories", "HTTPRequest": "http_requests", "Shipment": "shipments" }, "columns": { "OrderItem.id": "id", "OrderItem.createdAt": "created_at", "OrderItem.unitPriceCents": "unit_price_cents", "OrderItem.sku": "SKU_CODE", "Person.firstName": "first_name", "Person.lastName": "family_name", - "AuditEntry.eventType": "event_type", "Category.displayName": "display_name", "HTTPRequest.requestURL": "request_url" + "AuditEntry.eventType": "event_type", + "Category.displayName": "display_name", + "HTTPRequest.requestURL": "request_url", + "Shipment.id": "id", "Shipment.shipTo.street": "ship_to_street", "Shipment.shipTo.postalCode": "ship_to_postal_code", "Shipment.shipTo.country": "ship_to_CTRY", "Shipment.billTo.postalCode": "bill_postal_code", "Shipment.returnTo.postalCode": "postal_code", "Shipment.giftTo.postalCode": "g_postal_code", "Shipment.giftTo.country": "g_CTRY", "Shipment.homeTo.postalCode": "addr_postal_code" } }, { "name": "literal columns", "naming": { "columns": "literal" }, - "tables": { "OrderItem": "order_items", "Person": "people", "AuditEntry": "audit_log", "Category": "categories", "HTTPRequest": "http_requests" }, + "tables": { "OrderItem": "order_items", "Person": "people", "AuditEntry": "audit_log", "Category": "categories", "HTTPRequest": "http_requests", "Shipment": "shipments" }, "columns": { "OrderItem.id": "id", "OrderItem.createdAt": "createdAt", "OrderItem.unitPriceCents": "unitPriceCents", "OrderItem.sku": "SKU_CODE", "Person.firstName": "firstName", "Person.lastName": "family_name", - "AuditEntry.eventType": "eventType", "Category.displayName": "displayName", "HTTPRequest.requestURL": "requestURL" + "AuditEntry.eventType": "eventType", + "Category.displayName": "displayName", + "HTTPRequest.requestURL": "requestURL", + "Shipment.id": "id", "Shipment.shipTo.street": "shipTo_street", "Shipment.shipTo.postalCode": "shipTo_postalCode", "Shipment.shipTo.country": "shipTo_CTRY", "Shipment.billTo.postalCode": "bill_postalCode", "Shipment.returnTo.postalCode": "postalCode", "Shipment.giftTo.postalCode": "g_postalCode", "Shipment.giftTo.country": "g_CTRY", "Shipment.homeTo.postalCode": "addr_postalCode" } }, { "name": "literal tables", "naming": { "tables": "literal" }, - "tables": { "OrderItem": "OrderItems", "Person": "people", "AuditEntry": "AuditLog", "Category": "Categories", "HTTPRequest": "HTTPRequests" }, + "tables": { "OrderItem": "OrderItems", "Person": "people", "AuditEntry": "AuditLog", "Category": "Categories", "HTTPRequest": "HTTPRequests", "Shipment": "Shipments" }, "columns": { "OrderItem.id": "id", "OrderItem.createdAt": "created_at", "OrderItem.unitPriceCents": "unit_price_cents", "OrderItem.sku": "SKU_CODE", "Person.firstName": "first_name", "Person.lastName": "family_name", - "AuditEntry.eventType": "event_type", "Category.displayName": "display_name", "HTTPRequest.requestURL": "request_url" + "AuditEntry.eventType": "event_type", + "Category.displayName": "display_name", + "HTTPRequest.requestURL": "request_url", + "Shipment.id": "id", "Shipment.shipTo.street": "ship_to_street", "Shipment.shipTo.postalCode": "ship_to_postal_code", "Shipment.shipTo.country": "ship_to_CTRY", "Shipment.billTo.postalCode": "bill_postal_code", "Shipment.returnTo.postalCode": "postal_code", "Shipment.giftTo.postalCode": "g_postal_code", "Shipment.giftTo.country": "g_CTRY", "Shipment.homeTo.postalCode": "addr_postal_code" } }, { "name": "kebab-case columns, literal tables", "naming": { "columns": "kebab-case", "tables": "literal" }, - "tables": { "OrderItem": "OrderItems", "Person": "people", "AuditEntry": "AuditLog", "Category": "Categories", "HTTPRequest": "HTTPRequests" }, + "tables": { "OrderItem": "OrderItems", "Person": "people", "AuditEntry": "AuditLog", "Category": "Categories", "HTTPRequest": "HTTPRequests", "Shipment": "Shipments" }, "columns": { "OrderItem.id": "id", "OrderItem.createdAt": "created-at", "OrderItem.unitPriceCents": "unit-price-cents", "OrderItem.sku": "SKU_CODE", "Person.firstName": "first-name", "Person.lastName": "family_name", - "AuditEntry.eventType": "event-type", "Category.displayName": "display-name", "HTTPRequest.requestURL": "request-url" + "AuditEntry.eventType": "event-type", + "Category.displayName": "display-name", + "HTTPRequest.requestURL": "request-url", + "Shipment.id": "id", "Shipment.shipTo.street": "ship-to_street", "Shipment.shipTo.postalCode": "ship-to_postal-code", "Shipment.shipTo.country": "ship-to_CTRY", "Shipment.billTo.postalCode": "bill_postal-code", "Shipment.returnTo.postalCode": "postal-code", "Shipment.giftTo.postalCode": "g_postal-code", "Shipment.giftTo.country": "g_CTRY", "Shipment.homeTo.postalCode": "addr_postal-code" + } + }, + { + "name": "upperFirst columns, literal tables", + "naming": { "columns": "upperFirst", "tables": "literal" }, + "tables": { "OrderItem": "OrderItems", "Person": "people", "AuditEntry": "AuditLog", "Category": "Categories", "HTTPRequest": "HTTPRequests", "Shipment": "Shipments" }, + "columns": { + "OrderItem.id": "Id", "OrderItem.createdAt": "CreatedAt", "OrderItem.unitPriceCents": "UnitPriceCents", "OrderItem.sku": "SKU_CODE", + "Person.firstName": "FirstName", "Person.lastName": "family_name", + "AuditEntry.eventType": "EventType", + "Category.displayName": "DisplayName", + "HTTPRequest.requestURL": "RequestURL", + "Shipment.id": "Id", "Shipment.shipTo.street": "ShipTo_Street", "Shipment.shipTo.postalCode": "ShipTo_PostalCode", "Shipment.shipTo.country": "ShipTo_CTRY", "Shipment.billTo.postalCode": "bill_PostalCode", "Shipment.returnTo.postalCode": "PostalCode", "Shipment.giftTo.postalCode": "g_PostalCode", "Shipment.giftTo.country": "g_CTRY", "Shipment.homeTo.postalCode": "addr_PostalCode" } } ], "invalid": [ { "name": "unknown column rule", "naming": { "columns": "camelCase" } }, { "name": "kebab-case is not a table rule", "naming": { "tables": "kebab-case" } }, + { "name": "upperFirst is not a table rule", "naming": { "tables": "upperFirst" } }, { "name": "unknown key", "naming": { "column": "literal" } }, - { "name": "not an object", "naming": "snake_case" } + { "name": "not an object", "naming": "snake_case"} ] } diff --git a/fixtures/naming-conformance/physical-names/meta.naming.json b/fixtures/naming-conformance/physical-names/meta.naming.json index 45402809c7..f3e394ca94 100644 --- a/fixtures/naming-conformance/physical-names/meta.naming.json +++ b/fixtures/naming-conformance/physical-names/meta.naming.json @@ -59,6 +59,32 @@ { "identity.primary": { "name": "id", "@fields": "id" } } ] } + }, + { "field.object": { "name": "AddrCols", "abstract": true, "@objectRef": "acme::naming::Address", "@flattenedColumnPrefix": "addr_" } }, + { + "object.value": { + "name": "Address", + "children": [ + { "field.string": { "name": "street" } }, + { "field.string": { "name": "postalCode" } }, + { "field.string": { "name": "country", "@column": "CTRY" } } + ] + } + }, + { + "object.entity": { + "name": "Shipment", + "children": [ + { "source.rdb": {} }, + { "field.long": { "name": "id" } }, + { "field.object": { "name": "shipTo", "@objectRef": "Address", "@storage": "flattened" } }, + { "field.object": { "name": "billTo", "@objectRef": "Address", "@storage": "flattened", "@column": "bill" } }, + { "field.object": { "name": "returnTo", "@objectRef": "Address", "@storage": "flattened", "@flattenedColumnPrefix": "" } }, + { "field.object": { "name": "giftTo", "@objectRef": "Address", "@storage": "flattened", "@flattenedColumnPrefix": "g_" } }, + { "field.object": { "name": "homeTo", "extends": "acme::naming::AddrCols", "@storage": "flattened" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ] + } } ] } diff --git a/fixtures/registry-conformance/coverage-report.json b/fixtures/registry-conformance/coverage-report.json index 631dfcee54..898c776a48 100644 --- a/fixtures/registry-conformance/coverage-report.json +++ b/fixtures/registry-conformance/coverage-report.json @@ -1,6 +1,6 @@ { - "registeredSubTypeCount": 65, - "exercisedSubTypeCount": 53, + "registeredSubTypeCount": 66, + "exercisedSubTypeCount": 58, "untestedSubTypes": [ "attr.boolean", "attr.class", @@ -9,11 +9,7 @@ "attr.filter", "attr.int", "attr.intMap", - "attr.long", - "validator.atLeastOne", - "validator.comparison", - "validator.presentIff", - "validator.requiredWhen" + "attr.long" ], "untestedAttrsByExercisedSubType": [ { @@ -73,7 +69,6 @@ "formExclude", "instruction", "mutability", - "sortable", "sortableDefaultOrder", "unique", "xmlText" @@ -95,6 +90,23 @@ "xmlText" ] }, + { + "key": "field.duration", + "untestedAttrs": [ + "column", + "db.indexed", + "dbColumnType", + "default", + "example", + "formExclude", + "instruction", + "mutability", + "sortable", + "sortableDefaultOrder", + "unique", + "xmlText" + ] + }, { "key": "field.enum", "untestedAttrs": [ @@ -121,7 +133,6 @@ "formExclude", "instruction", "mutability", - "sortable", "sortableDefaultOrder", "unique", "xmlText" @@ -148,7 +159,6 @@ { "key": "field.int", "untestedAttrs": [ - "column", "dbColumnType", "example", "formExclude", @@ -188,7 +198,6 @@ "sortable", "sortableDefaultOrder", "unique", - "valueType", "xmlText" ] }, @@ -211,7 +220,6 @@ { "key": "field.string", "untestedAttrs": [ - "formExclude", "sortableDefaultOrder", "xmlText" ] @@ -324,12 +332,6 @@ "symmetric" ] }, - { - "key": "source.rdb", - "untestedAttrs": [ - "materializedView" - ] - }, { "key": "template.output", "untestedAttrs": [ @@ -355,12 +357,24 @@ "since" ] }, + { + "key": "validator.presentIff", + "untestedAttrs": [ + "constraintName" + ] + }, { "key": "validator.regex", "untestedAttrs": [ "max", "min" ] + }, + { + "key": "validator.requiredWhen", + "untestedAttrs": [ + "constraintName" + ] } ] } diff --git a/fixtures/registry-conformance/expected-registry.json b/fixtures/registry-conformance/expected-registry.json index e0fe66f7ef..da9d8569e9 100644 --- a/fixtures/registry-conformance/expected-registry.json +++ b/fixtures/registry-conformance/expected-registry.json @@ -1,5 +1,5 @@ { - "metamodelVersion": "1.2", + "metamodelVersion": "1.3", "types": [ { "type": "attr", @@ -1024,6 +1024,148 @@ } ] }, + { + "type": "field", + "subType": "duration", + "description": "A fixed-length span of time: days, hours, minutes and seconds (ADR-0036/0037 — a concept with a native type + behavior). Binds to TS string (TS has no native duration type); other ports bind to their native type (C# System.TimeSpan, Java and Kotlin java.time.Duration, Python datetime.timedelta). DB column is the Postgres-native interval type; SQLite, D1 and MySQL store a 64-bit integer count of microseconds, which orders and compares as the span does. The wire form is an ISO-8601 duration: every port writes [-]PT[nH][nM][n[.f]S] (hours never folded into days, as many fractional digits as its native type holds, zero is PT0S) and reads that form plus leading week and day counts (nW, exactly 7 days; nD, exactly 24 hours) and a fraction of any length, truncated toward zero. Years and months are refused: a calendar span is not a fixed length. Justified in ADR-0023 Amendment 1.", + "whenToUse": "A field holds an elapsed time, a timeout or a delay. Use it for a native Postgres interval column and a native duration type instead of a string or a number of seconds. A calendar span (\"every 3 months\") is not a duration: model it as an int and a unit.", + "attrs": [ + { + "name": "column", + "valueType": "string", + "isArray": false, + "required": false, + "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." + }, + { + "name": "db.indexed", + "valueType": "boolean", + "isArray": false, + "required": false, + "description": "When true, suppress the @filterable-without-index Loader warning (the field is indexed by other means)." + }, + { + "name": "dbColumnType", + "valueType": "string", + "isArray": false, + "required": false, + "allowedValues": [ + "uuid", + "jsonb" + ], + "description": "Physical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2." + }, + { + "name": "default", + "valueType": null, + "isArray": false, + "required": false, + "description": "Default value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue()." + }, + { + "name": "example", + "valueType": "string", + "isArray": false, + "required": false, + "description": "FR-010: an example value for this field, shown in the generated output-format prompt fragment." + }, + { + "name": "filterable", + "valueType": "boolean", + "isArray": false, + "required": false, + "description": "When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer)." + }, + { + "name": "formExclude", + "valueType": "boolean", + "isArray": false, + "required": false, + "description": "When true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields)." + }, + { + "name": "instruction", + "valueType": "string", + "isArray": false, + "required": false, + "description": "FR-010: a short instruction for this field, shown in the generated output-format prompt fragment." + }, + { + "name": "mutability", + "valueType": "string", + "isArray": false, + "required": false, + "allowedValues": [ + "readWrite", + "writeOnce", + "readOnly" + ], + "description": "FR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it." + }, + { + "name": "required", + "valueType": "boolean", + "isArray": false, + "required": false, + "description": "When true, the field is NOT NULL. Equivalent to attaching a validator.required child. On a non-array string, generated wire-tier input validation (create/patch) additionally rejects the empty string by default — whitespace is accepted — unless an explicit validator.length @min: 0 opts back to presence-only. In-process read models never enforce this at construction." + }, + { + "name": "sortable", + "valueType": "boolean", + "isArray": false, + "required": false, + "description": "When true, the field is exposed in generated CRUD sort allowlists. Inherits from @filterable by default; set false to opt out." + }, + { + "name": "sortableDefaultOrder", + "valueType": "string", + "isArray": false, + "required": false, + "allowedValues": [ + "asc", + "desc" + ], + "description": "Direction a sort takes when it names this field but omits the order: a ?sort= with no :order, and a layout.dataGrid whose @defaultSortField names it with no @defaultSortOrder. Defaults to 'asc'." + }, + { + "name": "unique", + "valueType": "boolean", + "isArray": false, + "required": false, + "description": "When true, the field gets a column-level UNIQUE constraint." + }, + { + "name": "xmlText", + "valueType": "boolean", + "isArray": false, + "required": false, + "description": "When true, this field receives its element's XML TEXT CONTENT during tolerant extract (JAXB @XmlValue / Jackson @JacksonXmlText / .NET [XmlText]) instead of a same-named child. No effect when the eliciting prompt's @responseFormat is json." + } + ], + "children": [ + { + "childType": "origin", + "childSubType": "*", + "childName": "*", + "min": 0, + "max": null + }, + { + "childType": "validator", + "childSubType": "*", + "childName": "*", + "min": 0, + "max": null + }, + { + "childType": "view", + "childSubType": "*", + "childName": "*", + "min": 0, + "max": null + } + ] + }, { "type": "field", "subType": "enum", @@ -1045,6 +1187,13 @@ "required": false, "description": "Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the \"naming\" setting in .metaobjects/config.json)." }, + { + "name": "constraintName", + "valueType": "string", + "isArray": false, + "required": false, + "description": "The database name of the CHECK constraint this enum's member list generates. Absent:
__chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." + }, { "name": "db.indexed", "valueType": "boolean", @@ -1119,6 +1268,13 @@ "required": false, "description": "Optional per-member int values ({member: int}) switching this enum field's DB persistence from string+CHECK to integer+CHECK. Keys must exactly match @values; values must be unique integers. The generated native type and wire format are unchanged in every language." }, + { + "name": "maxLength", + "valueType": "int", + "isArray": false, + "required": false, + "description": "Column length of a string-stored enum (drives VARCHAR(n)); every member must fit. Overrides the project's enums.maxLength. Refused with @intValueMap, whose column is an integer. Justified in ADR-0023 Amendment 1." + }, { "name": "mutability", "valueType": "string", @@ -2005,6 +2161,13 @@ "required": false, "description": "When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer)." }, + { + "name": "flattenedColumnPrefix", + "valueType": "string", + "isArray": false, + "required": false, + "description": "On a field.object with @storage \"flattened\": the text put before each member's column, verbatim (\"\" allowed, so the members keep their own column names). Absent: this field's resolved column name and \"_\" (with @column \"bill\", the members are bill_). A fact about the owning table, so it lives on the embedding field, never on the value object. Refused on any other storage (ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE). Justified in ADR-0023 Amendment 1." + }, { "name": "formExclude", "valueType": "boolean", @@ -2901,6 +3064,20 @@ "assigned" ], "description": "Primary-key value generation strategy: 'increment' (auto-increment), 'uuid', or 'assigned' (caller-supplied)." + }, + { + "name": "sequenceName", + "valueType": "string", + "isArray": false, + "required": false, + "description": "The physical name of an @generation \"increment\" key's sequence (Postgres: GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME ...)). Absent: the database's own default,
__seq on Postgres. Lets a model adopt a sequence that kept its name across a table rename. Refused without @generation \"increment\" (ERR_SEQUENCE_WITHOUT_INCREMENT). RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." + }, + { + "name": "sequenceStart", + "valueType": "long", + "isArray": false, + "required": false, + "description": "The first value an @generation \"increment\" key's sequence hands out (Postgres: GENERATED BY DEFAULT AS IDENTITY (START WITH n)). Absent: 1. Lets a model reproduce a table that keeps a low id range for seeded rows. Refused without @generation \"increment\" (ERR_SEQUENCE_WITHOUT_INCREMENT). RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." } ], "children": [] @@ -4650,6 +4827,20 @@ "rules": "@fields names two or more fields of the owning entity. Satisfied when any one of them is non-null.", "whenToUse": "At least one of several fields must be present (email or phone). Declare it instead of hand-written OR-presence checks.", "attrs": [ + { + "name": "condition", + "valueType": "expression", + "isArray": false, + "required": false, + "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." + }, + { + "name": "constraintName", + "valueType": "string", + "isArray": false, + "required": false, + "description": "The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." + }, { "name": "fields", "valueType": "string", @@ -4689,6 +4880,20 @@ "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).", "whenToUse": "Two sibling fields must stand in an order (start <= end, currentHp <= maxHp). Declare it instead of hand-writing the cross-field assertion.", "attrs": [ + { + "name": "condition", + "valueType": "expression", + "isArray": false, + "required": false, + "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." + }, + { + "name": "constraintName", + "valueType": "string", + "isArray": false, + "required": false, + "description": "The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." + }, { "name": "left", "valueType": "string", @@ -4721,12 +4926,50 @@ ], "children": [] }, + { + "type": "validator", + "subType": "exactlyOne", + "description": "Cardinality of presence: exactly one of the named fields (@fields) is present (NOT NULL), and the others are null. Entity-scoped; references fields by name (same @fields-by-name pattern as identity.*). Justified in ADR-0023 Amendment 1.", + "rules": "@fields names two or more fields of the owning entity (ERR_BAD_ATTR_VALUE): one field would just be a required field. Satisfied when exactly one of them is non-null.", + "whenToUse": "A row belongs to exactly one of several alternatives (a store or a kiosk, never both, never neither). Declare it instead of hand-written count-of-non-nulls checks.", + "attrs": [ + { + "name": "condition", + "valueType": "expression", + "isArray": false, + "required": false, + "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." + }, + { + "name": "constraintName", + "valueType": "string", + "isArray": false, + "required": false, + "description": "The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." + }, + { + "name": "fields", + "valueType": "string", + "isArray": true, + "required": true, + "description": "Names of the alternative fields; exactly one must be present." + } + ], + "children": [] + }, { "type": "validator", "subType": "length", "description": "Bounds string length / collection size via @min/@max.", "whenToUse": "Bound a string's length or a collection's size. Use @min/@max instead of hand-written guards.", "attrs": [ + { + "name": "constraintName", + "valueType": "string", + "isArray": false, + "required": false, + "description": "The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." + }, { "name": "max", "valueType": "int", @@ -4750,6 +4993,13 @@ "description": "Bounds a numeric value's magnitude via @min/@max.", "whenToUse": "Bound a number's magnitude. Use @min/@max instead of hand-written range checks.", "attrs": [ + { + "name": "constraintName", + "valueType": "string", + "isArray": false, + "required": false, + "description": "The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." + }, { "name": "max", "valueType": "int", @@ -4774,6 +5024,20 @@ "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.", "whenToUse": "A field must be present exactly when a condition holds, absent otherwise (biconditional). Declare it instead of hand-written presence logic.", "attrs": [ + { + "name": "condition", + "valueType": "expression", + "isArray": false, + "required": false, + "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." + }, + { + "name": "constraintName", + "valueType": "string", + "isArray": false, + "required": false, + "description": "The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." + }, { "name": "equals", "valueType": "string", @@ -4804,6 +5068,13 @@ "description": "Requires the value match a regular expression (@pattern).", "whenToUse": "A string must match a pattern (email, slug, code). Set @pattern instead of hand-writing the regex test.", "attrs": [ + { + "name": "constraintName", + "valueType": "string", + "isArray": false, + "required": false, + "description": "The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." + }, { "name": "max", "valueType": "int", @@ -4843,6 +5114,20 @@ "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).", "whenToUse": "A field becomes required only when another field has a given value. Declare it instead of conditional validation code.", "attrs": [ + { + "name": "condition", + "valueType": "expression", + "isArray": false, + "required": false, + "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." + }, + { + "name": "constraintName", + "valueType": "string", + "isArray": false, + "required": false, + "description": "The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." + }, { "name": "equals", "valueType": "string", diff --git a/fixtures/registry-conformance/providers.json b/fixtures/registry-conformance/providers.json index 10930e07b4..94e5457142 100644 --- a/fixtures/registry-conformance/providers.json +++ b/fixtures/registry-conformance/providers.json @@ -23,13 +23,23 @@ "projectsOnto": [ "field.*", "field.date", + "field.enum", "field.object", "field.time", "field.timestamp", + "identity.primary", "identity.reference", "identity.secondary", "index.lookup", - "source.rdb" + "source.rdb", + "validator.atLeastOne", + "validator.comparison", + "validator.exactlyOne", + "validator.length", + "validator.numeric", + "validator.presentIff", + "validator.regex", + "validator.requiredWhen" ] }, "metaobjects-documentation": { diff --git a/fixtures/requirement-harness/csharp/RequirementHarnessTests.cs b/fixtures/requirement-harness/csharp/RequirementHarnessTests.cs index 56a08ae700..f152f72dea 100644 --- a/fixtures/requirement-harness/csharp/RequirementHarnessTests.cs +++ b/fixtures/requirement-harness/csharp/RequirementHarnessTests.cs @@ -1,6 +1,6 @@ // @generated by scripts/generate-requirement-harness.ts — DO NOT EDIT IN PLACE. // -// One slot per authored promise in metaobjects/meta.requirements.yaml (115 of them). +// One slot per authored promise in metaobjects/meta.requirements.yaml (117 of them). // Every slot is SKIPPED: this is a to-do list with a drift gate behind it, not a // claim that nothing is verified — the conformance corpora already check many of // these. To FILL one, move it into C#'s own suite and write the assertion the @@ -64,6 +64,10 @@ public void declare_constraints_validatorAtLeastOne() { /* metaobjects.declare.c [Fact(Skip = "unfilled slot — write an assertion that fails when: An end date before its start date, stored, and every report that spans them silently empty.")] public void declare_constraints_validatorComparison() { /* metaobjects.declare.constraints.validatorComparison */ } + // Exactly one — An exactly-one rule requires that one member of a declared group is given, and no other. + [Fact(Skip = "unfilled slot — write an assertion that fails when: A booking tied to both a store and a kiosk, so neither can tell whose it is.")] + public void declare_constraints_validatorExactlyOne() { /* metaobjects.declare.constraints.validatorExactlyOne */ } + // Length bounds — A length rule bounds how long a value may be, and an explicitly declared bound is authoritative over any implicit floor. [Fact(Skip = "unfilled slot — write an assertion that fails when: A declared minimum of zero raised to one behind the author's back, so provided-but-empty becomes inexpressible.")] public void declare_constraints_validatorLength() { /* metaobjects.declare.constraints.validatorLength */ } @@ -380,6 +384,10 @@ public void declare_typedFieldVocabulary_fieldDecimal() { /* metaobjects.declare [Fact(Skip = "unfilled slot — write an assertion that fails when: A running total accumulated in one, where the sum of the parts no longer equals the whole.")] public void declare_typedFieldVocabulary_fieldDouble() { /* metaobjects.declare.typedFieldVocabulary.fieldDouble */ } + // Span of time — A duration field holds a fixed length of time as a span, not as a count whose unit only the code knows. + [Fact(Skip = "unfilled slot — write an assertion that fails when: A timeout read as seconds by one service and as milliseconds by another.")] + public void declare_typedFieldVocabulary_fieldDuration() { /* metaobjects.declare.typedFieldVocabulary.fieldDuration */ } + // Enumeration — An enumeration field holds one member of a closed declared set, and the member symbol is what the wire and the runtime see whatever the column stores. [Fact(Skip = "unfilled slot — write an assertion that fails when: A stored value that is no declared member, handed back as though it were one.")] public void declare_typedFieldVocabulary_fieldEnum() { /* metaobjects.declare.typedFieldVocabulary.fieldEnum */ } diff --git a/fixtures/requirement-harness/java/RequirementHarnessTest.java b/fixtures/requirement-harness/java/RequirementHarnessTest.java index f02b52d9d3..90a7342772 100644 --- a/fixtures/requirement-harness/java/RequirementHarnessTest.java +++ b/fixtures/requirement-harness/java/RequirementHarnessTest.java @@ -1,6 +1,6 @@ // @generated by scripts/generate-requirement-harness.ts — DO NOT EDIT IN PLACE. // -// One slot per authored promise in metaobjects/meta.requirements.yaml (115 of them). +// One slot per authored promise in metaobjects/meta.requirements.yaml (117 of them). // Every slot is SKIPPED: this is a to-do list with a drift gate behind it, not a // claim that nothing is verified — the conformance corpora already check many of // these. To FILL one, move it into Java's own suite and write the assertion the @@ -77,6 +77,11 @@ void declare_constraints_validatorAtLeastOne() { /* metaobjects.declare.constrai @Disabled("unfilled slot — write an assertion that fails when: An end date before its start date, stored, and every report that spans them silently empty.") void declare_constraints_validatorComparison() { /* metaobjects.declare.constraints.validatorComparison */ } + // Exactly one — An exactly-one rule requires that one member of a declared group is given, and no other. + @Test + @Disabled("unfilled slot — write an assertion that fails when: A booking tied to both a store and a kiosk, so neither can tell whose it is.") + void declare_constraints_validatorExactlyOne() { /* metaobjects.declare.constraints.validatorExactlyOne */ } + // Length bounds — A length rule bounds how long a value may be, and an explicitly declared bound is authoritative over any implicit floor. @Test @Disabled("unfilled slot — write an assertion that fails when: A declared minimum of zero raised to one behind the author's back, so provided-but-empty becomes inexpressible.") @@ -472,6 +477,11 @@ void declare_typedFieldVocabulary_fieldDecimal() { /* metaobjects.declare.typedF @Disabled("unfilled slot — write an assertion that fails when: A running total accumulated in one, where the sum of the parts no longer equals the whole.") void declare_typedFieldVocabulary_fieldDouble() { /* metaobjects.declare.typedFieldVocabulary.fieldDouble */ } + // Span of time — A duration field holds a fixed length of time as a span, not as a count whose unit only the code knows. + @Test + @Disabled("unfilled slot — write an assertion that fails when: A timeout read as seconds by one service and as milliseconds by another.") + void declare_typedFieldVocabulary_fieldDuration() { /* metaobjects.declare.typedFieldVocabulary.fieldDuration */ } + // Enumeration — An enumeration field holds one member of a closed declared set, and the member symbol is what the wire and the runtime see whatever the column stores. @Test @Disabled("unfilled slot — write an assertion that fails when: A stored value that is no declared member, handed back as though it were one.") diff --git a/fixtures/requirement-harness/kotlin/RequirementHarnessTest.kt b/fixtures/requirement-harness/kotlin/RequirementHarnessTest.kt index 38830b4bfc..6ed84f006c 100644 --- a/fixtures/requirement-harness/kotlin/RequirementHarnessTest.kt +++ b/fixtures/requirement-harness/kotlin/RequirementHarnessTest.kt @@ -1,6 +1,6 @@ // @generated by scripts/generate-requirement-harness.ts — DO NOT EDIT IN PLACE. // -// One slot per authored promise in metaobjects/meta.requirements.yaml (115 of them). +// One slot per authored promise in metaobjects/meta.requirements.yaml (117 of them). // Every slot is SKIPPED: this is a to-do list with a drift gate behind it, not a // claim that nothing is verified — the conformance corpora already check many of // these. To FILL one, move it into Kotlin's own suite and write the assertion the @@ -77,6 +77,11 @@ class RequirementHarnessTest { @Disabled("unfilled slot — write an assertion that fails when: An end date before its start date, stored, and every report that spans them silently empty.") fun declare_constraints_validatorComparison() { /* metaobjects.declare.constraints.validatorComparison */ } + // Exactly one — An exactly-one rule requires that one member of a declared group is given, and no other. + @Test + @Disabled("unfilled slot — write an assertion that fails when: A booking tied to both a store and a kiosk, so neither can tell whose it is.") + fun declare_constraints_validatorExactlyOne() { /* metaobjects.declare.constraints.validatorExactlyOne */ } + // Length bounds — A length rule bounds how long a value may be, and an explicitly declared bound is authoritative over any implicit floor. @Test @Disabled("unfilled slot — write an assertion that fails when: A declared minimum of zero raised to one behind the author's back, so provided-but-empty becomes inexpressible.") @@ -472,6 +477,11 @@ class RequirementHarnessTest { @Disabled("unfilled slot — write an assertion that fails when: A running total accumulated in one, where the sum of the parts no longer equals the whole.") fun declare_typedFieldVocabulary_fieldDouble() { /* metaobjects.declare.typedFieldVocabulary.fieldDouble */ } + // Span of time — A duration field holds a fixed length of time as a span, not as a count whose unit only the code knows. + @Test + @Disabled("unfilled slot — write an assertion that fails when: A timeout read as seconds by one service and as milliseconds by another.") + fun declare_typedFieldVocabulary_fieldDuration() { /* metaobjects.declare.typedFieldVocabulary.fieldDuration */ } + // Enumeration — An enumeration field holds one member of a closed declared set, and the member symbol is what the wire and the runtime see whatever the column stores. @Test @Disabled("unfilled slot — write an assertion that fails when: A stored value that is no declared member, handed back as though it were one.") diff --git a/fixtures/requirement-harness/python/test_requirement_harness.py b/fixtures/requirement-harness/python/test_requirement_harness.py index 0e5fdfd90e..bc494c15ce 100644 --- a/fixtures/requirement-harness/python/test_requirement_harness.py +++ b/fixtures/requirement-harness/python/test_requirement_harness.py @@ -1,6 +1,6 @@ # @generated by scripts/generate-requirement-harness.ts — DO NOT EDIT IN PLACE. # -# One slot per authored promise in metaobjects/meta.requirements.yaml (115 of them). +# One slot per authored promise in metaobjects/meta.requirements.yaml (117 of them). # Every slot is SKIPPED: this is a to-do list with a drift gate behind it, not a # claim that nothing is verified — the conformance corpora already check many of # these. To FILL one, move it into Python's own suite and write the assertion the @@ -72,6 +72,11 @@ def test_declare_constraints_validatorAtLeastOne(): def test_declare_constraints_validatorComparison(): """metaobjects.declare.constraints.validatorComparison""" +# Exactly one — An exactly-one rule requires that one member of a declared group is given, and no other. +@pytest.mark.skip(reason="unfilled slot — write an assertion that fails when: A booking tied to both a store and a kiosk, so neither can tell whose it is.") +def test_declare_constraints_validatorExactlyOne(): + """metaobjects.declare.constraints.validatorExactlyOne""" + # Length bounds — A length rule bounds how long a value may be, and an explicitly declared bound is authoritative over any implicit floor. @pytest.mark.skip(reason="unfilled slot — write an assertion that fails when: A declared minimum of zero raised to one behind the author's back, so provided-but-empty becomes inexpressible.") def test_declare_constraints_validatorLength(): @@ -467,6 +472,11 @@ def test_declare_typedFieldVocabulary_fieldDecimal(): def test_declare_typedFieldVocabulary_fieldDouble(): """metaobjects.declare.typedFieldVocabulary.fieldDouble""" +# Span of time — A duration field holds a fixed length of time as a span, not as a count whose unit only the code knows. +@pytest.mark.skip(reason="unfilled slot — write an assertion that fails when: A timeout read as seconds by one service and as milliseconds by another.") +def test_declare_typedFieldVocabulary_fieldDuration(): + """metaobjects.declare.typedFieldVocabulary.fieldDuration""" + # Enumeration — An enumeration field holds one member of a closed declared set, and the member symbol is what the wire and the runtime see whatever the column stores. @pytest.mark.skip(reason="unfilled slot — write an assertion that fails when: A stored value that is no declared member, handed back as though it were one.") def test_declare_typedFieldVocabulary_fieldEnum(): diff --git a/fixtures/requirement-harness/typescript/requirement-harness.test.ts b/fixtures/requirement-harness/typescript/requirement-harness.test.ts index 487abe99db..1e7d21f463 100644 --- a/fixtures/requirement-harness/typescript/requirement-harness.test.ts +++ b/fixtures/requirement-harness/typescript/requirement-harness.test.ts @@ -1,6 +1,6 @@ // @generated by scripts/generate-requirement-harness.ts — DO NOT EDIT IN PLACE. // -// One slot per authored promise in metaobjects/meta.requirements.yaml (115 of them). +// One slot per authored promise in metaobjects/meta.requirements.yaml (117 of them). // Every slot is SKIPPED: this is a to-do list with a drift gate behind it, not a // claim that nothing is verified — the conformance corpora already check many of // these. To FILL one, move it into TypeScript's own suite and write the assertion the @@ -72,6 +72,11 @@ test.skip("metaobjects.declare.constraints.validatorComparison", () => { // unfilled slot — write an assertion that fails when: An end date before its start date, stored, and every report that spans them silently empty. }); +// Exactly one — An exactly-one rule requires that one member of a declared group is given, and no other. +test.skip("metaobjects.declare.constraints.validatorExactlyOne", () => { + // unfilled slot — write an assertion that fails when: A booking tied to both a store and a kiosk, so neither can tell whose it is. +}); + // Length bounds — A length rule bounds how long a value may be, and an explicitly declared bound is authoritative over any implicit floor. test.skip("metaobjects.declare.constraints.validatorLength", () => { // unfilled slot — write an assertion that fails when: A declared minimum of zero raised to one behind the author's back, so provided-but-empty becomes inexpressible. @@ -467,6 +472,11 @@ test.skip("metaobjects.declare.typedFieldVocabulary.fieldDouble", () => { // unfilled slot — write an assertion that fails when: A running total accumulated in one, where the sum of the parts no longer equals the whole. }); +// Span of time — A duration field holds a fixed length of time as a span, not as a count whose unit only the code knows. +test.skip("metaobjects.declare.typedFieldVocabulary.fieldDuration", () => { + // unfilled slot — write an assertion that fails when: A timeout read as seconds by one service and as milliseconds by another. +}); + // Enumeration — An enumeration field holds one member of a closed declared set, and the member symbol is what the wire and the runtime see whatever the column stores. test.skip("metaobjects.declare.typedFieldVocabulary.fieldEnum", () => { // unfilled slot — write an assertion that fails when: A stored value that is no declared member, handed back as though it were one. diff --git a/metaobjects/meta.requirements.yaml b/metaobjects/meta.requirements.yaml index dcd229a300..fedf9a3b70 100644 --- a/metaobjects/meta.requirements.yaml +++ b/metaobjects/meta.requirements.yaml @@ -206,6 +206,18 @@ metadata.root: counterexample: >- An opening hour that cannot be declared without inventing a date to hang it on. + - requirement.functional: + name: fieldDuration + title: Span of time + level: 4 + status: live + statement: >- + A duration field holds a fixed length of time as a + span, not as a count whose unit only the code + knows. + counterexample: >- + A timeout read as seconds by one service and as + milliseconds by another. - requirement.functional: name: fieldTimestamp title: Instant @@ -1370,6 +1382,17 @@ metadata.root: counterexample: >- A contact saved with neither an email address nor a phone number, and no way to reach them. + - requirement.functional: + name: validatorExactlyOne + title: Exactly one + level: 4 + status: live + statement: >- + An exactly-one rule requires that one member of a + declared group is given, and no other. + counterexample: >- + A booking tied to both a store and a kiosk, so + neither can tell whose it is. - requirement.functional: name: presentation diff --git a/server/csharp/MetaObjects/Config/MetadataLocation.cs b/server/csharp/MetaObjects/Config/MetadataLocation.cs index cbc250800e..cdb83c612b 100644 --- a/server/csharp/MetaObjects/Config/MetadataLocation.cs +++ b/server/csharp/MetaObjects/Config/MetadataLocation.cs @@ -53,6 +53,11 @@ public sealed record ResolvedMetadata( /// csharp.providers. Hosts that load by hand can pass it to MetaDataLoader.FromDirectory. public TypeRegistry Registry() => Provider.ComposeRegistry(ProviderLoader.Compose(Providers)); + /// The project's enum storage defaults (enums in config.json), set on the loaded + /// root by . An init property, not a positional parameter, so the record's + /// constructor and Deconstruct keep the signatures they had. + public EnumDefaults? Enums { get; init; } + /// Load this metadata, with the project's libraries, and set the loaded /// root's from (ADR-0058) and its /// from . @@ -66,6 +71,7 @@ public LoadResult Load(bool strict = false) Directory, Registry(), Libraries, leading, strict: strict); result.Root.PhysicalNaming = Naming; result.Root.WireFormat = Wire ?? WireFormat.Default; + result.Root.EnumDefaults = Enums ?? EnumDefaults.Default; if (Imports is null) return result; result.Root.ImportScope = Imports; @@ -130,7 +136,7 @@ public static ResolvedMetadata Resolve(string? metadataDir, string cwd) // No config found means no project to read a lock from: nothing is imported. var explicitImports = explicitCfg is null ? ImportScope.None : ImportScope.Resolve(explicitCfg.ConfigDir, explicitCfg); return new ResolvedMetadata( - metadataDir, null, explicitLibs, explicitNaming, explicitCfg?.Wire, explicitCfg?.OutDir, explicitImports, explicitCfg?.Namespace, LoadProviders(explicitCfg)); + metadataDir, null, explicitLibs, explicitNaming, explicitCfg?.Wire, explicitCfg?.OutDir, explicitImports, explicitCfg?.Namespace, LoadProviders(explicitCfg)) { Enums = explicitCfg?.Enums }; } var cfg = NeutralConfig.Read(cwd); @@ -145,7 +151,7 @@ public static ResolvedMetadata Resolve(string? metadataDir, string cwd) // No declared sources: resolve the DEFAULT directory through the same ladder // the shared conformance corpus gates (ERR_COLLECTION_NOT_FOUND when absent). var defaultFiles = SourceResolver.ResolveCollection(cwd); - return new ResolvedMetadata(Path.Combine(cwd, NeutralConfig.DefaultMetadataDir), defaultFiles, libraries, naming, wire, cfg?.OutDir, imports, cfg?.Namespace, LoadProviders(cfg)); + return new ResolvedMetadata(Path.Combine(cwd, NeutralConfig.DefaultMetadataDir), defaultFiles, libraries, naming, wire, cfg?.OutDir, imports, cfg?.Namespace, LoadProviders(cfg)) { Enums = cfg?.Enums }; } if (specs.Count > 1) @@ -175,6 +181,6 @@ public static ResolvedMetadata Resolve(string? metadataDir, string cwd) "\"sources\" at the file's containing directory."); } - return new ResolvedMetadata(resolved, files, libraries, naming, wire, cfg?.OutDir, imports, cfg?.Namespace, LoadProviders(cfg)); + return new ResolvedMetadata(resolved, files, libraries, naming, wire, cfg?.OutDir, imports, cfg?.Namespace, LoadProviders(cfg)) { Enums = cfg?.Enums }; } } diff --git a/server/csharp/MetaObjects/Config/NeutralConfig.cs b/server/csharp/MetaObjects/Config/NeutralConfig.cs index 4b2eb8c078..50e3bb75b3 100644 --- a/server/csharp/MetaObjects/Config/NeutralConfig.cs +++ b/server/csharp/MetaObjects/Config/NeutralConfig.cs @@ -29,7 +29,7 @@ // generated code lands in; the other ports ignore the block. // // `wire` (ADR-0019 Amendment 2) is read here too — the output form of a generated REST surface -// (`decimals`, `nullMembers`), refused exactly as `naming` is. +// (`decimals`, `nullMembers`, `durations`), refused exactly as `naming` is. using System.Text.Json; using MetaObjects.Meta; @@ -87,6 +87,10 @@ public sealed class NeutralConfig /// absent key at its native default. Never null. public WireFormat Wire { get; } + /// The project's enum storage defaults (`enums` in config.json). Never null: an absent + /// block is no default, same as an empty `enums: {}`. + public EnumDefaults Enums { get; private init; } = EnumDefaults.Default; + /// The C# port's generated-code directory (`csharp.outDir` in config.json), resolved /// against the directory holding `.metaobjects/` so it reads the same from any working /// directory. Null when the key is absent. `--out` on the command line wins over it. @@ -257,7 +261,7 @@ private NeutralConfig( case "columns": if (p.Value.ValueKind != JsonValueKind.String || !TryParseColumnNaming(p.Value.GetString()!, out columns)) throw new MetaModelException( - $"{path}: \"naming.columns\" must be one of \"snake_case\", \"literal\", \"kebab-case\"", + $"{path}: \"naming.columns\" must be one of \"snake_case\", \"literal\", \"kebab-case\", \"upperFirst\"", ErrorCode.ERR_BAD_ATTR_VALUE); break; case "tables": @@ -278,7 +282,7 @@ private NeutralConfig( // lists read above, are IGNORED by design — see the file header. return new NeutralConfig(specs, libraries, new PhysicalNaming(columns, tables), ReadWire(root, path), ReadCSharp(root, path, configDir), dependencies, scopeInclude, migrateScope, - Path.GetFullPath(configDir)); + Path.GetFullPath(configDir)) { Enums = ReadEnums(root, path) }; } } @@ -382,6 +386,38 @@ private static IReadOnlyList ReadProviders(JsonElement value, stri return specs; } + /// The `enums` block: `maxLength`, a positive integer, is the column length of a string-stored + /// enum with no @maxLength. An unknown key, a wrong-typed value, or a non-object block is + /// refused, the same rule as `naming`. + private static EnumDefaults ReadEnums(JsonElement root, string path) + { + if (!root.TryGetProperty("enums", out var enums)) return EnumDefaults.Default; + if (enums.ValueKind != JsonValueKind.Object) + throw new MetaModelException($"{path}: \"enums\" must be an object", ErrorCode.ERR_BAD_ATTR_VALUE); + long? maxLength = null; + foreach (var p in enums.EnumerateObject()) + { + switch (p.Name) + { + case "maxLength": + // A whole number however JSON spells it (32, 32.0, 1e2): a reader that parses + // numbers as doubles (TypeScript's) cannot tell those apart. + if (p.Value.ValueKind != JsonValueKind.Number || !p.Value.TryGetDouble(out var d) + || d <= 0 || d != Math.Floor(d) || d > long.MaxValue) + throw new MetaModelException( + $"{path}: \"enums.maxLength\" must be a positive integer", + ErrorCode.ERR_BAD_ATTR_VALUE); + maxLength = (long)d; + break; + default: + throw new MetaModelException( + $"{path}: \"enums\" has an unknown key \"{p.Name}\" (expected \"maxLength\")", + ErrorCode.ERR_BAD_ATTR_VALUE); + } + } + return maxLength is null ? EnumDefaults.Default : new EnumDefaults(maxLength); + } + /// The `wire` block (ADR-0019 Amendment 2): an unknown key, an unknown value, or a /// non-object block is refused, the same rule as `naming`. private static WireFormat ReadWire(JsonElement root, string path) @@ -392,6 +428,7 @@ private static WireFormat ReadWire(JsonElement root, string path) var decimals = DecimalWireForm.Native; var nullMembers = NullMemberForm.Native; + var durations = DurationWireForm.Native; foreach (var p in wire.EnumerateObject()) { var raw = p.Value.ValueKind == JsonValueKind.String ? p.Value.GetString() : null; @@ -418,13 +455,23 @@ private static WireFormat ReadWire(JsonElement root, string path) ErrorCode.ERR_BAD_ATTR_VALUE), }; break; + case "durations": + durations = raw switch + { + "native" => DurationWireForm.Native, + "iso" => DurationWireForm.Iso, + _ => throw new MetaModelException( + $"{path}: \"wire.durations\" must be one of \"native\", \"iso\"", + ErrorCode.ERR_BAD_ATTR_VALUE), + }; + break; default: throw new MetaModelException( - $"{path}: \"wire\" has an unknown key \"{p.Name}\" (expected \"decimals\" and/or \"nullMembers\")", + $"{path}: \"wire\" has an unknown key \"{p.Name}\" (expected \"decimals\", \"nullMembers\" and/or \"durations\")", ErrorCode.ERR_BAD_ATTR_VALUE); } } - return new WireFormat(decimals, nullMembers); + return new WireFormat(decimals, nullMembers, durations); } /// The cross-port spellings (ADR-0058) — not .NET enum names, so the JSON value reads @@ -436,6 +483,7 @@ private static bool TryParseColumnNaming(string raw, out ColumnNamingStrategy st case "literal": strategy = ColumnNamingStrategy.Literal; return true; case "snake_case": strategy = ColumnNamingStrategy.SnakeCase; return true; case "kebab-case": strategy = ColumnNamingStrategy.KebabCase; return true; + case "upperFirst": strategy = ColumnNamingStrategy.UpperFirst; return true; default: strategy = ColumnNamingStrategy.Literal; return false; } } diff --git a/server/csharp/MetaObjects/Core/Attr/ExpressionGrammar.cs b/server/csharp/MetaObjects/Core/Attr/ExpressionGrammar.cs index 82b59bc3d4..ac96a913b3 100644 --- a/server/csharp/MetaObjects/Core/Attr/ExpressionGrammar.cs +++ b/server/csharp/MetaObjects/Core/Attr/ExpressionGrammar.cs @@ -57,6 +57,23 @@ public static class ExpressionGrammar FILTER_COMPOSE_AND, FILTER_COMPOSE_OR, }; + /// The field names an expression tree references, in order of appearance. + public static List FieldRefs(object? node) + { + var refs = new List(); + void Walk(object? x) + { + if (x is not IReadOnlyDictionary n) return; + if (Get(n, KEY_FIELD) is string f) refs.Add(f); + Walk(Get(n, KEY_LEFT)); + Walk(Get(n, KEY_RIGHT)); + Walk(Get(n, KEY_ARG)); + if (Get(n, KEY_ARGS) is IEnumerable args) foreach (var a in args) Walk(a); + } + Walk(node); + return refs; + } + // ------------------------------------------------------------------------- // Structural validation — known node kinds, ops, arity. No entity/type // context (see InferExprType for typing). Returns [] when valid. diff --git a/server/csharp/MetaObjects/Core/Field/FieldConstants.cs b/server/csharp/MetaObjects/Core/Field/FieldConstants.cs index 5daaedb59a..22c891a0df 100644 --- a/server/csharp/MetaObjects/Core/Field/FieldConstants.cs +++ b/server/csharp/MetaObjects/Core/Field/FieldConstants.cs @@ -60,6 +60,16 @@ public static class FieldConstants /// public const string FIELD_SUBTYPE_INET = "inet"; + /// + /// field.duration: a fixed-length span of time (days, hours, minutes, seconds). A + /// concept with a native type + behavior, so a subtype. String-backed on the wire (an + /// ISO-8601 duration: every port writes [-]PT[nH][nM][n[.f]S]); native binding is + /// System.TimeSpan in entity codegen and a Postgres-native interval column + /// (a 64-bit microsecond count elsewhere). Weeks and days are exactly 7 days and 24 hours; + /// years and months are refused: a calendar span has no fixed length. The wire rule and its cross-port vectors are in fixtures/duration-wire/. + /// + public const string FIELD_SUBTYPE_DURATION = "duration"; + public static readonly string[] FIELD_SUBTYPES = [ BaseTypes.SUBTYPE_BASE, @@ -80,6 +90,7 @@ public static class FieldConstants FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, + FIELD_SUBTYPE_DURATION, ]; // ----------------------------------------------------------------------- diff --git a/server/csharp/MetaObjects/Core/Query/QueryConstants.cs b/server/csharp/MetaObjects/Core/Query/QueryConstants.cs index 722a630b0e..3173122ec0 100644 --- a/server/csharp/MetaObjects/Core/Query/QueryConstants.cs +++ b/server/csharp/MetaObjects/Core/Query/QueryConstants.cs @@ -50,6 +50,11 @@ public static class QueryConstants ["enum"] = [FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_IN, FILTER_OP_LIKE, FILTER_OP_IS_NULL], // uuid: identity-comparison only — no `like` (not a substring type), no ordering. ["uuid"] = [FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_IN, FILTER_OP_IS_NULL], + // uri: a URI string, free-text comparable — the string band (`like` prefix match is + // meaningful for URLs). + ["uri"] = [FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_IN, FILTER_OP_LIKE, FILTER_OP_IS_NULL], + // inet: an IP address — identity comparison only, the uuid band. + ["inet"] = [FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_IN, FILTER_OP_IS_NULL], ["int"] = [FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_GT, FILTER_OP_GTE, FILTER_OP_LT, FILTER_OP_LTE, FILTER_OP_IN, FILTER_OP_IS_NULL], ["long"] = [FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_GT, FILTER_OP_GTE, FILTER_OP_LT, FILTER_OP_LTE, FILTER_OP_IN, FILTER_OP_IS_NULL], ["double"] = [FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_GT, FILTER_OP_GTE, FILTER_OP_LT, FILTER_OP_LTE, FILTER_OP_IN, FILTER_OP_IS_NULL], @@ -61,6 +66,9 @@ public static class QueryConstants ["date"] = [FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_GT, FILTER_OP_GTE, FILTER_OP_LT, FILTER_OP_LTE, FILTER_OP_IN, FILTER_OP_IS_NULL], ["time"] = [FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_GT, FILTER_OP_GTE, FILTER_OP_LT, FILTER_OP_LTE, FILTER_OP_IN, FILTER_OP_IS_NULL], ["timestamp"] = [FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_GT, FILTER_OP_GTE, FILTER_OP_LT, FILTER_OP_LTE, FILTER_OP_IN, FILTER_OP_IS_NULL], + // duration: an orderable span — a native interval on Postgres, an integer count of + // microseconds on SQLite / MySQL, so ordering holds on every database. + ["duration"] = [FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_GT, FILTER_OP_GTE, FILTER_OP_LT, FILTER_OP_LTE, FILTER_OP_IN, FILTER_OP_IS_NULL], }; /// diff --git a/server/csharp/MetaObjects/Core/Validator/ValidatorConstants.cs b/server/csharp/MetaObjects/Core/Validator/ValidatorConstants.cs index 5c1e4e8892..9dc7b88c6c 100644 --- a/server/csharp/MetaObjects/Core/Validator/ValidatorConstants.cs +++ b/server/csharp/MetaObjects/Core/Validator/ValidatorConstants.cs @@ -22,6 +22,7 @@ public static class ValidatorConstants 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 const string VALIDATOR_SUBTYPE_EXACTLY_ONE = "exactlyOne"; public static readonly string[] VALIDATOR_SUBTYPES = [ @@ -35,6 +36,7 @@ public static class ValidatorConstants VALIDATOR_SUBTYPE_REQUIRED_WHEN, VALIDATOR_SUBTYPE_PRESENT_IFF, VALIDATOR_SUBTYPE_AT_LEAST_ONE, + VALIDATOR_SUBTYPE_EXACTLY_ONE, ]; // Validator attr keys (used by codegen-ts when reading validator children) @@ -49,4 +51,17 @@ public static class ValidatorConstants public const string VALIDATOR_ATTR_WHEN = "when"; public const string VALIDATOR_ATTR_EQUALS = "equals"; public const string VALIDATOR_ATTR_FIELDS = "fields"; + /// comparison / requiredWhen / presentIff / atLeastOne / exactlyOne: the case the rule applies in (an attr.expression). + public const string VALIDATOR_ATTR_CONDITION = "condition"; + /// A CHECK-generating validator (db provider): the database name of that CHECK. + public const string VALIDATOR_ATTR_CONSTRAINT_NAME = "constraintName"; + + /// The validators meta migrate renders as a CHECK (and so may carry @constraintName): + /// numeric, length, regex and the cross-field ones. required is NOT NULL; array and base have none. + public static readonly IReadOnlyList CHECK_VALIDATOR_SUBTYPES = + [ + 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_SUBTYPE_EXACTLY_ONE, + ]; } diff --git a/server/csharp/MetaObjects/Core/Validator/ValidatorSchema.cs b/server/csharp/MetaObjects/Core/Validator/ValidatorSchema.cs index 42d3b756a3..05744c362c 100644 --- a/server/csharp/MetaObjects/Core/Validator/ValidatorSchema.cs +++ b/server/csharp/MetaObjects/Core/Validator/ValidatorSchema.cs @@ -26,6 +26,11 @@ public static class ValidatorSchema Description: "Maximum allowed value (length, numeric value, or array element count depending on the validator subtype)."), ]; + /// @condition on a cross-field validator: the case the rule applies in (an attr.expression). + private static readonly AttrSchema ConditionAttr = new(Name: ValidatorConstants.VALIDATOR_ATTR_CONDITION, + ValueType: AttrConstants.ATTR_SUBTYPE_EXPRESSION, Required: false, + Description: "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1."); + /// /// Attrs per validator subtype. required uses none; regex adds @pattern; /// length / numeric / array (and the base) use @min / @max. @@ -57,6 +62,7 @@ public static class ValidatorSchema 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."), + ConditionAttr, ], [ValidatorConstants.VALIDATOR_SUBTYPE_REQUIRED_WHEN] = [ @@ -66,6 +72,7 @@ public static class ValidatorSchema 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."), + ConditionAttr, ], [ValidatorConstants.VALIDATOR_SUBTYPE_PRESENT_IFF] = [ @@ -75,11 +82,19 @@ public static class ValidatorSchema 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."), + ConditionAttr, ], [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."), + ConditionAttr, + ], + [ValidatorConstants.VALIDATOR_SUBTYPE_EXACTLY_ONE] = + [ + new AttrSchema(Name: ValidatorConstants.VALIDATOR_ATTR_FIELDS, ValueType: AttrConstants.ATTR_SUBTYPE_STRING, + Required: true, IsArray: true, Description: "Names of the alternative fields; exactly one must be present."), + ConditionAttr, ], }; } diff --git a/server/csharp/MetaObjects/CoreTypes.cs b/server/csharp/MetaObjects/CoreTypes.cs index 328b4c0632..4926ed6b00 100644 --- a/server/csharp/MetaObjects/CoreTypes.cs +++ b/server/csharp/MetaObjects/CoreTypes.cs @@ -152,6 +152,9 @@ private static TypeDefinition Def( // native System.Uri / System.Net.IPAddress bindings are build-time codegen concerns. [FIELD_SUBTYPE_URI] = DataType.String, [FIELD_SUBTYPE_INET] = DataType.String, + // field.duration is string-backed on the wire (an ISO-8601 duration); the native + // System.TimeSpan binding is a build-time codegen concern. + [FIELD_SUBTYPE_DURATION] = DataType.String, [FIELD_SUBTYPE_DOUBLE] = DataType.Double, [FIELD_SUBTYPE_FLOAT] = DataType.Double, [FIELD_SUBTYPE_DECIMAL] = DataType.Double, @@ -332,7 +335,7 @@ private static void RegisterCoreTypeDefs(TypeRegistry registry) objectAttrs)); } - // field — 18 subtypes (base + 17) + // field — 19 subtypes (base + 18) List fieldRules = [ Wildcard(TYPE_VALIDATOR), diff --git a/server/csharp/MetaObjects/Errors.cs b/server/csharp/MetaObjects/Errors.cs index b98e164351..e06baf297b 100644 --- a/server/csharp/MetaObjects/Errors.cs +++ b/server/csharp/MetaObjects/Errors.cs @@ -306,6 +306,17 @@ public enum ErrorCode // default @normalize: strip; rename a member or set collapse / none. ERR_ENUM_NORMALIZE_COLLISION, ERR_UNKNOWN, + // Appended, never inserted: a member's integer value is its position, so adding one + // mid-enum would renumber every member after it in a patch release. + // @flattenedColumnPrefix on a field.object whose @storage is not "flattened" (absent is jsonb). + ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE, + // field.enum @maxLength: a member longer than the column, or a length on an int-stored enum. + ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH, + ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP, + // identity.primary @sequenceStart / @sequenceName without @generation "increment". + ERR_SEQUENCE_WITHOUT_INCREMENT, + // identity.primary @sequenceStart below 1, or past what its key field can hold. + ERR_SEQUENCE_START_OUT_OF_RANGE, } /// diff --git a/server/csharp/MetaObjects/Loader/MetaDataLoader.cs b/server/csharp/MetaObjects/Loader/MetaDataLoader.cs index f5796338fa..2d8d311a12 100644 --- a/server/csharp/MetaObjects/Loader/MetaDataLoader.cs +++ b/server/csharp/MetaObjects/Loader/MetaDataLoader.cs @@ -171,6 +171,7 @@ internal static LoadResult FromDirectory(string directory, TypeRegistry registry { result.Root.PhysicalNaming = cfg.Naming; result.Root.WireFormat = cfg.Wire; + result.Root.EnumDefaults = cfg.Enums; } return result; } @@ -535,6 +536,9 @@ public LoadResult Load(IReadOnlyList sources) // Pass 8b: field.map — exactly one of @valueType (scalar) or @objectRef (VO), // and @valueType (when set) must name a known scalar subtype. errors.AddRange(ValidationPasses.ValidateFieldMap(root)); + errors.AddRange(ValidationPasses.ValidateEnumMaxLength(root)); + errors.AddRange(ValidationPasses.ValidateIdentitySequence(root)); + errors.AddRange(ValidationPasses.ValidateCrossFieldValidators(root)); // Pass 9: template @payloadRef / @requiredSlots resolution errors.AddRange(ValidationPasses.ValidateTemplatePayloadRefs(root)); diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs index 7d11a0ca44..963fe6dce7 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs @@ -2090,6 +2090,262 @@ private static void ValidateAttrSchemaNode( } } + // --------------------------------------------------------------------------- + // Cross-field validators (comparison / requiredWhen / presentIff / atLeastOne / exactlyOne), + // which the database enforces as CHECKs (ERR_BAD_ATTR_VALUE): exactlyOne @fields names two or + // more fields (one is just a required field); every field a @condition names is a field of the owning entity; + // and the @condition can become a CHECK guard (ConditionProblem). Read RESOLVING + // (ADR-0039). Mirrors TS validateCrossFieldValidators. + // --------------------------------------------------------------------------- + private static readonly HashSet CrossFieldValidatorSubtypes = new(StringComparer.Ordinal) + { + VALIDATOR_SUBTYPE_COMPARISON, VALIDATOR_SUBTYPE_REQUIRED_WHEN, + VALIDATOR_SUBTYPE_PRESENT_IFF, VALIDATOR_SUBTYPE_AT_LEAST_ONE, VALIDATOR_SUBTYPE_EXACTLY_ONE, + }; + + public static IReadOnlyList ValidateCrossFieldValidators(MetaData root) + { + var errors = new List(); + foreach (var obj in root.Children().OfType()) + { + var fieldNames = obj.Fields().Select(f => f.Name).ToHashSet(StringComparer.Ordinal); + foreach (var v in obj.OwnChildren().Where(c => c.Type == TYPE_VALIDATOR && CrossFieldValidatorSubtypes.Contains(c.SubType))) + { + if (v.SubType == VALIDATOR_SUBTYPE_EXACTLY_ONE) + { + var fields = v.Attr(VALIDATOR_ATTR_FIELDS) switch + { + string s => 1, + IReadOnlyList ss => ss.Count, + IReadOnlyList os => os.Count, + _ => 0, + }; + if (fields < 2) + { + errors.Add(new MetaError( + $"validator.exactlyOne \"{obj.Name}.{v.Name}\" @fields names {fields} field{(fields == 1 ? "" : "s")}; " + + "it needs two or more. For one field, make that field required.", + ErrorCode.ERR_BAD_ATTR_VALUE, + Envelope: v.Source)); + continue; + } + } + var condition = v.Attr(VALIDATOR_ATTR_CONDITION); + if (condition is null) continue; + var label = $"validator.{v.SubType} \"{obj.Name}.{v.Name}\" @condition"; + var unknown = MetaObjects.Core.Attr.ExpressionGrammar.FieldRefs(condition) + .Distinct(StringComparer.Ordinal).Where(f => !fieldNames.Contains(f)) + .OrderBy(f => f, StringComparer.Ordinal).ToList(); + if (unknown.Count > 0) + { + errors.Add(new MetaError( + $"{label} names {string.Join(", ", unknown.Select(f => $"\"{f}\""))}, " + + $"which {(unknown.Count == 1 ? "is not a field" : "are not fields")} of \"{obj.Name}\".", + ErrorCode.ERR_BAD_ATTR_VALUE, + Envelope: v.Source)); + continue; + } + if (ConditionProblem(condition, obj) is { } problem) + { + errors.Add(new MetaError($"{label} {problem}", ErrorCode.ERR_BAD_ATTR_VALUE, Envelope: v.Source)); + } + } + } + return errors; + } + + /// Field subtypes whose values a condition compares with a number literal. + private static readonly HashSet ConditionNumberSubtypes = new(StringComparer.Ordinal) + { + FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, + FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_CURRENCY, + }; + + /// + /// Why a cross-field validator's @condition cannot become a CHECK guard, or null when + /// it can. Its names are already known to be fields. The rule, the same in every port: + /// it types cleanly (the origin.computed inference) and its root is boolean, since a CHECK + /// negates it; a literal compared with a field is of that field's kind (a boolean for + /// field.boolean, a number for a numeric field, a string otherwise) and, for field.enum, one + /// of its @values; and no comparison has a null literal (x = NULL is never true, so + /// use isNull / isNotNull). Mirrors TS conditionProblem. + /// + private static string? ConditionProblem(object condition, MetaObject obj) + { + var fields = new Dictionary(StringComparer.Ordinal); + foreach (var f in obj.Fields()) fields.TryAdd(f.Name, f); + var inferred = MetaObjects.Core.Attr.ExpressionGrammar.InferExprType( + condition, name => fields.TryGetValue(name, out var f) ? f.SubType : null); + if (inferred.Errors.Count > 0) return $"is not a valid expression: {string.Join("; ", inferred.Errors)}."; + if (inferred.Type != FIELD_SUBTYPE_BOOLEAN) + { + return $"must be a boolean expression (a comparison, a null test, or and / or / not of them), not a field.{inferred.Type ?? "undefined"}."; + } + string? problem = null; + void Visit(object? node) + { + if (problem is not null || node is not IReadOnlyDictionary n) return; + if (n.TryGetValue("left", out var left) && left is not null && n.TryGetValue("right", out var right) && right is not null) + { + foreach (var (a, b) in new[] { (left, right), (right, left) }) + { + if (a is not IReadOnlyDictionary lit || !lit.TryGetValue("value", out var value)) continue; + if (value is null) + { + problem = "compares with null, which is never true: use isNull or isNotNull."; + return; + } + if (b is not IReadOnlyDictionary refNode + || !refNode.TryGetValue("field", out var refName) || refName is not string name + || !fields.TryGetValue(name, out var field)) + { + continue; + } + var kind = field.SubType == FIELD_SUBTYPE_BOOLEAN ? "boolean" + : ConditionNumberSubtypes.Contains(field.SubType) ? "number" : "string"; + var actual = value switch + { + bool => "boolean", + long or int or double => "number", + string => "string", + _ => null, + }; + if (actual != kind) + { + problem = $"compares \"{field.Name}\" (field.{field.SubType}) with {JsonStringify(value)}; it needs a {kind}."; + return; + } + var members = field.SubType == FIELD_SUBTYPE_ENUM ? field.EffectiveEnumValues : null; + if (members is not null && !members.Contains(Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture), StringComparer.Ordinal)) + { + problem = $"compares \"{field.Name}\" with {JsonStringify(value)}, which is not one of its @values ({string.Join(", ", members)})."; + return; + } + } + } + n.TryGetValue("left", out var l); + Visit(l); + n.TryGetValue("right", out var r); + Visit(r); + n.TryGetValue("arg", out var g); + Visit(g); + if (n.TryGetValue("args", out var args) && args is IEnumerable list) + { + foreach (var x in list) Visit(x); + } + } + Visit(condition); + return problem; + } + + // --------------------------------------------------------------------------- + // identity.primary @sequenceStart / @sequenceName describe the sequence an @generation + // "increment" key draws from; on any other generation there is none, so either one is + // refused (ERR_SEQUENCE_WITHOUT_INCREMENT). Read RESOLVING (ADR-0039), reported on the + // identity that declares one of them. Mirrors TS validateIdentitySequence. + // --------------------------------------------------------------------------- + public static IReadOnlyList ValidateIdentitySequence(MetaData root) + { + var errors = new List(); + foreach (var obj in root.Children().Where(c => c.Type == TYPE_OBJECT)) + { + foreach (var id in obj.OwnChildren().Where(c => c.Type == TYPE_IDENTITY && c.SubType == IDENTITY_SUBTYPE_PRIMARY)) + { + var declared = new[] { MetaObjects.Persistence.Db.DbConstants.IDENTITY_ATTR_SEQUENCE_START, MetaObjects.Persistence.Db.DbConstants.IDENTITY_ATTR_SEQUENCE_NAME } + .Where(a => id.OwnAttr(a) is not null).ToList(); + var generation = id.Attr(IDENTITY_ATTR_GENERATION) as string; + if (declared.Count == 0) continue; + if (generation == GENERATION_INCREMENT) + { + // The key field's own range bounds the start (Postgres refuses the sequence + // otherwise, at apply time): 1 to the largest value of a field.int / field.long. + if (id.Attr(MetaObjects.Persistence.Db.DbConstants.IDENTITY_ATTR_SEQUENCE_START) is long start) + { + var keyName = id.Attr(IDENTITY_ATTR_FIELDS) switch + { + string s => s, + IReadOnlyList ss => ss.FirstOrDefault(), + IReadOnlyList os => os.FirstOrDefault() as string, + _ => null, + }; + var key = (obj as MetaObject)?.Fields().FirstOrDefault(f => f.Name == keyName); + long? max = key?.SubType == FIELD_SUBTYPE_INT ? int.MaxValue : null; + if (start < 1 || (max is { } m && start > m)) + { + errors.Add(new MetaError( + $"identity.primary \"{obj.Name}.{id.Name}\" @sequenceStart {start} is out of range for its key " + + $"\"{keyName}\"{(key is not null ? $" (field.{key.SubType})" : "")}: it must be at least 1" + + $"{(max is { } mx ? $" and at most {mx}" : "")}.", + ErrorCode.ERR_SEQUENCE_START_OUT_OF_RANGE, + Envelope: id.Source)); + } + } + continue; + } + var shown = generation is null ? "not set" : $"\"{generation}\""; + var names = string.Join(" and ", declared.Select(a => "@" + a)); + errors.Add(new MetaError( + $"identity.primary \"{obj.Name}.{id.Name}\" sets {names}, but its @generation is {shown}; " + + $"only an @generation \"increment\" key draws from a sequence. Set @generation \"increment\", or remove {names}.", + ErrorCode.ERR_SEQUENCE_WITHOUT_INCREMENT, + Envelope: id.Source)); + } + } + return errors; + } + + // --------------------------------------------------------------------------- + // field.enum @maxLength — the column length of a string-stored enum: + // 1. every member of @values fits in it (ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH), the + // longest offender named; + // 2. it is refused beside @intValueMap (ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP): an + // int-stored enum's column is an integer, so a length would change nothing. + // Read RESOLVING (ADR-0039), reported on the node that DECLARES one of the three, so an + // enum that only inherits them is not reported twice. Every field.enum in the tree is + // checked, root-level abstract enums included. Mirrors TS validateEnumMaxLength. + // --------------------------------------------------------------------------- + public static IReadOnlyList ValidateEnumMaxLength(MetaData root) + { + var errors = new List(); + void Visit(MetaData node) + { + foreach (var child in node.OwnChildren()) + { + if (child.Type == TYPE_FIELD && child.SubType == FIELD_SUBTYPE_ENUM && child is MetaField f) CheckEnum(f); + if (child.Type != TYPE_FIELD) Visit(child); + } + } + void CheckEnum(MetaField field) + { + var declares = new[] { FIELD_ATTR_MAX_LENGTH, FIELD_ATTR_VALUES, FIELD_ATTR_INT_VALUE_MAP } + .Any(a => field.OwnAttr(a) is not null); + if (!declares || field.MaxLength is not { } maxLength) return; + var owner = field.Parent; + var label = owner is not null && owner.Type == TYPE_OBJECT ? $"{owner.Name}.{field.Name}" : field.Name; + if (field.Attr(FIELD_ATTR_INT_VALUE_MAP) is not null) + { + errors.Add(new MetaError( + $"field.enum \"{label}\" sets @maxLength {maxLength} and @intValueMap; an @intValueMap enum is stored as an integer, so a column length does not apply. Remove @maxLength.", + ErrorCode.ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP, + Envelope: field.Source)); + return; + } + var tooLong = (field.EffectiveEnumValues ?? []) + .Where(v => v.Length > maxLength) + .OrderByDescending(v => v.Length) + .FirstOrDefault(); + if (tooLong is not null) + { + errors.Add(new MetaError( + $"field.enum \"{label}\" @maxLength {maxLength} is shorter than its member \"{tooLong}\" ({tooLong.Length} characters). Raise @maxLength to at least {tooLong.Length}, or shorten the member.", + ErrorCode.ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH, + Envelope: field.Source)); + } + } + Visit(root); + return errors; + } + // ------------------------------------------------------------------------- // Attr-type check helper — mirrors valueMatchesType() in attr-schema-validate.ts // @@ -2924,6 +3180,8 @@ private static void WalkDbColumnType(MetaData node, List errors) // now a clear load-time error.) // - @storage "flattened" requires isArray=false (cannot flatten a // variable-length array) → ERR_STORAGE_FLATTENED_ARRAY + // - @flattenedColumnPrefix requires @storage "flattened" (no other storage gives the + // members columns of their own to prefix) → ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE // // Ported from validateFieldObjectStorage in // typescript/packages/metadata/src/loader/validation-passes.ts. @@ -2959,6 +3217,20 @@ public static IReadOnlyList ValidateFieldObjectStorage(MetaData root) // ADR-0039: resolving — @storage and array-ness may be inherited via extends. var storage = field.Attr(FIELD_ATTR_STORAGE); + // @flattenedColumnPrefix names the columns a flattened value object adds to the owning + // table; under any other storage it would change nothing, so it is refused + // rather than silently ignored. + if (field.Attr(MetaObjects.Persistence.Db.DbConstants.FIELD_ATTR_FLATTENED_COLUMN_PREFIX) is not null + && !(storage is string sp && sp == STORAGE_FLATTENED)) + { + var storageText = storage is null ? "not set (jsonb)" : $"\"{storage}\""; + errors.Add(new MetaError( + $"field.object \"{obj.Name}.{field.Name}\" sets @flattenedColumnPrefix, but its @storage is {storageText}; " + + "only a @storage \"flattened\" value object puts its members' columns in the owning table. " + + "Set @storage \"flattened\", or remove @flattenedColumnPrefix.", + ErrorCode.ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE, + Envelope: field.Source)); + } if (storage is null) continue; if (storage is string st && st == STORAGE_FLATTENED && field.ResolvedIsArray()) @@ -4458,6 +4730,35 @@ private static bool ReadsThroughSqlView(MetaData obj) sources.Any(s => s.EffectiveKind == SourceConstants.SOURCE_KIND_VIEW && s.SqlBody is not null); } + /// One physical column a field contributes to its object's table. + private sealed record PhysicalColumn(string Column, string Label, MetaField Named, bool Embedded); + + // A field.object whose RESOLVED @storage is flattened has no column of its own: each member + // of its value object is one, named by PhysicalNaming.ResolveFlattenedColumnName exactly as + // migrate creates it. Any other field is one column under its own resolved name. + private static IEnumerable PhysicalColumnsOf(MetaField field) + { + if (field.SubType == FIELD_SUBTYPE_OBJECT && field.Storage == STORAGE_FLATTENED) + { + if (NamingRefs.RefTarget(field, FIELD_ATTR_OBJECT_REF) is not MetaObject target) yield break; + foreach (var member in target.Fields()) + yield return new PhysicalColumn( + PhysicalNaming.ResolveFlattenedColumnName(field, member), + $"{field.Name}.{member.Name}", member, true); + yield break; + } + yield return new PhysicalColumn(PhysicalNaming.ResolveColumnName(field), field.Name, field, false); + } + + private static string CollisionHint(PhysicalColumn a, PhysicalColumn b) + { + if (a.Embedded || b.Embedded) + return "Give one flattened field a different @flattenedColumnPrefix, or rename one field."; + return a.Named.DbColumn is { Length: > 0 } && b.Named.DbColumn is { Length: > 0 } + ? "Rename one field, or point one @column at a different column." + : "Rename one field, or give one an explicit @column so the two columns differ."; + } + public static IReadOnlyList ValidateColumnNameCollisions(MetaData root) { var errors = new List(); @@ -4466,24 +4767,23 @@ public static IReadOnlyList ValidateColumnNameCollisions(MetaData roo if (obj.IsAbstract) continue; if (!obj.Children().Any(c => c.Type == TYPE_SOURCE)) continue; if (ReadsThroughSqlView(obj)) continue; - var seen = new Dictionary(StringComparer.Ordinal); + var seen = new Dictionary(StringComparer.Ordinal); foreach (var field in obj.Children().OfType()) { - if (field.SubType == FIELD_SUBTYPE_OBJECT || field.IsDerived()) continue; - var column = PhysicalNaming.ResolveColumnName(field); - if (!seen.TryGetValue(column, out var first)) + if (field.IsDerived()) continue; + foreach (var col in PhysicalColumnsOf(field)) { - seen[column] = field; - continue; + if (!seen.TryGetValue(col.Column, out var first)) + { + seen[col.Column] = col; + continue; + } + errors.Add(new MetaError( + $"fields \"{first.Label}\" and \"{col.Label}\" on \"{obj.Name}\" both resolve to " + + $"physical column \"{col.Column}\". " + CollisionHint(first, col), + ErrorCode.ERR_COLUMN_NAME_COLLISION, + Envelope: field.Source)); } - errors.Add(new MetaError( - $"fields \"{first.Name}\" and \"{field.Name}\" on \"{obj.Name}\" both resolve to " + - $"physical column \"{column}\". " + - (first.DbColumn is { Length: > 0 } && field.DbColumn is { Length: > 0 } - ? "Rename one field, or point one @column at a different column." - : "Rename one field, or give one an explicit @column so the two columns differ."), - ErrorCode.ERR_COLUMN_NAME_COLLISION, - Envelope: field.Source)); } } return errors; diff --git a/server/csharp/MetaObjects/Meta/EnumLength.cs b/server/csharp/MetaObjects/Meta/EnumLength.cs new file mode 100644 index 0000000000..9990ff980c --- /dev/null +++ b/server/csharp/MetaObjects/Meta/EnumLength.cs @@ -0,0 +1,54 @@ +// The column length of a string-stored field.enum: the field's @maxLength, else the project's +// default (`enums.maxLength` in .metaobjects/config.json, carried on the loaded root beside the +// naming rules), else none — an unbounded text column. Mirrors TS enum-length.ts. + +using static MetaObjects.Core.Field.FieldConstants; +using static MetaObjects.Shared.BaseTypes; + +namespace MetaObjects.Meta; + +/// The project's enum storage defaults: enums in .metaobjects/config.json. +/// The column length of a string-stored enum that declares no @maxLength. +public sealed record EnumDefaults(long? MaxLength) +{ + /// No project default: an enum with no @maxLength is an unbounded text column. + public static readonly EnumDefaults Default = new((long?)null); + + /// The enum defaults for the tree belongs to: what its root carries, else none. + public static EnumDefaults Of(MetaData node) => (node.Root() as MetaRoot)?.EnumDefaults ?? Default; +} + +/// Core resolution of a string-stored enum's column length. +public static class EnumLength +{ + /// + /// The column length of , a string-stored field.enum: its + /// @maxLength (ADR-0039 — resolving), else the project default, else null. Null for an + /// @intValueMap enum, whose column is an integer, for an array enum (a text[] column whose + /// elements carry no length), and for any other field. + /// The loader refuses an @maxLength a member does not fit; it does not see the + /// project config, so a member longer than the project DEFAULT is refused here + /// (ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH), by every consumer that sizes the column. + /// + public static long? ResolveMaxLength(MetaField field) + { + if (field.SubType != FIELD_SUBTYPE_ENUM || field.Attr(FIELD_ATTR_INT_VALUE_MAP) is not null) return null; + // An array enum is a text[] column, whose elements carry no length. + if (field.ResolvedIsArray()) return null; + if (field.MaxLength is { } own) return own; + if (EnumDefaults.Of(field).MaxLength is not { } fallback) return null; + var longest = (field.EffectiveEnumValues ?? []) + .OrderByDescending(v => v.Length).FirstOrDefault(); + if (longest is not null && longest.Length > fallback) + { + var owner = field.Parent; + var label = owner is not null && owner.Type == TYPE_OBJECT ? $"{owner.Name}.{field.Name}" : field.Name; + throw new MetaModelException( + $"field.enum \"{label}\" has the member \"{longest}\" ({longest.Length} characters), longer than " + + $"the project's enums.maxLength {fallback}. Give the field its own @maxLength of at least " + + $"{longest.Length}, raise enums.maxLength, or shorten the member.", + ErrorCode.ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH); + } + return fallback; + } +} diff --git a/server/csharp/MetaObjects/Meta/MetaField.cs b/server/csharp/MetaObjects/Meta/MetaField.cs index 7c819e65b4..9d55b9e9d6 100644 --- a/server/csharp/MetaObjects/Meta/MetaField.cs +++ b/server/csharp/MetaObjects/Meta/MetaField.cs @@ -67,6 +67,18 @@ public string? DbColumn } } + /// Flattened-member column prefix (@flattenedColumnPrefix on a field.object + /// @storage "flattened"); "" is a value, null is absent. + /// ADR-0039: resolving — inheritable via extends. + public string? FlattenedColumnPrefix + { + get + { + var v = Attr(FIELD_ATTR_FLATTENED_COLUMN_PREFIX); + return v is string s ? s : null; + } + } + /// /// R6 Plan 2b — physical DB column-type override (own-only @dbColumnType): /// uuid / jsonb, or null when absent. Own-only diff --git a/server/csharp/MetaObjects/Meta/MetaRoot.cs b/server/csharp/MetaObjects/Meta/MetaRoot.cs index cf7ada93a6..d0235e3f04 100644 --- a/server/csharp/MetaObjects/Meta/MetaRoot.cs +++ b/server/csharp/MetaObjects/Meta/MetaRoot.cs @@ -27,6 +27,13 @@ public class MetaRoot(TypeId typeId, string name) : MetaData(typeId, name) /// public WireFormat WireFormat { get; set; } = WireFormat.Default; + /// + /// The project's enum storage defaults (enums in .metaobjects/config.json), set by + /// the same project-aware load as and read through + /// . A tree loaded with no project has none. + /// + public EnumDefaults EnumDefaults { get; set; } = EnumDefaults.Default; + /// /// FR-023 — what the project imports from metadata dependencies, set by the same project-aware load /// as . Null for a tree loaded with no project (nothing is imported). diff --git a/server/csharp/MetaObjects/Meta/PhysicalNaming.cs b/server/csharp/MetaObjects/Meta/PhysicalNaming.cs index 0844b9eb1a..095a012426 100644 --- a/server/csharp/MetaObjects/Meta/PhysicalNaming.cs +++ b/server/csharp/MetaObjects/Meta/PhysicalNaming.cs @@ -23,6 +23,10 @@ public enum ColumnNamingStrategy SnakeCase, /// camelCase / PascalCase → kebab-case. KebabCase, + /// upperFirst: the first letter upper-cased, nothing else changed + /// (requestURL → RequestURL), so a camelCase model maps onto a schema whose + /// columns are its field names with a capital first letter, with no @column. + UpperFirst, } /// @@ -64,17 +68,54 @@ public static PhysicalNaming Of(MetaData node) => /// A field's physical column name: its explicit @column (resolving, so an /// inherited override counts), else the field name through the project's column strategy. /// The loader's collision check compares THESE resolved names, not the raw field names. - public static string ResolveColumnName(MetaField field) + public static string ResolveColumnName(MetaField field) => + ResolveColumnName(field, PhysicalNaming.Of(field).Columns); + + /// under an explicit column rule — for + /// verify --codegen's probe of an alternate rule, never a second configured source. + public static string ResolveColumnName(MetaField field, ColumnNamingStrategy strategy) { if (field.DbColumn is { Length: > 0 } explicitColumn) return explicitColumn; - return PhysicalNaming.Of(field).Columns switch + return strategy switch { ColumnNamingStrategy.SnakeCase => SourceNaming.ToSnakeCase(field.Name), ColumnNamingStrategy.KebabCase => SourceNaming.ToSnakeCase(field.Name).Replace('_', '-'), + ColumnNamingStrategy.UpperFirst => ToUpperFirstColumn(field.Name), _ => field.Name, }; } + /// + /// The text before each member's column when (a field.object + /// @storage "flattened") expands its value object into the owning table: its + /// @flattenedColumnPrefix verbatim ("" allowed), else its own resolved column and + /// _. Core naming, not one generator's: the migration creates these columns and the + /// generated ORM mappings address them, so every one of them spells them here. + /// ADR-0039: resolving — @flattenedColumnPrefix and @column may be inherited. + /// + public static string ResolveFlattenedColumnPrefix(MetaField field) => + ResolveFlattenedColumnPrefix(field, PhysicalNaming.Of(field).Columns); + + /// under an explicit column rule. + public static string ResolveFlattenedColumnPrefix(MetaField field, ColumnNamingStrategy strategy) => + field.FlattenedColumnPrefix ?? ResolveColumnName(field, strategy) + "_"; + + /// The column a member of a flattened value object gets in the owning table: the + /// embedding field's prefix () followed + /// by the member's own resolved column. + public static string ResolveFlattenedColumnName(MetaField field, MetaField member) => + ResolveFlattenedColumnName(field, member, PhysicalNaming.Of(field).Columns); + + /// under an explicit column rule. + public static string ResolveFlattenedColumnName(MetaField field, MetaField member, ColumnNamingStrategy strategy) => + ResolveFlattenedColumnPrefix(field, strategy) + ResolveColumnName(member, strategy); + + /// The upperFirst column rule: the first letter upper-cased, the rest + /// verbatim (one UTF-16 unit in, one out). Not a word split — an acronym keeps the case it + /// was written in. + public static string ToUpperFirstColumn(string name) => + name.Length == 0 ? name : char.ToUpperInvariant(name[0]) + name[1..]; + /// cased by the table rule — no pluralization (the /// caller decides whether to pluralize; see ). public static string ApplyTableNamingRule(string name, TableNamingRule rule) => diff --git a/server/csharp/MetaObjects/Meta/WireFormat.cs b/server/csharp/MetaObjects/Meta/WireFormat.cs index 20a78f7697..34a0867b13 100644 --- a/server/csharp/MetaObjects/Meta/WireFormat.cs +++ b/server/csharp/MetaObjects/Meta/WireFormat.cs @@ -26,15 +26,24 @@ public enum NullMemberForm Omit, } +/// How a field.duration is written and read (wire.durations). +public enum DurationWireForm +{ + /// The default: System.Text.Json's own TimeSpan form, 01:30:00, both ways. + Native, + /// Canonical ISO-8601 (PT1H30M), the form every port writes alike. + Iso, +} + /// /// The project's wire-format setting: wire in .metaobjects/config.json -/// (ADR-0019 Amendment 2). Both keys default to native, each port's own language-standard output; -/// { "decimals": "string", "nullMembers": "omit" } is the cross-port form. +/// (ADR-0019 Amendment 2). Every key defaults to native, each port's own language-standard output; +/// { "decimals": "string", "nullMembers": "omit", "durations": "iso" } is the cross-port form. /// -public sealed record WireFormat(DecimalWireForm Decimals, NullMemberForm NullMembers) +public sealed record WireFormat(DecimalWireForm Decimals, NullMemberForm NullMembers, DurationWireForm Durations = DurationWireForm.Native) { - /// Both keys native: what a tree loaded with no project carries. - public static readonly WireFormat Default = new(DecimalWireForm.Native, NullMemberForm.Native); + /// Every key native: what a tree loaded with no project carries. + public static readonly WireFormat Default = new(DecimalWireForm.Native, NullMemberForm.Native, DurationWireForm.Native); /// The setting the tree belongs to carries, else . public static WireFormat Of(MetaData node) => @@ -51,7 +60,10 @@ public static WireFormat Of(MetaData node) => _ => "native", }; + /// The wire.durations spelling of . + public static string Spelling(DurationWireForm form) => form == DurationWireForm.Iso ? "iso" : "native"; + /// This setting as the wire block that declares it. public string Block() => - $"\"wire\": {{ \"decimals\": \"{Spelling(Decimals)}\", \"nullMembers\": \"{Spelling(NullMembers)}\" }}"; + $"\"wire\": {{ \"decimals\": \"{Spelling(Decimals)}\", \"nullMembers\": \"{Spelling(NullMembers)}\", \"durations\": \"{Spelling(Durations)}\" }}"; } diff --git a/server/csharp/MetaObjects/Parser.cs b/server/csharp/MetaObjects/Parser.cs index 356d7cfb54..81c6209374 100644 --- a/server/csharp/MetaObjects/Parser.cs +++ b/server/csharp/MetaObjects/Parser.cs @@ -974,7 +974,7 @@ private static void DetectAttrMergeConflicts( continue; } - if (IsEmptyAttrValue(newValRaw) || IsEmptyAttrValue(existingVal)) continue; + if (IsEmptyAttrValue(newValRaw, attrName) || IsEmptyAttrValue(existingVal, attrName)) continue; // Both sides set a non-empty value — compare. If equal, last-writer-wins // is a no-op; if different, that's a conflict. if (AttrValuesEqual(existingVal, newValRaw)) continue; @@ -998,10 +998,14 @@ private static void DetectAttrMergeConflicts( } } - private static bool IsEmptyAttrValue(object? v) + /// Attrs whose empty string is a meaningful value rather than "not set" (FR5c). + private static readonly HashSet EmptyStringIsAValue = + new(StringComparer.Ordinal) { MetaObjects.Persistence.Db.DbConstants.FIELD_ATTR_FLATTENED_COLUMN_PREFIX }; + + private static bool IsEmptyAttrValue(object? v, string attrName) { if (v is null) return true; - if (v is string s && s == "") return true; + if (v is string s && s == "") return !EmptyStringIsAValue.Contains(attrName); if (v is System.Collections.ICollection col && col.Count == 0) return true; return false; } diff --git a/server/csharp/MetaObjects/RegistryManifest.cs b/server/csharp/MetaObjects/RegistryManifest.cs index b59670f15d..8f0ee03576 100644 --- a/server/csharp/MetaObjects/RegistryManifest.cs +++ b/server/csharp/MetaObjects/RegistryManifest.cs @@ -375,7 +375,7 @@ private static ManifestType ToManifestType(TypeDefinition def) => /// unstable (semver major-0). The 1.0 cut flips this to "1.0". /// Emitted as the FIRST top-level key in the registry manifest (C4). /// - public const string MetamodelVersion = "1.2"; + public const string MetamodelVersion = "1.3"; /// /// Emit the canonical registry manifest as a byte-stable JSON string. diff --git a/server/csharp/MetaObjects/SpecMetamodel/db.json b/server/csharp/MetaObjects/SpecMetamodel/db.json index 44c1048d73..4e087e72f0 100644 --- a/server/csharp/MetaObjects/SpecMetamodel/db.json +++ b/server/csharp/MetaObjects/SpecMetamodel/db.json @@ -21,6 +21,7 @@ "type": "field", "subType": "object", "children": [ + { "type": "attr", "subType": "string", "name": "flattenedColumnPrefix", "min": 0, "max": 1, "description": "On a field.object with @storage \"flattened\": the text put before each member's column, verbatim (\"\" allowed, so the members keep their own column names). Absent: this field's resolved column name and \"_\" (with @column \"bill\", the members are bill_). A fact about the owning table, so it lives on the embedding field, never on the value object. Refused on any other storage (ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE). Justified in ADR-0023 Amendment 1." }, { "type": "attr", "subType": "string", "name": "storage", "min": 0, "max": 1, "allowedValues": ["flattened", "jsonb", "subdocument"], "description": "Storage strategy for an object-typed field (set with @objectRef). \"flattened\" expands the nested value into prefixed columns on the parent table. \"jsonb\" stores the structured value in a single jsonb column (supports isArray=true for arrays of values). \"subdocument\" is a hint for document-store codegen targets and emits no Postgres column." } ] }, @@ -58,6 +59,28 @@ { "type": "attr", "subType": "string", "name": "using", "min": 0, "max": 1, "description": "Index access method (e.g. \"gin\", \"gist\", \"hash\"); default \"btree\" (not rendered). Pair with @expr for e.g. a GIN index over an array/jsonb expression. RDB-physical — contributed by the db provider." } ] }, + { + "type": "field", + "subType": "enum", + "children": [ + { "type": "attr", "subType": "string", "name": "constraintName", "min": 0, "max": 1, "description": "The database name of the CHECK constraint this enum's member list generates. Absent:
__chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." } + ] + }, + { + "type": "validator", + "subType": ["numeric", "length", "regex", "comparison", "requiredWhen", "presentIff", "atLeastOne", "exactlyOne"], + "children": [ + { "type": "attr", "subType": "string", "name": "constraintName", "min": 0, "max": 1, "description": "The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." } + ] + }, + { + "type": "identity", + "subType": "primary", + "children": [ + { "type": "attr", "subType": "long", "name": "sequenceStart", "min": 0, "max": 1, "description": "The first value an @generation \"increment\" key's sequence hands out (Postgres: GENERATED BY DEFAULT AS IDENTITY (START WITH n)). Absent: 1. Lets a model reproduce a table that keeps a low id range for seeded rows. Refused without @generation \"increment\" (ERR_SEQUENCE_WITHOUT_INCREMENT). RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." }, + { "type": "attr", "subType": "string", "name": "sequenceName", "min": 0, "max": 1, "description": "The physical name of an @generation \"increment\" key's sequence (Postgres: GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME ...)). Absent: the database's own default,
__seq on Postgres. Lets a model adopt a sequence that kept its name across a table rename. Refused without @generation \"increment\" (ERR_SEQUENCE_WITHOUT_INCREMENT). RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." } + ] + }, { "type": "identity", "subType": "reference", diff --git a/server/csharp/MetaObjects/SpecMetamodel/field.json b/server/csharp/MetaObjects/SpecMetamodel/field.json index b054bda765..3b4a4ce9fc 100644 --- a/server/csharp/MetaObjects/SpecMetamodel/field.json +++ b/server/csharp/MetaObjects/SpecMetamodel/field.json @@ -151,6 +151,7 @@ "children": [ { "type": "attr", "subType": "string", "name": "values", "isArray": true, "min": 1, "max": 1, "description": "Member symbols of an enum-subtype field. Declaration order is significant; each is a legal identifier and its own stored string." }, { "type": "attr", "subType": "boolean", "name": "provided", "min": 0, "max": 1, "description": "FR-019: marks an abstract package-level field.enum as externally provided — codegen references the type (resolved via per-port codegen config) instead of materializing it. Default false. Not a field attr — it lives on the type declaration." }, + { "type": "attr", "subType": "int", "name": "maxLength", "min": 0, "max": 1, "description": "Column length of a string-stored enum (drives VARCHAR(n)); every member must fit. Overrides the project's enums.maxLength. Refused with @intValueMap, whose column is an integer. Justified in ADR-0023 Amendment 1." }, { "type": "attr", "subType": "intMap", "name": "intValueMap", "min": 0, "max": 1, "description": "Optional per-member int values ({member: int}) switching this enum field's DB persistence from string+CHECK to integer+CHECK. Keys must exactly match @values; values must be unique integers. The generated native type and wire format are unchanged in every language." } ] }, @@ -183,6 +184,14 @@ "children": [ { "type": "attr", "subType": "boolean", "name": "lenient", "min": 0, "max": 1, "description": "#234: opt this field out of strict well-formedness enforcement. When true, codegen binds a plain string (no URL/IP validator, no native URI/InetAddress type; field.inet uses a text column, not the native inet type) so a not-strictly-valid value round-trips unchanged. Default (absent/false) is strict: an absolute-scheme URI / an IPv4-or-IPv6 literal." } ] + }, + { + "type": "field", + "subType": "duration", + "dataType": "string", + "extendsBase": true, + "description": "A fixed-length span of time: days, hours, minutes and seconds (ADR-0036/0037 — a concept with a native type + behavior). Binds to TS string (TS has no native duration type); other ports bind to their native type (C# System.TimeSpan, Java and Kotlin java.time.Duration, Python datetime.timedelta). DB column is the Postgres-native interval type; SQLite, D1 and MySQL store a 64-bit integer count of microseconds, which orders and compares as the span does. The wire form is an ISO-8601 duration: every port writes [-]PT[nH][nM][n[.f]S] (hours never folded into days, as many fractional digits as its native type holds, zero is PT0S) and reads that form plus leading week and day counts (nW, exactly 7 days; nD, exactly 24 hours) and a fraction of any length, truncated toward zero. Years and months are refused: a calendar span is not a fixed length. Justified in ADR-0023 Amendment 1.", + "whenToUse": "A field holds an elapsed time, a timeout or a delay. Use it for a native Postgres interval column and a native duration type instead of a string or a number of seconds. A calendar span (\"every 3 months\") is not a duration: model it as an int and a unit." } ] } diff --git a/server/csharp/MetaObjects/SpecMetamodel/validator.json b/server/csharp/MetaObjects/SpecMetamodel/validator.json index 9520605bd7..4aba88dbd3 100644 --- a/server/csharp/MetaObjects/SpecMetamodel/validator.json +++ b/server/csharp/MetaObjects/SpecMetamodel/validator.json @@ -66,7 +66,8 @@ "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": "attr", "subType": "string", "name": "right", "min": 1, "max": 1, "description": "Name of the right-hand field of the owning entity." }, + { "type": "attr", "subType": "expression", "name": "condition", "min": 0, "max": 1, "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." } ] }, { @@ -78,7 +79,8 @@ "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": "attr", "subType": "string", "name": "equals", "min": 1, "max": 1, "description": "The gating value; when @when equals this, @field must be present." }, + { "type": "attr", "subType": "expression", "name": "condition", "min": 0, "max": 1, "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." } ] }, { @@ -90,7 +92,8 @@ "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": "attr", "subType": "string", "name": "equals", "min": 1, "max": 1, "description": "The gating value; @field is present exactly when @when equals this." }, + { "type": "attr", "subType": "expression", "name": "condition", "min": 0, "max": 1, "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." } ] }, { @@ -100,7 +103,19 @@ "whenToUse": "At least one of several fields must be present (email or phone). Declare it instead of hand-written OR-presence checks.", "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." } + { "type": "attr", "subType": "string", "name": "fields", "isArray": true, "min": 1, "max": 1, "description": "Names of the candidate fields; at least one must be present." }, + { "type": "attr", "subType": "expression", "name": "condition", "min": 0, "max": 1, "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." } + ] + }, + { + "type": "validator", + "subType": "exactlyOne", + "description": "Cardinality of presence: exactly one of the named fields (@fields) is present (NOT NULL), and the others are null. Entity-scoped; references fields by name (same @fields-by-name pattern as identity.*). Justified in ADR-0023 Amendment 1.", + "whenToUse": "A row belongs to exactly one of several alternatives (a store or a kiosk, never both, never neither). Declare it instead of hand-written count-of-non-nulls checks.", + "rules": "@fields names two or more fields of the owning entity (ERR_BAD_ATTR_VALUE): one field would just be a required field. Satisfied when exactly one of them is non-null.", + "children": [ + { "type": "attr", "subType": "string", "name": "fields", "isArray": true, "min": 1, "max": 1, "description": "Names of the alternative fields; exactly one must be present." }, + { "type": "attr", "subType": "expression", "name": "condition", "min": 0, "max": 1, "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." } ] } ] diff --git a/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java b/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java index 3f2433e6c5..f3f7830ae2 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java +++ b/server/java/metadata/src/main/java/com/metaobjects/ErrorCode.java @@ -299,6 +299,13 @@ public enum ErrorCode { /** @storage "flattened" cannot be combined with isArray=true. */ ERR_STORAGE_FLATTENED_ARRAY, + /** @flattenedColumnPrefix on a field.object whose @storage is not "flattened" (absent is jsonb). */ + ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE, + ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH, + ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP, + ERR_SEQUENCE_WITHOUT_INCREMENT, + ERR_SEQUENCE_START_OUT_OF_RANGE, + /** @storage was set on a field that has no @objectRef. */ ERR_STORAGE_WITHOUT_OBJECT_REF, diff --git a/server/java/metadata/src/main/java/com/metaobjects/MetaRoot.java b/server/java/metadata/src/main/java/com/metaobjects/MetaRoot.java index ec99e0c8c5..de98496cd6 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/MetaRoot.java +++ b/server/java/metadata/src/main/java/com/metaobjects/MetaRoot.java @@ -213,6 +213,22 @@ public void setPhysicalNaming(com.metaobjects.database.PhysicalNaming physicalNa private transient com.metaobjects.config.WireFormat wireFormat = com.metaobjects.config.WireFormat.DEFAULT; + /** + * The project's enum storage defaults ({@code enums} in {@code .metaobjects/config.json}), + * set by the same project-aware load as the naming rules and read through + * {@link com.metaobjects.config.EnumDefaults#of(MetaData)}. A tree loaded with no project has none. + */ + private transient com.metaobjects.config.EnumDefaults enumDefaults = + com.metaobjects.config.EnumDefaults.DEFAULT; + + public com.metaobjects.config.EnumDefaults getEnumDefaults() { + return enumDefaults; + } + + public void setEnumDefaults(com.metaobjects.config.EnumDefaults enumDefaults) { + this.enumDefaults = (enumDefaults != null) ? enumDefaults : com.metaobjects.config.EnumDefaults.DEFAULT; + } + /** The project's wire-format setting for this tree. Never {@code null}. */ public com.metaobjects.config.WireFormat getWireFormat() { return wireFormat; diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/EnumDefaults.java b/server/java/metadata/src/main/java/com/metaobjects/config/EnumDefaults.java new file mode 100644 index 0000000000..2a0c19006c --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/config/EnumDefaults.java @@ -0,0 +1,35 @@ +package com.metaobjects.config; + +import com.metaobjects.MetaData; +import com.metaobjects.MetaRoot; + +/** + * The project's enum storage defaults: {@code enums} in {@code .metaobjects/config.json}. + * {@code maxLength} is the column length (VARCHAR(n)) of a string-stored {@code field.enum} + * that declares no {@code @maxLength}; absent means no default (an unbounded text column). + * Carried on the loaded root beside the naming rules. Mirrors TS {@code enum-length.ts}. + */ +public final class EnumDefaults { + + /** No project default. */ + public static final EnumDefaults DEFAULT = new EnumDefaults(null); + + private final Long maxLength; + + public EnumDefaults(Long maxLength) { + this.maxLength = maxLength; + } + + /** The default column length, or {@code null} when the project declares none. */ + public Long getMaxLength() { + return maxLength; + } + + /** The enum defaults for the tree {@code node} belongs to: what its root carries, else none. */ + public static EnumDefaults of(MetaData node) { + for (MetaData n = node; n != null; n = n.getParent()) { + if (n instanceof MetaRoot root) return root.getEnumDefaults(); + } + return DEFAULT; + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java b/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java index 7c30658a46..f6d0282e6a 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java +++ b/server/java/metadata/src/main/java/com/metaobjects/config/NeutralConfig.java @@ -69,6 +69,7 @@ public final class NeutralConfig { private final List migrateScope; private final PhysicalNaming physicalNaming; private final WireFormat wireFormat; + private EnumDefaults enumDefaults = EnumDefaults.DEFAULT; private NeutralConfig(List> sources, List> dependencies, List scopeInclude, List migrateScope, @@ -134,6 +135,11 @@ public WireFormat getWireFormat() { return wireFormat; } + /** The project's enum storage defaults ({@code enums} in this file). Never {@code null}. */ + public EnumDefaults getEnumDefaults() { + return enumDefaults; + } + /** * Returns empty when {@code /.metaobjects/config.json} does not exist. * A file that EXISTS but is malformed throws — swallowing it would make a typo'd @@ -328,8 +334,41 @@ public static NeutralConfig parse(String content, String origin) { // Unknown top-level keys, and every key of `scope`/`migrate` but the two read above, // are IGNORED by design — see the class javadoc. - return new NeutralConfig(specs, dependencies, scopeInclude, migrateScope, physicalNaming, + NeutralConfig cfg = new NeutralConfig(specs, dependencies, scopeInclude, migrateScope, physicalNaming, wireFormat); + cfg.enumDefaults = parseEnums(root.get("enums"), origin); + return cfg; + } + + /** + * The {@code enums} block: {@code maxLength}, a positive integer, is the column length of a + * string-stored enum with no {@code @maxLength}. An unknown key, a wrong-typed value, or a + * non-object block is refused, the same rule as {@code naming}. + */ + private static EnumDefaults parseEnums(JsonElement enums, String origin) { + if (enums == null) return EnumDefaults.DEFAULT; + if (!enums.isJsonObject()) { + throw new MetaDataException(origin + ": \"enums\" must be an object", ErrorCode.ERR_BAD_ATTR_VALUE); + } + Long maxLength = null; + JsonObject obj = enums.getAsJsonObject(); + for (String key : obj.keySet()) { + if (!"maxLength".equals(key)) { + throw new MetaDataException( + origin + ": \"enums\" has unknown key \"" + key + "\"; expected \"maxLength\"", + ErrorCode.ERR_BAD_ATTR_VALUE); + } + JsonElement v = obj.get(key); + java.math.BigDecimal n = v.isJsonPrimitive() && v.getAsJsonPrimitive().isNumber() + ? v.getAsBigDecimal() : null; + if (n == null || n.signum() <= 0 || n.stripTrailingZeros().scale() > 0) { + throw new MetaDataException( + origin + ": \"enums.maxLength\" must be a positive integer", + ErrorCode.ERR_BAD_ATTR_VALUE); + } + maxLength = n.longValueExact(); + } + return maxLength == null ? EnumDefaults.DEFAULT : new EnumDefaults(maxLength); } /** Mirrors the TS {@code DependencyName} regex (DESIGN §3.1). */ @@ -438,6 +477,7 @@ private static WireFormat parseWire(JsonElement wire, String origin) { JsonObject wireObj = wire.getAsJsonObject(); WireFormat.Decimals decimals = WireFormat.Decimals.NATIVE; WireFormat.NullMembers nullMembers = WireFormat.NullMembers.NATIVE; + WireFormat.Durations durations = WireFormat.Durations.NATIVE; for (String key : wireObj.keySet()) { JsonElement el = wireObj.get(key); String raw = el.isJsonPrimitive() && el.getAsJsonPrimitive().isString() ? el.getAsString() : null; @@ -458,12 +498,20 @@ private static WireFormat parseWire(JsonElement wire, String origin) { ErrorCode.ERR_BAD_ATTR_VALUE); } } + case "durations" -> { + durations = raw == null ? null : WireFormat.durationsOf(raw); + if (durations == null) { + throw new MetaDataException( + origin + ": \"wire.durations\" must be one of \"native\", \"iso\"", + ErrorCode.ERR_BAD_ATTR_VALUE); + } + } default -> throw new MetaDataException( origin + ": \"wire\" has unknown key \"" + key - + "\"; expected \"decimals\" and/or \"nullMembers\"", + + "\"; expected \"decimals\", \"nullMembers\" and/or \"durations\"", ErrorCode.ERR_BAD_ATTR_VALUE); } } - return new WireFormat(decimals, nullMembers); + return new WireFormat(decimals, nullMembers, durations); } } diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/SourceResolver.java b/server/java/metadata/src/main/java/com/metaobjects/config/SourceResolver.java index b9985dbc8a..fac3312f07 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/config/SourceResolver.java +++ b/server/java/metadata/src/main/java/com/metaobjects/config/SourceResolver.java @@ -225,6 +225,11 @@ public static java.util.Optional discoverWireFormat(List discoverEnumDefaults(List uris) { + return discoverConfig(uris).map(NeutralConfig::getEnumDefaults); + } + /** * ADR-0058 — the physical-naming rules of the {@code .metaobjects/config.json} found by * walking up from the first {@code file:} URI (stopping after a directory holding diff --git a/server/java/metadata/src/main/java/com/metaobjects/config/WireFormat.java b/server/java/metadata/src/main/java/com/metaobjects/config/WireFormat.java index 044dccfb4f..2cb6f790e9 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/config/WireFormat.java +++ b/server/java/metadata/src/main/java/com/metaobjects/config/WireFormat.java @@ -23,12 +23,15 @@ * {@code .metaobjects/config.json}): the OUTPUT form of a generated REST surface. Input rules * (W2, W3) never change with it. * - *

Both keys default to {@code native}: what the language's standard serializer writes (for + *

Every key defaults to {@code native}: what the language's standard serializer writes (for * Java and Kotlin, Jackson as the host configures it: a decimal is a JSON number, a value * object's null member is {@code null} with the key present). {@code "decimals": "string"} and - * {@code "nullMembers": "omit"} are the cross-port form the conformance corpora pin.

+ * {@code "nullMembers": "omit"} and {@code "durations": "iso"} are the cross-port form the + * conformance corpora pin. A {@code native} duration is Jackson's own {@code java.time} form + * ({@code Duration.toString()} / {@code Duration.parse} under Spring Boot's default of durations + * not written as timestamps).

*/ -public record WireFormat(Decimals decimals, NullMembers nullMembers) { +public record WireFormat(Decimals decimals, NullMembers nullMembers, Durations durations) { /** How a {@code field.decimal} is written. */ public enum Decimals { @@ -62,12 +65,33 @@ public enum NullMembers { public String wireName() { return wireName; } } - /** Both keys at {@code native}: what a tree loaded with no project carries. */ - public static final WireFormat DEFAULT = new WireFormat(Decimals.NATIVE, NullMembers.NATIVE); + /** How a {@code field.duration} is written and read. */ + public enum Durations { + /** The language's standard serializer: Jackson's {@code java.time} handling of a {@code Duration}. */ + NATIVE("native"), + /** Canonical ISO-8601 ({@code PT1H30M}), the rule in {@code fixtures/duration-wire/}. */ + ISO("iso"); + + private final String wireName; + + Durations(String wireName) { this.wireName = wireName; } + + /** The value as spelled in {@code .metaobjects/config.json}. */ + public String wireName() { return wireName; } + } + + /** Every key at {@code native}: what a tree loaded with no project carries. */ + public static final WireFormat DEFAULT = new WireFormat(Decimals.NATIVE, NullMembers.NATIVE, Durations.NATIVE); public WireFormat { if (decimals == null) decimals = Decimals.NATIVE; if (nullMembers == null) nullMembers = NullMembers.NATIVE; + if (durations == null) durations = Durations.NATIVE; + } + + /** True when a duration is written and read as canonical ISO-8601 (the duration wire annotations are emitted). */ + public boolean durationsIso() { + return durations == Durations.ISO; } /** True when a decimal is written as the canonical string (the serializer annotation is emitted). */ @@ -92,6 +116,12 @@ public static NullMembers nullMembersOf(String raw) { return null; } + /** The {@code durations} value spelled {@code raw}, or {@code null} when it names none. */ + public static Durations durationsOf(String raw) { + for (Durations d : Durations.values()) if (d.wireName.equals(raw)) return d; + return null; + } + /** The wire setting {@code node}'s root carries, or {@link #DEFAULT} for a detached node. */ public static WireFormat of(MetaData node) { for (MetaData n = node; n != null; n = n.getParent()) { diff --git a/server/java/metadata/src/main/java/com/metaobjects/database/ColumnNaming.java b/server/java/metadata/src/main/java/com/metaobjects/database/ColumnNaming.java index 0726e5bc07..690a051764 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/database/ColumnNaming.java +++ b/server/java/metadata/src/main/java/com/metaobjects/database/ColumnNaming.java @@ -45,6 +45,13 @@ private ColumnNaming() {} public static final String SNAKE_CASE = "snake_case"; /** camelCase / PascalCase to kebab-case. */ public static final String KEBAB_CASE = "kebab-case"; + /** + * The first letter upper-cased, nothing else changed ({@code requestURL} to + * {@code RequestURL}, {@code id} to {@code Id}): for a schema whose columns are named like + * the fields with an upper-case first letter, so a camelCase model maps onto it with no + * {@code @column}. Not a word split: one UTF-16 unit in, one out. + */ + public static final String UPPER_FIRST = "upperFirst"; /** The project default (ADR-0058) — what {@code meta migrate} has always created. */ public static final String DEFAULT = SNAKE_CASE; @@ -83,9 +90,16 @@ public static String apply(String name, String strategy) { if (LITERAL.equals(strategy)) return name; if (SNAKE_CASE.equals(strategy)) return toSnakeCase(name); if (KEBAB_CASE.equals(strategy)) return toSnakeCase(name).replace('_', '-'); + if (UPPER_FIRST.equals(strategy)) return toUpperFirstColumn(name); throw new IllegalArgumentException( "unknown column-naming strategy '" + strategy + "'; expected one of: " - + LITERAL + ", " + SNAKE_CASE + ", " + KEBAB_CASE); + + LITERAL + ", " + SNAKE_CASE + ", " + KEBAB_CASE + ", " + UPPER_FIRST); + } + + /** The {@link #UPPER_FIRST} rule: the first letter upper-cased, the rest verbatim. */ + public static String toUpperFirstColumn(String name) { + if (name == null || name.isEmpty()) return name; + return Character.toUpperCase(name.charAt(0)) + name.substring(1); } /** @@ -119,6 +133,43 @@ public static String resolve(MetaField field, String strategy) { return apply(field.getName(), strategy); } + /** + * The text before each member's column when {@code field} (a {@code field.object + * @storage "flattened"}) expands its value object into the owning table: its + * {@code @flattenedColumnPrefix} verbatim ({@code ""} allowed), else its own resolved column + * and {@code _}. Core naming, not one generator's: the migration creates these columns and + * the generated ORM mappings (the Kotlin Exposed tables) address them, so every one of them + * spells them here. + * + *

ADR-0039: read RESOLVING — the prefix and {@code @column} may be inherited through + * {@code extends}.

+ */ + public static String resolveFlattenedPrefix(MetaField field, String strategy) { + if (field.hasMetaAttr(CoreDBMetaDataProvider.FLATTENED_COLUMN_PREFIX, true)) { + Object prefix = field.getMetaAttr(CoreDBMetaDataProvider.FLATTENED_COLUMN_PREFIX, true).getValue(); + if (prefix instanceof String) return (String) prefix; + } + return resolve(field, strategy) + "_"; + } + + /** {@link #resolveFlattenedPrefix(MetaField, String)} under the project's rule. */ + public static String resolveFlattenedPrefix(MetaField field) { + return resolveFlattenedPrefix(field, PhysicalNaming.of(field).columns()); + } + + /** + * The column a member of a flattened value object gets in the owning table: the embedding + * field's {@link #resolveFlattenedPrefix prefix} followed by the member's own resolved column. + */ + public static String resolveFlattened(MetaField field, MetaField member, String strategy) { + return resolveFlattenedPrefix(field, strategy) + resolve(member, strategy); + } + + /** {@link #resolveFlattened(MetaField, MetaField, String)} under the project's rule. */ + public static String resolveFlattened(MetaField field, MetaField member) { + return resolveFlattened(field, member, PhysicalNaming.of(field).columns()); + } + /** * {@code displayName} to {@code display_name}, {@code URLPath} to {@code url_path}. * diff --git a/server/java/metadata/src/main/java/com/metaobjects/database/CoreDBMetaDataProvider.java b/server/java/metadata/src/main/java/com/metaobjects/database/CoreDBMetaDataProvider.java index 8e59a9d217..5ed395ec73 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/database/CoreDBMetaDataProvider.java +++ b/server/java/metadata/src/main/java/com/metaobjects/database/CoreDBMetaDataProvider.java @@ -1,6 +1,7 @@ package com.metaobjects.database; import com.metaobjects.attr.StringAttribute; +import java.util.List; import com.metaobjects.index.Index; import com.metaobjects.identity.SecondaryIdentity; import com.metaobjects.registry.MetaDataRegistry; @@ -46,6 +47,13 @@ public class CoreDBMetaDataProvider implements MetaDataTypeProvider { */ public static final String COLUMN = "column"; + /** + * {@code @flattenedColumnPrefix} on a {@code field.object @storage "flattened"}: the text before each + * member's column, verbatim ({@code ""} allowed). Absent: the field's resolved column and + * {@code _} ({@link ColumnNaming#resolveFlattenedPrefix(com.metaobjects.field.MetaField)}). + */ + public static final String FLATTENED_COLUMN_PREFIX = "flattenedColumnPrefix"; + /** * Physical column-type attribute (R6 Plan 2b, ADR-0013). Selects the DB column type * while leaving the logical field type and its idiomatic native binding untouched — @@ -109,7 +117,7 @@ public String getProviderId() { @Override public String[] getDependencies() { // Depends on field types, object types, identity types, and index types so it loads after them. - return new String[]{"field-types", "object-types", "identity-types", "index-types"}; + return new String[]{"field-types", "object-types", "identity-types", "index-types", "validator-types"}; } @Override @@ -141,6 +149,24 @@ public void registerTypes(MetaDataRegistry registry) { .ofType(StringAttribute.SUBTYPE_STRING).asSingle(); }); + // --- @constraintName: the CHECK name of a CHECK-generating validator, and of a --- + // field.enum's membership (db.json's validator and field.enum blocks; identity.reference + // declares its own). required is NOT NULL; array and base generate no CHECK. + for (Class c : List.of( + com.metaobjects.validator.LengthValidator.class, + com.metaobjects.validator.RegexValidator.class, + com.metaobjects.validator.NumericValidator.class, + com.metaobjects.validator.ComparisonValidator.class, + com.metaobjects.validator.RequiredWhenValidator.class, + com.metaobjects.validator.PresentIffValidator.class, + com.metaobjects.validator.AtLeastOneValidator.class, + com.metaobjects.validator.ExactlyOneValidator.class, + com.metaobjects.field.EnumField.class)) { + registry.extendType(c, def -> def + .optionalAttributeWithConstraints(com.metaobjects.validator.MetaValidator.ATTR_CONSTRAINT_NAME) + .ofType(StringAttribute.SUBTYPE_STRING).asSingle()); + } + // --- index.lookup RDB-physical attrs --- registry.extendType(com.metaobjects.index.LookupIndex.class, def -> { diff --git a/server/java/metadata/src/main/java/com/metaobjects/database/EnumLength.java b/server/java/metadata/src/main/java/com/metaobjects/database/EnumLength.java new file mode 100644 index 0000000000..8867237376 --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/database/EnumLength.java @@ -0,0 +1,66 @@ +package com.metaobjects.database; + +import com.metaobjects.ErrorCode; +import com.metaobjects.MetaData; +import com.metaobjects.MetaDataException; +import com.metaobjects.config.EnumDefaults; +import com.metaobjects.field.EnumField; +import com.metaobjects.field.MetaField; +import com.metaobjects.object.MetaObject; +import com.metaobjects.util.ErrorMessageConstants; + +import java.util.List; + +/** + * The column length of a string-stored {@code field.enum}: its {@code @maxLength}, else the + * project's {@code enums.maxLength}, else none (an unbounded text column). Mirrors TS + * {@code resolveEnumMaxLength}. + */ +public final class EnumLength { + + private EnumLength() {} + + /** + * The column length of {@code field}, a string-stored {@code field.enum}: its + * {@code @maxLength} (ADR-0039 — resolving), else the project default, else {@code null}. + * {@code null} for an {@code @intValueMap} enum, whose column is an integer, and for any + * other field. + * + *

The loader refuses an {@code @maxLength} a member does not fit; it does not see the + * project config, so a member longer than the project DEFAULT is refused here + * ({@code ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH}), by every consumer that sizes the column.

+ */ + public static Long resolveMaxLength(MetaField field) { + if (!(field instanceof EnumField) || field.hasMetaAttr(EnumField.ATTR_INT_VALUE_MAP)) return null; + // An array enum is a text[] column, whose elements carry no length. + if (field.isArrayType()) return null; + if (field.hasMetaAttr(EnumField.ATTR_MAX_LENGTH)) { + Object own = field.getMetaAttr(EnumField.ATTR_MAX_LENGTH).getValue(); + if (own instanceof Number n) return n.longValue(); + } + Long fallback = EnumDefaults.of(field).getMaxLength(); + if (fallback == null) return null; + String longest = null; + Object raw = field.hasMetaAttr(EnumField.ATTR_VALUES) + ? field.getMetaAttr(EnumField.ATTR_VALUES).getValue() : null; + if (raw instanceof List values) { + for (Object o : values) { + String v = String.valueOf(o); + if (longest == null || v.length() > longest.length()) longest = v; + } + } + if (longest != null && longest.length() > fallback) { + MetaData owner = field.getParent(); + String label = owner instanceof MetaObject o + ? o.getShortName() + "." + field.getShortName() : field.getShortName(); + throw new MetaDataException( + ErrorMessageConstants.ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH + + ": field.enum \"" + label + "\" has the member \"" + longest + "\" (" + + longest.length() + " characters), longer than the project's enums.maxLength " + + fallback + ". Give the field its own @maxLength of at least " + longest.length() + + ", raise enums.maxLength, or shorten the member.", + ErrorCode.ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH); + } + return fallback; + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/database/PhysicalNaming.java b/server/java/metadata/src/main/java/com/metaobjects/database/PhysicalNaming.java index 87971a3180..a25be31c57 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/database/PhysicalNaming.java +++ b/server/java/metadata/src/main/java/com/metaobjects/database/PhysicalNaming.java @@ -46,17 +46,18 @@ private PhysicalNaming(String columns, String tables) { * whole schema to the wrong names and report success. * * @throws MetaDataException on a {@code columns} value outside - * {@link ColumnNaming#SNAKE_CASE}/{@link ColumnNaming#LITERAL}/{@link ColumnNaming#KEBAB_CASE}, - * or a {@code tables} value outside {@link #TABLE_SNAKE_CASE}/{@link #TABLE_LITERAL} - * (kebab-case is a column rule only — not a table rule). + * {@link ColumnNaming#SNAKE_CASE}/{@link ColumnNaming#LITERAL}/{@link ColumnNaming#KEBAB_CASE}/ + * {@link ColumnNaming#UPPER_FIRST}, or a {@code tables} value outside + * {@link #TABLE_SNAKE_CASE}/{@link #TABLE_LITERAL} (kebab-case and upperFirst are column + * rules only — not table rules). */ public static PhysicalNaming of(String columns, String tables) { if (!ColumnNaming.SNAKE_CASE.equals(columns) && !ColumnNaming.LITERAL.equals(columns) - && !ColumnNaming.KEBAB_CASE.equals(columns)) { + && !ColumnNaming.KEBAB_CASE.equals(columns) && !ColumnNaming.UPPER_FIRST.equals(columns)) { throw new MetaDataException( "unknown \"naming.columns\" rule \"" + columns + "\" in .metaobjects/config.json; " + "expected one of: " + ColumnNaming.SNAKE_CASE + ", " + ColumnNaming.LITERAL - + ", " + ColumnNaming.KEBAB_CASE, + + ", " + ColumnNaming.KEBAB_CASE + ", " + ColumnNaming.UPPER_FIRST, ErrorCode.ERR_BAD_ATTR_VALUE); } if (!TABLE_SNAKE_CASE.equals(tables) && !TABLE_LITERAL.equals(tables)) { @@ -68,7 +69,7 @@ public static PhysicalNaming of(String columns, String tables) { return new PhysicalNaming(columns, tables); } - /** The {@code naming.columns} rule — snake_case / literal / kebab-case. */ + /** The {@code naming.columns} rule — snake_case / literal / kebab-case / upperFirst. */ public String columns() { return columns; } diff --git a/server/java/metadata/src/main/java/com/metaobjects/field/DurationField.java b/server/java/metadata/src/main/java/com/metaobjects/field/DurationField.java new file mode 100644 index 0000000000..e33ccb7b92 --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/field/DurationField.java @@ -0,0 +1,78 @@ +/* + * Copyright 2004 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.field; + +import com.metaobjects.DataTypes; +import com.metaobjects.registry.MetaDataRegistry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A duration field — a fixed-length span of time (weeks, days, hours, minutes, seconds). + * + *

{@code field.duration} is a logical subtype (ADR-0036/0037): it fixes the value's + * meaning and drives idiomatic native binding per language — {@code java.time.Duration} + * on the JVM — independent of how the value is physically stored. Like {@link InetField} it is + * a bare scalar with no own attributes.

+ * + *

Wire / storage contract. The value is carried as a {@code String} + * ({@link DataTypes#STRING}): the canonical ISO-8601 duration {@link DurationWire} writes + * ({@code [-]PT[nH][nM][n[.f]S]}). The DB column is the Postgres-native {@code interval}, and + * elsewhere a 64-bit count of microseconds. A week is exactly 7 days and a day exactly 24 hours; years and months are refused: a + * calendar span has no fixed length.

+ * + * @since 1.2.0 + */ +@SuppressWarnings("serial") +public class DurationField extends PrimitiveField { + + private static final Logger log = LoggerFactory.getLogger(DurationField.class); + + /** duration field subtype constant — cross-language vocabulary ({@code field.duration}). */ + public static final String SUBTYPE_DURATION = "duration"; + + public DurationField(String name) { + // String-backed value: the wire/storage representation is the canonical ISO-8601 + // duration. The native java.time.Duration binding is a codegen concern. + super(SUBTYPE_DURATION, name, DataTypes.STRING); + } + + /** + * Register the {@code field.duration} type with the MetaDataRegistry. A bare scalar + * inheriting the common field attributes, with no own attrs. The cross-port description is + * sourced from {@code spec/metamodel/field.json}. + */ + public static void registerTypes(MetaDataRegistry registry) { + try { + registry.registerType(DurationField.class, def -> def + .type(TYPE_FIELD).subType(SUBTYPE_DURATION) + .description("duration field — fixed-length span of time; native java.time.Duration binding") + .inheritsFrom(TYPE_FIELD, SUBTYPE_BASE)); + if (log != null) log.debug("Registered DurationField type with unified registry"); + } catch (Exception e) { + if (log != null) log.error("Failed to register DurationField type with unified registry", e); + } + } + + /** + * Manually create a DurationField. + * @param name Name of the field + * @return New DurationField + */ + public static DurationField create(String name) { + return new DurationField(name); + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/field/DurationWire.java b/server/java/metadata/src/main/java/com/metaobjects/field/DurationWire.java new file mode 100644 index 0000000000..05e4a84c23 --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/field/DurationWire.java @@ -0,0 +1,256 @@ +/* + * Copyright 2004 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.field; + +import java.time.Duration; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * The {@code field.duration} wire rule (shared vectors: {@code fixtures/duration-wire/}). + * + *
    + *
  • Accepted on read: {@link #ACCEPTED} — an optional sign, {@code P}, an optional week + * count ({@code W} = exactly 7 days), an optional day count ({@code D} = exactly 24 hours), an + * optional {@code T} followed by hours, minutes and seconds (a fraction of any length). Years + * and months are refused: a calendar span has no fixed length. Validated BEFORE any parse, + * and parsed here: {@link Duration#parse} accepts more (fractional hours, per-component signs, + * lowercase) and refuses weeks and more than nine fractional digits.
  • + *
  • Canonical on write ({@link #format}): {@code [-]PT[H][M][[.]S]} — + * hours never folded into days, zero components omitted, zero is {@code PT0S}, up to nine + * fractional digits (a {@link Duration} holds nanoseconds; trailing zeros dropped), one + * leading {@code -} for a negative span. {@link Duration#toString()} differs for negatives, + * so it is never the wire form.
  • + *
  • Postgres: an {@code interval} reads back (IntervalStyle {@code postgres}) as + * {@code [-]N days [-+]HH:MM:SS[.f]} — {@link #parsePostgres}; one with a year or month part + * is refused. Postgres refuses a signed ISO-8601 duration as input, so a TEXT bind uses + * {@link #formatPostgres} ({@code [-]HH:MM:SS[.f]}).
  • + *
+ * + *

A parsed value keeps nanoseconds (more fractional digits are truncated toward zero, never + * rounded). Storage is the database's limit, not this one: a Postgres interval and the + * SQLite / MySQL microsecond column both hold microseconds, so {@link #formatPostgres} and + * {@link #toMicros} truncate below a microsecond toward zero.

+ */ +public final class DurationWire { + + private DurationWire() {} + + /** The accepted ISO-8601 duration form. */ + public static final String ACCEPTED_REGEX = + "^[-+]?P(?!$)(\\d+W)?(\\d+D)?(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+(\\.\\d+)?S)?)?$"; + + /** {@link #ACCEPTED_REGEX}, compiled. */ + public static final Pattern ACCEPTED = Pattern.compile(ACCEPTED_REGEX); + + /** Capturing form of {@link #ACCEPTED}: sign, weeks, days, hours, minutes, seconds, fraction. */ + private static final Pattern PARTS = Pattern.compile( + "^([-+])?P(?!$)(?:(\\d+)W)?(?:(\\d+)D)?(?:T(?=\\d)(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)(?:\\.(\\d+))?S)?)?$"); + + /** A Postgres interval under IntervalStyle {@code postgres}, with no year or month part. */ + private static final Pattern POSTGRES = Pattern.compile( + "^(?:([-+]?\\d+) days?(?: |$))?(?:([-+])?(\\d+):(\\d{2}):(\\d{2})(?:\\.(\\d{1,6}))?)?$"); + + private static final Pattern POSTGRES_CALENDAR = Pattern.compile("\\b(years?|mons?)\\b"); + + /** What a refused value is told it should be. */ + public static final String EXPECTED = + "an ISO-8601 duration such as PT1H30M (no years or months)"; + + /** + * True iff {@code s} is in the accepted ISO-8601 form and fits a {@link Duration} (its own range + * is the only limit; a value past it is refused, never wrapped). + */ + public static boolean isDuration(String s) { + if (s == null || !ACCEPTED.matcher(s).matches()) return false; + try { + parse(s); + return true; + } catch (IllegalArgumentException outOfRange) { + return false; + } + } + + /** + * Parse an accepted ISO-8601 duration to the nanosecond (fractional digits past the ninth are + * truncated toward zero, never rounded). + * + * @throws IllegalArgumentException when {@code s} is not in the accepted form, or overflows + */ + public static Duration parse(String s) { + if (s == null) throw new IllegalArgumentException("field.duration: null is not a duration"); + Matcher m = PARTS.matcher(s); + if (!m.matches()) { + throw new IllegalArgumentException("field.duration: not " + EXPECTED + ": " + quote(s)); + } + try { + long seconds = Math.addExact( + Math.addExact(Math.multiplyExact(num(m.group(2)), 604_800L), + Math.multiplyExact(num(m.group(3)), 86_400L)), + Math.addExact(Math.multiplyExact(num(m.group(4)), 3_600L), + Math.addExact(Math.multiplyExact(num(m.group(5)), 60L), num(m.group(6))))); + Duration d = Duration.ofSeconds(seconds, nanos(m.group(7))); + return "-".equals(m.group(1)) ? d.negated() : d; + } catch (ArithmeticException | NumberFormatException e) { + throw new IllegalArgumentException("field.duration: out of range: " + quote(s), e); + } + } + + /** {@link #parse} then {@link #format}: the canonical spelling of an accepted duration. */ + public static String canonicalize(String s) { + return format(parse(s)); + } + + /** The canonical wire form of {@code d}, to the nanosecond. */ + public static String format(Duration d) { + if (d == null) throw new IllegalArgumentException("field.duration: null is not a duration"); + boolean negative = d.isNegative(); + Duration mag = negative ? d.negated() : d; + long total = mag.getSeconds(); + long nanos = mag.getNano(); + if (total == 0 && nanos == 0) return "PT0S"; + long h = total / 3_600L; + long mi = (total % 3_600L) / 60L; + long s = total % 60L; + StringBuilder b = new StringBuilder(negative ? "-PT" : "PT"); + if (h > 0) b.append(h).append('H'); + if (mi > 0) b.append(mi).append('M'); + if (s > 0 || nanos > 0) { + b.append(s); + appendFraction(b, nanos, 9); + b.append('S'); + } + return b.toString(); + } + + /** + * The Postgres interval input spelling of {@code d}: {@code [-]HH:MM:SS[.f]}, no day part. An + * interval holds microseconds, so a sub-microsecond part is truncated toward zero here. + */ + public static String formatPostgres(Duration d) { + if (d == null) throw new IllegalArgumentException("field.duration: null is not a duration"); + boolean negative = d.isNegative(); + Duration mag = negative ? d.negated() : d; + long total = mag.getSeconds(); + long micros = mag.getNano() / 1_000L; + if (total == 0 && micros == 0) negative = false; + StringBuilder b = new StringBuilder(negative ? "-" : ""); + long h = total / 3_600L; + if (h < 10) b.append('0'); + b.append(h).append(':'); + two(b, (total % 3_600L) / 60L); + b.append(':'); + two(b, total % 60L); + appendFraction(b, micros, 6); + return b.toString(); + } + + /** + * Read the text a Postgres {@code interval} column returns (IntervalStyle {@code postgres}). + * + * @throws IllegalArgumentException for an interval with a year or month part (a calendar span + * is not a duration — never a guess at the length of a month) or unreadable text + */ + public static Duration parsePostgres(String s) { + if (s == null) throw new IllegalArgumentException("field.duration: null is not an interval"); + if (POSTGRES_CALENDAR.matcher(s).find()) { + throw new IllegalArgumentException( + "field.duration: the interval " + quote(s) + " has a year or month part; a calendar span is not a duration"); + } + Matcher m = POSTGRES.matcher(s); + if (s.isEmpty() || !m.matches() || (m.group(1) == null && m.group(3) == null)) { + throw new IllegalArgumentException("field.duration: not a Postgres interval: " + quote(s)); + } + try { + Duration days = Duration.ofSeconds(Math.multiplyExact(m.group(1) == null ? 0L : Long.parseLong(m.group(1)), 86_400L)); + if (m.group(3) == null) return days; + long secs = Math.addExact(Math.multiplyExact(Long.parseLong(m.group(3)), 3_600L), + Long.parseLong(m.group(4)) * 60L + Long.parseLong(m.group(5))); + Duration time = Duration.ofSeconds(secs, nanos(m.group(6))); + return days.plus("-".equals(m.group(2)) ? time.negated() : time); + } catch (ArithmeticException | NumberFormatException e) { + throw new IllegalArgumentException("field.duration: interval out of range: " + quote(s), e); + } + } + + /** + * A stored duration: the canonical (or any accepted) ISO-8601 string a text column holds, or + * the text a Postgres interval column returns. + */ + public static Duration fromStored(String s) { + return s != null && ACCEPTED.matcher(s).matches() ? parse(s) : parsePostgres(s); + } + + /** {@link #fromStored} then {@link #format}: the canonical wire string of a stored value. */ + public static String canonicalizeStored(String s) { + return format(fromStored(s)); + } + + /** + * The 64-bit count of microseconds a SQLite / MySQL column stores (sub-microsecond precision + * truncated toward zero). + * + * @throws IllegalArgumentException past the 64-bit range, which is that column's limit + */ + public static long toMicros(Duration d) { + try { + boolean negative = d.isNegative(); + Duration mag = negative ? d.negated() : d; + long micros = Math.addExact(Math.multiplyExact(mag.getSeconds(), 1_000_000L), mag.getNano() / 1_000L); + return negative ? -micros : micros; + } catch (ArithmeticException tooLong) { + throw new IllegalArgumentException( + "field.duration: " + d + " is past a 64-bit count of microseconds", tooLong); + } + } + + /** A stored count of microseconds as a duration. */ + public static Duration fromMicros(long micros) { + return Duration.ofSeconds(Math.floorDiv(micros, 1_000_000L), Math.floorMod(micros, 1_000_000L) * 1_000L); + } + + // ── helpers ── + + private static long num(String digits) { + return digits == null ? 0L : Long.parseLong(digits); + } + + /** A fraction of any length → whole nanoseconds, the digits past the ninth truncated toward zero. */ + private static long nanos(String frac) { + if (frac == null || frac.isEmpty()) return 0L; + String nine = frac.length() >= 9 ? frac.substring(0, 9) : (frac + "000000000").substring(0, 9); + return Long.parseLong(nine); + } + + /** {@code value} as {@code width} zero-padded ASCII digits after a {@code .}, trailing zeros dropped. */ + private static void appendFraction(StringBuilder b, long value, int width) { + if (value == 0) return; + String digits = Long.toString(value); + String f = "000000000".substring(0, width - digits.length()) + digits; + int end = f.length(); + while (end > 0 && f.charAt(end - 1) == '0') end--; + b.append('.').append(f, 0, end); + } + + private static void two(StringBuilder b, long v) { + if (v < 10) b.append('0'); + b.append(v); + } + + private static String quote(String s) { + return "\"" + s + "\""; + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/field/EnumField.java b/server/java/metadata/src/main/java/com/metaobjects/field/EnumField.java index f8d86f6fe4..9bae5e39bb 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/field/EnumField.java +++ b/server/java/metadata/src/main/java/com/metaobjects/field/EnumField.java @@ -63,6 +63,9 @@ public class EnumField extends PrimitiveField { */ public static final String ATTR_VALUES = "values"; + /** The column length of a string-stored enum. Same name as {@link StringField#ATTR_MAX_LENGTH}. */ + public static final String ATTR_MAX_LENGTH = StringField.ATTR_MAX_LENGTH; + /** * Name of the optional off-vocabulary → canonical-member alias map (properties). * Consumed by FR-010 extract: maps raw input tokens to their canonical enum member. @@ -221,6 +224,13 @@ public static void registerTypes(MetaDataRegistry registry) { .ofType(com.metaobjects.attr.IntMapAttribute.SUBTYPE_INT_MAP) .asSingle(); + // Optional @maxLength — the column length of a string-stored enum (VARCHAR(n)). + // Every member must fit, and it is refused beside @intValueMap; both checked + // post-load in ValidationPhase.validateEnumMaxLength. + def.optionalAttributeWithConstraints(ATTR_MAX_LENGTH) + .ofType(com.metaobjects.attr.IntAttribute.SUBTYPE_INT) + .asSingle(); + // FR-033: @enumDoc and @coerceDefault are re-homed to the // metaobjects-prompt concern provider (reads // spec/metamodel/prompt.json's field.enum extends). diff --git a/server/java/metadata/src/main/java/com/metaobjects/field/FieldTypesMetaDataProvider.java b/server/java/metadata/src/main/java/com/metaobjects/field/FieldTypesMetaDataProvider.java index 97db5f4c5a..10a946d607 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/field/FieldTypesMetaDataProvider.java +++ b/server/java/metadata/src/main/java/com/metaobjects/field/FieldTypesMetaDataProvider.java @@ -68,6 +68,7 @@ public void registerTypes(MetaDataRegistry registry) { UuidField.registerTypes(registry); UriField.registerTypes(registry); InetField.registerTypes(registry); + DurationField.registerTypes(registry); log.debug("Field types registered via provider"); } diff --git a/server/java/metadata/src/main/java/com/metaobjects/field/ObjectField.java b/server/java/metadata/src/main/java/com/metaobjects/field/ObjectField.java index 2622f9b0cb..fba5bc3a88 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/field/ObjectField.java +++ b/server/java/metadata/src/main/java/com/metaobjects/field/ObjectField.java @@ -39,6 +39,10 @@ public class ObjectField extends MetaField public final static String ATTR_OBJECTREF = MetaObject.ATTR_OBJECT_REF; /** Persistence-side storage shape for owned-object data. Cross-port values: flattened / jsonb / subdocument. */ public final static String ATTR_STORAGE = "storage"; + /** {@code @storage "flattened"}: the nested object's columns expand into the parent table. */ + public final static String STORAGE_FLATTENED = "flattened"; + /** {@code @flattenedColumnPrefix} — see {@link com.metaobjects.database.CoreDBMetaDataProvider#FLATTENED_COLUMN_PREFIX}. */ + public final static String ATTR_FLATTENED_COLUMN_PREFIX = com.metaobjects.database.CoreDBMetaDataProvider.FLATTENED_COLUMN_PREFIX; /** * Register ObjectField type using the standardized registerTypes() pattern. @@ -58,6 +62,11 @@ public static void registerTypes(MetaDataRegistry registry) { // them), so ObjectField inherits both via the snapshot rather // than re-declaring them here. .inheritsFrom(TYPE_FIELD, SUBTYPE_BASE) + // @flattenedColumnPrefix (string, "" allowed) — the flattened-member column + // prefix. Scoped to field.object; description enriched from spec db.json. + .optionalAttributeWithConstraints(ATTR_FLATTENED_COLUMN_PREFIX) + .ofType(com.metaobjects.attr.StringAttribute.SUBTYPE_STRING) + .asSingle() // ADR-0042 — @objectRef is a cross-reference to a real object (the // nested value's shape); the loader's registry-derived validation // resolves it package-locally (dangling target = load error). Fires diff --git a/server/java/metadata/src/main/java/com/metaobjects/identity/PrimaryIdentity.java b/server/java/metadata/src/main/java/com/metaobjects/identity/PrimaryIdentity.java index 9ed59d7cb4..f30c678a7c 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/identity/PrimaryIdentity.java +++ b/server/java/metadata/src/main/java/com/metaobjects/identity/PrimaryIdentity.java @@ -17,6 +17,11 @@ */ public class PrimaryIdentity extends MetaIdentity { + /** The first value of an increment key's sequence. Absent: 1. */ + public static final String ATTR_SEQUENCE_START = "sequenceStart"; + /** The physical name of an increment key's sequence. Absent: the database's default. */ + public static final String ATTR_SEQUENCE_NAME = "sequenceName"; + private static final Logger log = LoggerFactory.getLogger(PrimaryIdentity.class); /** @@ -48,6 +53,11 @@ public static void registerTypes(MetaDataRegistry registry) { // the dedicated validateIdentityFieldsAndGeneration pass already enforces it). def.requiredAttributeWithConstraints(ATTR_FIELDS).ofType(StringAttribute.SUBTYPE_STRING).asArray(); def.optionalAttributeWithConstraints(ATTR_GENERATION).ofType(StringAttribute.SUBTYPE_STRING).withEnum(GENERATION_INCREMENT, GENERATION_UUID, GENERATION_ASSIGNED); + // The sequence an @generation "increment" key draws from: its first value and its + // physical name (spec/metamodel/db.json). Refused on any other generation, post-load + // in ValidationPhase.validateIdentitySequence. + def.optionalAttributeWithConstraints(ATTR_SEQUENCE_START).ofType(com.metaobjects.attr.LongAttribute.SUBTYPE_LONG).asSingle(); + def.optionalAttributeWithConstraints(ATTR_SEQUENCE_NAME).ofType(StringAttribute.SUBTYPE_STRING).asSingle(); // ACCEPTS ANY ATTRIBUTES (for extensibility from service providers) // ADR-0051: NO any-attr wildcard. An undeclared attribute is ERR_UNKNOWN_ATTR in diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java b/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java index f09473d809..b1e04a4cb7 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/MetaDataLoader.java @@ -747,6 +747,9 @@ public static MetaDataLoader fromDirectory(String name, Path directory, loader.getRoot().setWireFormat(com.metaobjects.config.SourceResolver .discoverWireFormat(List.of(directory.toAbsolutePath().toUri())) .orElse(com.metaobjects.config.WireFormat.DEFAULT)); + loader.getRoot().setEnumDefaults(com.metaobjects.config.SourceResolver + .discoverEnumDefaults(List.of(directory.toAbsolutePath().toUri())) + .orElse(com.metaobjects.config.EnumDefaults.DEFAULT)); } catch (MetaDataLoadingException e) { throw e; } catch (Exception e) { @@ -834,6 +837,8 @@ public static MetaDataLoader fromUris(String name, List uris, LoaderOptions // ADR-0019 Amendment 2: the wire setting from the same file, else the native default. loader.getRoot().setWireFormat(com.metaobjects.config.SourceResolver .discoverWireFormat(uris).orElse(com.metaobjects.config.WireFormat.DEFAULT)); + loader.getRoot().setEnumDefaults(com.metaobjects.config.SourceResolver + .discoverEnumDefaults(uris).orElse(com.metaobjects.config.EnumDefaults.DEFAULT)); } catch (MetaDataLoadingException e) { throw e; } catch (Exception e) { diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java index f4c4b8c615..521602f4a8 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java @@ -262,6 +262,9 @@ public static void run(MetaRoot root, MetaDataLoader loader) { // FR-024 B6 — derived-field providability (entity origin fields need a read source). pass(collected, () -> validateDerivedFieldProvidability(root)); pass(collected, () -> validateObjectFieldStorage(root)); + validateEnumMaxLength(root, collected); + validateIdentitySequence(root, collected); + validateCrossFieldValidators(root, collected); pass(collected, () -> validateFieldMap(root)); pass(collected, () -> validateIdentityFieldsAndGeneration(root)); // FR-024 B3/B4a — subtype rules (identity-name, value purity, projection licensing). @@ -290,7 +293,13 @@ public static void run(MetaRoot root, MetaDataLoader loader) { pass(collected, () -> validateSortableHasSupportedSubtype(root)); pass(collected, () -> validateIndexLookupFields(root)); // Two fields of one stored object resolving to the same physical column. - pass(collected, () -> validateColumnNameCollisions(root)); + // Collects every collision (a flattened embedding can collide on several members at + // once); keyed by message like the reporting pass, since they share code + envelope. + pass(collected, () -> { + List collisions = validateColumnNameCollisions(root); + reportingFindings.addAll(collisions); + collected.addAll(collisions); + }); // FR-039 — a retired requirement carries no @implementedBy (refused, not exempted, // so the dangling-ref class is unreachable) and @supersededBy is legal only there. pass(collected, () -> validateRetiredRequirementLinks(root)); @@ -2361,11 +2370,297 @@ private static List junctionReferenceFkFields(MetaObject junction) { // 2. @storage="flattened" + isArray → ERR_STORAGE_FLATTENED_ARRAY // (flattened storage materialises one column-per-field; arrays would // require a side table, which is what @storage="jsonb" is for.) + // 3. @flattenedColumnPrefix without @storage="flattened" → + // ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE (no other storage gives + // the members columns of their own to prefix). // // Only field.object nodes are inspected; @storage on other field subtypes is // already rejected by the constraint phase. // ========================================================================= + // ========================================================================= + // field.enum @maxLength — the column length of a string-stored enum: + // 1. every member of @values fits in it (ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH), the + // longest offender named; + // 2. it is refused beside @intValueMap (ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP). + // Read RESOLVING (ADR-0039), reported on the node that DECLARES one of the three, so an + // enum that only inherits them is not reported twice. Every field.enum in the tree is + // checked, root-level abstract enums included. Mirrors TS validateEnumMaxLength. + // ========================================================================= + + // ========================================================================= + // Cross-field validators (comparison / requiredWhen / presentIff / atLeastOne / exactlyOne), + // which the database enforces as CHECKs: exactlyOne names two or more @fields; + // every field a @condition names is a field of the owning entity; and the @condition can + // become a CHECK guard (see conditionProblem). ERR_BAD_ATTR_VALUE, one per validator, + // every violation collected. Mirrors TS validateCrossFieldValidators. + // ========================================================================= + static void validateCrossFieldValidators(MetaRoot root, List out) { + java.util.Set crossField = java.util.Set.of( + com.metaobjects.validator.ComparisonValidator.SUBTYPE_COMPARISON, + com.metaobjects.validator.RequiredWhenValidator.SUBTYPE_REQUIRED_WHEN, + com.metaobjects.validator.PresentIffValidator.SUBTYPE_PRESENT_IFF, + com.metaobjects.validator.AtLeastOneValidator.SUBTYPE_AT_LEAST_ONE, + com.metaobjects.validator.ExactlyOneValidator.SUBTYPE_EXACTLY_ONE); + for (MetaObject obj : root.getChildren(MetaObject.class, false)) { + java.util.Map> fields = new java.util.LinkedHashMap<>(); + for (MetaField f : obj.getMetaFields()) fields.put(f.getShortName(), f); + for (com.metaobjects.validator.MetaValidator v + : obj.getChildren(com.metaobjects.validator.MetaValidator.class, false)) { + if (!crossField.contains(v.getSubType())) continue; + String label = v.getSubType() + " \"" + obj.getShortName() + "." + v.getShortName() + "\""; + if (com.metaobjects.validator.ExactlyOneValidator.SUBTYPE_EXACTLY_ONE.equals(v.getSubType())) { + Object raw = v.hasMetaAttr(com.metaobjects.validator.ExactlyOneValidator.ATTR_FIELDS) + ? v.getMetaAttr(com.metaobjects.validator.ExactlyOneValidator.ATTR_FIELDS).getValue() : null; + int count = raw instanceof java.util.Collection c ? c.size() : raw == null ? 0 : 1; + if (count < 2) { + out.add(new MetaDataException( + ErrorMessageConstants.ERR_BAD_ATTR_VALUE + ": validator." + label + " @fields names " + + count + " field" + (count == 1 ? "" : "s") + + "; it needs two or more. For one field, make that field required.", + ErrorCode.ERR_BAD_ATTR_VALUE, v.getSource())); + continue; + } + } + if (!v.hasMetaAttr(com.metaobjects.validator.MetaValidator.ATTR_CONDITION)) continue; + Object condition = v.getMetaAttr(com.metaobjects.validator.MetaValidator.ATTR_CONDITION).getValue(); + java.util.TreeSet unknown = new java.util.TreeSet<>(); + collectExprFieldRefs(condition, unknown); + unknown.removeAll(fields.keySet()); + if (!unknown.isEmpty()) { + StringBuilder names = new StringBuilder(); + for (String u : unknown) names.append(names.length() == 0 ? "" : ", ").append('"').append(u).append('"'); + out.add(new MetaDataException( + ErrorMessageConstants.ERR_BAD_ATTR_VALUE + ": validator." + label + " @condition names " + names + + ", which " + (unknown.size() == 1 ? "is not a field" : "are not fields") + + " of \"" + obj.getShortName() + "\".", + ErrorCode.ERR_BAD_ATTR_VALUE, v.getSource())); + continue; + } + String problem = conditionProblem(condition, fields); + if (problem != null) { + out.add(new MetaDataException( + ErrorMessageConstants.ERR_BAD_ATTR_VALUE + ": validator." + label + " @condition " + problem, + ErrorCode.ERR_BAD_ATTR_VALUE, v.getSource())); + } + } + } + } + + /** Field subtypes whose values a condition compares with a number literal. */ + private static final java.util.Set CONDITION_NUMBER_SUBTYPES = java.util.Set.of( + IntegerField.SUBTYPE_INT, LongField.SUBTYPE_LONG, DoubleField.SUBTYPE_DOUBLE, + FloatField.SUBTYPE_FLOAT, DecimalField.SUBTYPE_DECIMAL, CurrencyField.SUBTYPE_CURRENCY); + + /** + * Why a cross-field validator's {@code @condition} cannot become a CHECK guard, or + * {@code null} when it can. Its names are already known to be fields. The rule, the same + * in every port: + * - it types cleanly (the origin.computed inference) and its root is boolean, since a + * CHECK negates it; + * - a literal compared with a field is of that field's kind (a boolean for field.boolean, + * a number for a numeric field, a string otherwise) and, for field.enum, one of its + * @values; + * - no comparison has a null literal: {@code x = NULL} is never true, so use isNull / + * isNotNull. + * Mirrors TS conditionProblem. + */ + private static String conditionProblem(Object condition, java.util.Map> fields) { + ExpressionAttribute.InferResult inferred = ExpressionAttribute.inferExprType( + condition, name -> fields.containsKey(name) ? fields.get(name).getSubType() : null); + if (!inferred.errors.isEmpty()) { + return "is not a valid expression: " + String.join("; ", inferred.errors) + "."; + } + if (!BooleanField.SUBTYPE_BOOLEAN.equals(inferred.type)) { + return "must be a boolean expression (a comparison, a null test, or and / or / not of them), not a field." + + inferred.type + "."; + } + return conditionLiteralProblem(condition, fields); + } + + /** The first comparison in {@code node} whose literal side cannot be compared with its + * other side, as conditionProblem's message tail, or {@code null}. */ + private static String conditionLiteralProblem(Object node, java.util.Map> fields) { + if (!(node instanceof java.util.Map n)) return null; + if (n.get("left") != null && n.get("right") != null) { + Object[][] sides = { { n.get("left"), n.get("right") }, { n.get("right"), n.get("left") } }; + for (Object[] side : sides) { + if (!(side[0] instanceof java.util.Map lit) || !lit.containsKey("value")) continue; + Object value = lit.get("value"); + if (value == null) { + return "compares with null, which is never true: use isNull or isNotNull."; + } + MetaField field = side[1] instanceof java.util.Map ref && ref.get("field") instanceof String f + ? fields.get(f) : null; + if (field == null) continue; + String subType = field.getSubType(); + String kind = BooleanField.SUBTYPE_BOOLEAN.equals(subType) ? "boolean" + : CONDITION_NUMBER_SUBTYPES.contains(subType) ? "number" : "string"; + boolean matches = switch (kind) { + case "boolean" -> value instanceof Boolean; + case "number" -> value instanceof Number; + default -> value instanceof String; + }; + if (!matches) { + return "compares \"" + field.getShortName() + "\" (field." + subType + ") with " + + conditionJsonLiteral(value) + "; it needs a " + kind + "."; + } + if (EnumField.SUBTYPE_ENUM.equals(subType) && field.hasMetaAttr(EnumField.ATTR_VALUES) + && field.getMetaAttr(EnumField.ATTR_VALUES).getValue() instanceof java.util.Collection members) { + List names = new java.util.ArrayList<>(); + for (Object m : members) names.add(String.valueOf(m)); + if (!names.contains(String.valueOf(value))) { + return "compares \"" + field.getShortName() + "\" with " + conditionJsonLiteral(value) + + ", which is not one of its @values (" + String.join(", ", names) + ")."; + } + } + } + } + for (String k : List.of("left", "right", "arg")) { + String p = conditionLiteralProblem(n.get(k), fields); + if (p != null) return p; + } + if (n.get("args") instanceof java.util.Collection args) { + for (Object a : args) { + String p = conditionLiteralProblem(a, fields); + if (p != null) return p; + } + } + return null; + } + + /** A condition literal as JSON, the way TS JSON.stringify renders it (an integral + * double without a fraction). */ + private static String conditionJsonLiteral(Object value) { + if ((value instanceof Double || value instanceof Float) && !Double.isInfinite(((Number) value).doubleValue()) + && ((Number) value).doubleValue() == Math.rint(((Number) value).doubleValue())) { + return Long.toString(((Number) value).longValue()); + } + return new com.google.gson.GsonBuilder().disableHtmlEscaping().create().toJson(value); + } + + /** The field names an expression tree (ExpressionAttribute's Map form) references. */ + private static void collectExprFieldRefs(Object node, java.util.Set out) { + if (node instanceof java.util.Map m) { + if (m.get("field") instanceof String f) out.add(f); + for (String k : List.of("left", "right", "arg")) collectExprFieldRefs(m.get(k), out); + if (m.get("args") instanceof java.util.Collection args) for (Object a : args) collectExprFieldRefs(a, out); + } + } + + // ========================================================================= + // identity.primary @sequenceStart / @sequenceName describe the sequence an @generation + // "increment" key draws from; on any other generation there is none, so either one is + // refused (ERR_SEQUENCE_WITHOUT_INCREMENT). Read RESOLVING (ADR-0039), reported on the + // identity that declares one of them, every offender collected. Mirrors TS. + // ========================================================================= + + static void validateIdentitySequence(MetaRoot root, List out) { + for (MetaObject obj : root.getChildren(MetaObject.class, false)) { + for (com.metaobjects.identity.PrimaryIdentity id + : obj.getChildren(com.metaobjects.identity.PrimaryIdentity.class, false)) { + List declared = new java.util.ArrayList<>(); + for (String a : List.of(com.metaobjects.identity.PrimaryIdentity.ATTR_SEQUENCE_START, + com.metaobjects.identity.PrimaryIdentity.ATTR_SEQUENCE_NAME)) { + if (id.hasMetaAttr(a, false)) declared.add("@" + a); + } + String generation = id.hasMetaAttr(com.metaobjects.identity.MetaIdentity.ATTR_GENERATION) + ? id.getMetaAttr(com.metaobjects.identity.MetaIdentity.ATTR_GENERATION).getValueAsString() : null; + if (declared.isEmpty()) continue; + if (com.metaobjects.identity.MetaIdentity.GENERATION_INCREMENT.equals(generation)) { + // The key field's own range bounds the start (Postgres refuses the sequence + // otherwise, at apply time): 1 to the largest value of a field.int / field.long. + if (id.hasMetaAttr(com.metaobjects.identity.PrimaryIdentity.ATTR_SEQUENCE_START)) { + Object raw = id.getMetaAttr(com.metaobjects.identity.PrimaryIdentity.ATTR_SEQUENCE_START).getValue(); + if (raw instanceof Number n) { + long start = n.longValue(); + List keys = id.getFields(); + String keyName = keys.isEmpty() ? null : keys.get(0); + com.metaobjects.field.MetaField key = null; + for (com.metaobjects.field.MetaField f : obj.getMetaFields()) { + if (f.getShortName().equals(keyName)) { key = f; break; } + } + Long max = key instanceof com.metaobjects.field.IntegerField ? (long) Integer.MAX_VALUE : null; + if (start < 1 || (max != null && start > max)) { + out.add(new MetaDataException( + ErrorMessageConstants.ERR_SEQUENCE_START_OUT_OF_RANGE + + ": identity.primary \"" + obj.getShortName() + "." + id.getShortName() + + "\" @sequenceStart " + start + " is out of range for its key \"" + keyName + "\"" + + (key != null ? " (field." + key.getSubType() + ")" : "") + + ": it must be at least 1" + (max != null ? " and at most " + max : "") + ".", + ErrorCode.ERR_SEQUENCE_START_OUT_OF_RANGE, id.getSource())); + } + } + } + continue; + } + String shown = generation == null ? "not set" : "\"" + generation + "\""; + String names = String.join(" and ", declared); + out.add(new MetaDataException( + ErrorMessageConstants.ERR_SEQUENCE_WITHOUT_INCREMENT + + ": identity.primary \"" + obj.getShortName() + "." + id.getShortName() + "\" sets " + + names + ", but its @generation is " + shown + "; only an @generation \"increment\"" + + " key draws from a sequence. Set @generation \"increment\", or remove " + names + ".", + ErrorCode.ERR_SEQUENCE_WITHOUT_INCREMENT, id.getSource())); + } + } + } + + /** Every offending enum is reported, as the other ports do, not only the first. */ + static void validateEnumMaxLength(MetaData node, List out) { + for (MetaData child : node.getChildren(MetaData.class, false)) { + if (child instanceof EnumField) { + try { + validateEnumMaxLengthNode((EnumField) child); + } catch (MetaDataException e) { + out.add(e); + } + } else if (!(child instanceof com.metaobjects.field.MetaField)) { + validateEnumMaxLength(child, out); + } + } + } + + private static void validateEnumMaxLengthNode(EnumField field) { + boolean declares = field.hasMetaAttr(EnumField.ATTR_MAX_LENGTH, false) + || field.hasMetaAttr(EnumField.ATTR_VALUES, false) + || field.hasMetaAttr(EnumField.ATTR_INT_VALUE_MAP, false); + if (!declares || !field.hasMetaAttr(EnumField.ATTR_MAX_LENGTH)) return; + Object rawLength = field.getMetaAttr(EnumField.ATTR_MAX_LENGTH).getValue(); + if (!(rawLength instanceof Number)) return; + long maxLength = ((Number) rawLength).longValue(); + MetaData owner = field.getParent(); + String label = owner instanceof MetaObject + ? ((MetaObject) owner).getShortName() + "." + field.getShortName() + : field.getShortName(); + if (field.hasMetaAttr(EnumField.ATTR_INT_VALUE_MAP)) { + throw new MetaDataException( + ErrorMessageConstants.ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP + + ": field.enum \"" + label + "\" sets @maxLength " + maxLength + + " and @intValueMap; an @intValueMap enum is stored as an integer, so a column" + + " length does not apply. Remove @maxLength.", + ErrorCode.ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP, field.getSource()); + } + String tooLong = null; + Object raw = field.hasMetaAttr(EnumField.ATTR_VALUES) + ? field.getMetaAttr(EnumField.ATTR_VALUES).getValue() : null; + if (raw instanceof List) { + for (Object o : (List) raw) { + String v = String.valueOf(o); + if (v.length() > maxLength && (tooLong == null || v.length() > tooLong.length())) tooLong = v; + } + } + if (tooLong != null) { + throw new MetaDataException( + ErrorMessageConstants.ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH + + ": field.enum \"" + label + "\" @maxLength " + maxLength + + " is shorter than its member \"" + tooLong + "\" (" + tooLong.length() + + " characters). Raise @maxLength to at least " + tooLong.length() + + ", or shorten the member.", + ErrorCode.ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH, field.getSource()); + } + } + static void validateObjectFieldStorage(MetaRoot root) { walkObjectFieldStorage(root); } @@ -2400,10 +2695,32 @@ private static void validateObjectFieldStorageNode(MetaData node) { } // ADR-0039: @storage is also an effective property — resolve it. - if (!node.hasMetaAttr(ObjectField.ATTR_STORAGE)) return; + Object storageVal = node.hasMetaAttr(ObjectField.ATTR_STORAGE) + ? node.getMetaAttr(ObjectField.ATTR_STORAGE).getValue() + : null; - Object storageVal = node.getMetaAttr(ObjectField.ATTR_STORAGE).getValue(); - if ("flattened".equals(String.valueOf(storageVal)) && field.isArrayType()) { + // @flattenedColumnPrefix names the columns a flattened value object adds to the owning + // table; under any other storage it would change nothing, so it is refused rather + // than silently ignored. + // Only a field an object owns is checked (as TS and C# do): a root-level abstract + // field.object is a template, and the concrete field that extends it — resolving both + // the prefix and @storage — is the one checked. + MetaData owner = node.getParent(); + if (owner instanceof MetaObject + && node.hasMetaAttr(ObjectField.ATTR_FLATTENED_COLUMN_PREFIX) + && !ObjectField.STORAGE_FLATTENED.equals(storageVal == null ? null : String.valueOf(storageVal))) { + String storageText = storageVal == null ? "not set (jsonb)" : "\"" + storageVal + "\""; + throw new MetaDataException( + ErrorMessageConstants.ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE + + ": field.object \"" + ((MetaObject) owner).getShortName() + "." + field.getName() + + "\" sets @flattenedColumnPrefix, but its @storage is " + storageText + + "; only a @storage \"flattened\" value object puts its members' columns in" + + " the owning table. Set @storage \"flattened\", or remove @flattenedColumnPrefix.", + ErrorCode.ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE, field.getSource()); + } + + if (storageVal == null) return; + if (ObjectField.STORAGE_FLATTENED.equals(String.valueOf(storageVal)) && field.isArrayType()) { throw new MetaDataException( ErrorMessageConstants.ERR_STORAGE_FLATTENED_ARRAY + ": field.object '" + field.getName() @@ -5406,8 +5723,10 @@ private static void walkRetiredRequirementLinks(MetaData node) { *

Only the RESOLVED name is compared, through {@link com.metaobjects.database.ColumnNaming#resolve}, * so an explicit {@code @column}, an inherited field and the project's column strategy are * honoured. Scope: a concrete object declaring at least one source (a source-less object - * generates nothing); derived fields and {@code field.object} (may flatten to several - * columns) are skipped. ADR-0039: resolving accessors, so an inherited field collides like + * generates nothing); derived fields are skipped. A {@code field.object @storage "flattened"} + * has no column of its own: each member of its value object is a column, named by + * {@link com.metaobjects.database.ColumnNaming#resolveFlattened} exactly as migrate creates + * it. Any other {@code field.object} is one jsonb column under its own resolved name. ADR-0039: resolving accessors, so an inherited field collides like * an own one.

* *

Exempt: an object whose every source is read-only and at least one is a @@ -5432,28 +5751,76 @@ private static boolean hasExplicitColumn(MetaField field) { return v != null && !v.isEmpty(); } - static void validateColumnNameCollisions(MetaRoot root) { + /** One physical column a field contributes to its object's table. */ + private static final class PhysicalColumn { + final String column; + /** {@code street}, or {@code shipTo.street} for a member of a flattened value object. */ + final String label; + /** The field that names the column: the field itself, or the flattened value object's member. */ + final MetaField named; + final boolean flattenedMember; + + PhysicalColumn(String column, String label, MetaField named, boolean flattenedMember) { + this.column = column; + this.label = label; + this.named = named; + this.flattenedMember = flattenedMember; + } + } + + private static List physicalColumnsOf(MetaField field) { + // ADR-0039: resolving - @storage and @objectRef may be inherited via extends. + if (ObjectField.SUBTYPE_OBJECT.equals(field.getSubType()) + && field.hasMetaAttr(ObjectField.ATTR_STORAGE) + && ObjectField.STORAGE_FLATTENED.equals( + String.valueOf(field.getMetaAttr(ObjectField.ATTR_STORAGE).getValue()))) { + MetaObject target = field.hasMetaAttr(ObjectField.ATTR_OBJECTREF) + ? References.refTarget(field, ObjectField.ATTR_OBJECTREF) : null; + List out = new ArrayList<>(); + if (target == null) return out; + // Every member, as migrate expands it. + for (MetaField member : target.getChildren(MetaField.class, true)) { + out.add(new PhysicalColumn( + com.metaobjects.database.ColumnNaming.resolveFlattened(field, member), + field.getName() + "." + member.getName(), member, true)); + } + return out; + } + return List.of(new PhysicalColumn( + com.metaobjects.database.ColumnNaming.resolve(field), field.getName(), field, false)); + } + + private static String collisionHint(PhysicalColumn a, PhysicalColumn b) { + if (a.flattenedMember || b.flattenedMember) { + return "Give one flattened field a different @flattenedColumnPrefix, or rename one field."; + } + return hasExplicitColumn(a.named) && hasExplicitColumn(b.named) + ? "Rename one field, or point one @column at a different column." + : "Rename one field, or give one an explicit @column so the two columns differ."; + } + + static List validateColumnNameCollisions(MetaRoot root) { + List errors = new ArrayList<>(); for (MetaObject obj : root.getChildren(MetaObject.class, false)) { if (isAbstract(obj)) continue; if (obj.getChildren(MetaSource.class, true).isEmpty()) continue; if (readsThroughSqlView(obj)) continue; - java.util.Map> seen = new java.util.HashMap<>(); + java.util.Map seen = new java.util.HashMap<>(); for (MetaField field : obj.getChildren(MetaField.class, true)) { - if (ObjectField.SUBTYPE_OBJECT.equals(field.getSubType()) || field.isDerived()) continue; - String column = com.metaobjects.database.ColumnNaming.resolve(field); - MetaField first = seen.putIfAbsent(column, field); - if (first == null) continue; - throw new MetaDataException( - ErrorMessageConstants.ERR_COLUMN_NAME_COLLISION - + ": fields \"" + first.getName() + "\" and \"" + field.getName() - + "\" on \"" + obj.getName() + "\" both resolve to physical column \"" - + column + "\". " - + (hasExplicitColumn(first) && hasExplicitColumn(field) - ? "Rename one field, or point one @column at a different column." - : "Rename one field, or give one an explicit @column so the two columns differ."), - ErrorCode.ERR_COLUMN_NAME_COLLISION, field.getSource()); + if (field.isDerived()) continue; + for (PhysicalColumn col : physicalColumnsOf(field)) { + PhysicalColumn first = seen.putIfAbsent(col.column, col); + if (first == null) continue; + errors.add(new MetaDataException( + ErrorMessageConstants.ERR_COLUMN_NAME_COLLISION + + ": fields \"" + first.label + "\" and \"" + col.label + + "\" on \"" + obj.getName() + "\" both resolve to physical column \"" + + col.column + "\". " + collisionHint(first, col), + ErrorCode.ERR_COLUMN_NAME_COLLISION, field.getSource())); + } } } + return errors; } static void validateIndexLookupFields(MetaRoot root) { diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/parser/json/CanonicalJsonParser.java b/server/java/metadata/src/main/java/com/metaobjects/loader/parser/json/CanonicalJsonParser.java index 93873239a9..54f9d54c66 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/parser/json/CanonicalJsonParser.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/parser/json/CanonicalJsonParser.java @@ -5,6 +5,7 @@ import com.google.gson.JsonObject; import com.google.gson.JsonParser; import com.google.gson.JsonPrimitive; +import com.metaobjects.database.CoreDBMetaDataProvider; import com.metaobjects.ErrorCode; import com.metaobjects.MetaData; import com.metaobjects.MetaDataException; @@ -1047,8 +1048,9 @@ private void detectAttrMergeConflicts(MetaData target, JsonObject body, String existingVal = preAttrs.get(attrName); if (existingVal == null) continue; - if (isEmptyJsonValue(entry.getValue())) continue; - if (existingVal.isEmpty()) continue; + boolean emptyIsAValue = EMPTY_STRING_IS_A_VALUE.contains(attrName); + if (isEmptyJsonValue(entry.getValue(), emptyIsAValue)) continue; + if (existingVal.isEmpty() && !emptyIsAValue) continue; // Normalize the new JSON value to the same comma-delimited form // that {@link MetaAttribute#getValueAsString()} returns for arrays, @@ -1057,7 +1059,7 @@ private void detectAttrMergeConflicts(MetaData target, JsonObject body, // parser's {@code convertJsonArrayToCommaDelimited} treatment of // array-shaped inline attrs. String newVal = normalizeAttrValueForCompare(entry.getValue()); - if (newVal == null || newVal.isEmpty()) continue; + if (newVal == null || (newVal.isEmpty() && !emptyIsAValue)) continue; if (existingVal.equals(newVal)) continue; // Real conflict — emit ERR_MERGE_CONFLICT with merged envelope. @@ -1180,11 +1182,17 @@ private String normalizeAttrValueForCompare(JsonElement el) { return raw; } - /** FR5c — empty-value check matching the TS reference. */ - private static boolean isEmptyJsonValue(JsonElement el) { + /** Attrs whose empty string is a meaningful value rather than "not set" (FR5c). */ + private static final java.util.Set EMPTY_STRING_IS_A_VALUE = + java.util.Set.of(CoreDBMetaDataProvider.FLATTENED_COLUMN_PREFIX); + + /** FR5c — empty-value check matching the TS reference. An attr in + * {@link #EMPTY_STRING_IS_A_VALUE} treats {@code ""} as a value, so {@code ""} beside + * {@code "home_"} is a conflict rather than last-loaded-wins. */ + private static boolean isEmptyJsonValue(JsonElement el, boolean emptyStringIsAValue) { if (el == null || el.isJsonNull()) return true; if (el.isJsonArray() && el.getAsJsonArray().size() == 0) return true; - if (el.isJsonPrimitive() && el.getAsJsonPrimitive().isString() + if (!emptyStringIsAValue && el.isJsonPrimitive() && el.getAsJsonPrimitive().isString() && el.getAsString().isEmpty()) return true; return false; } diff --git a/server/java/metadata/src/main/java/com/metaobjects/query/FilterOps.java b/server/java/metadata/src/main/java/com/metaobjects/query/FilterOps.java index e970857173..09f77297e8 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/query/FilterOps.java +++ b/server/java/metadata/src/main/java/com/metaobjects/query/FilterOps.java @@ -30,6 +30,7 @@ import com.metaobjects.field.UuidField; import com.metaobjects.field.UriField; import com.metaobjects.field.InetField; +import com.metaobjects.field.DurationField; import java.util.Collections; import java.util.LinkedHashMap; @@ -126,6 +127,8 @@ private static Set ordered(String... ops) { m.put(CurrencyField.SUBTYPE_CURRENCY, OPS_NUMERIC); m.put(DateField.SUBTYPE_DATE, OPS_NUMERIC); m.put(TimeField.SUBTYPE_TIME, OPS_NUMERIC); + // field.duration orders on every DB: a native interval on Postgres, a microsecond count elsewhere. + m.put(DurationField.SUBTYPE_DURATION, OPS_NUMERIC); m.put(TimestampField.SUBTYPE_TIMESTAMP, OPS_NUMERIC); m.put(BooleanField.SUBTYPE_BOOLEAN, OPS_BOOLEAN); OPS_BY_SUBTYPE = Collections.unmodifiableMap(m); diff --git a/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java b/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java index 89c236a088..167bbef143 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java +++ b/server/java/metadata/src/main/java/com/metaobjects/registry/RegistryManifest.java @@ -297,7 +297,7 @@ public static List metamodelProviders() { * the 1.0 cut will flip it to {@code "1.0"}. Mirrors the TS reference's * {@code METAMODEL_VERSION} constant. */ - public static final String METAMODEL_VERSION = "1.2"; + public static final String METAMODEL_VERSION = "1.3"; // ------------------------------------------------------------------ // SP-G Phase1 Units2-3 — manifest emitter exclusions (documented, uniform diff --git a/server/java/metadata/src/main/java/com/metaobjects/util/ErrorMessageConstants.java b/server/java/metadata/src/main/java/com/metaobjects/util/ErrorMessageConstants.java index 41402721ff..ca76773a5a 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/util/ErrorMessageConstants.java +++ b/server/java/metadata/src/main/java/com/metaobjects/util/ErrorMessageConstants.java @@ -168,6 +168,42 @@ private ErrorMessageConstants() { */ public static final String ERR_STORAGE_FLATTENED_ARRAY = "ERR_STORAGE_FLATTENED_ARRAY"; + /** + * Error code emitted when {@code @flattenedColumnPrefix} is set on a {@code field.object} + * whose {@code @storage} is not {@code "flattened"} (absent is jsonb): no other storage + * gives the members columns of their own to prefix. + * Cross-language contract: {@code ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE}. + */ + public static final String ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE = + "ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE"; + + /** + * A string-stored {@code field.enum}'s {@code @maxLength} (its column length) is shorter + * than one of its {@code @values} members, or the project's {@code enums.maxLength} is. + * Cross-language contract: {@code ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH}. + */ + public static final String ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH = "ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH"; + + /** + * A {@code field.enum} sets both {@code @maxLength} and {@code @intValueMap}: an int-stored + * enum's column is an integer, so a length would change nothing. + * Cross-language contract: {@code ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP}. + */ + public static final String ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP = "ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP"; + + /** + * An {@code identity.primary} sets {@code @sequenceStart} or {@code @sequenceName} without + * {@code @generation "increment"}: only an increment key draws from a sequence. + * Cross-language contract: {@code ERR_SEQUENCE_WITHOUT_INCREMENT}. + */ + public static final String ERR_SEQUENCE_WITHOUT_INCREMENT = "ERR_SEQUENCE_WITHOUT_INCREMENT"; + + /** + * An {@code identity.primary} {@code @sequenceStart} is below 1, or past the largest value its + * key field can hold. Cross-language contract: {@code ERR_SEQUENCE_START_OUT_OF_RANGE}. + */ + public static final String ERR_SEQUENCE_START_OUT_OF_RANGE = "ERR_SEQUENCE_START_OUT_OF_RANGE"; + /** * Error code emitted when {@code field.object @storage} is set on a field that * has no {@code @objectRef} — storage shape only applies to referenced objects. 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 index ea7ceea0c4..be5aaae941 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/validator/AtLeastOneValidator.java +++ b/server/java/metadata/src/main/java/com/metaobjects/validator/AtLeastOneValidator.java @@ -42,6 +42,8 @@ public static void registerTypes(MetaDataRegistry registry) { .inheritsFrom(TYPE_VALIDATOR, SUBTYPE_BASE); def.requiredAttributeWithConstraints(ATTR_FIELDS).ofType(StringAttribute.SUBTYPE_STRING).asArray(); + def.optionalAttributeWithConstraints(ATTR_CONDITION) + .ofType(com.metaobjects.attr.ExpressionAttribute.SUBTYPE_EXPRESSION).asSingle(); }); } 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 index 82ddc56898..fee1a184c4 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/validator/ComparisonValidator.java +++ b/server/java/metadata/src/main/java/com/metaobjects/validator/ComparisonValidator.java @@ -50,6 +50,8 @@ public static void registerTypes(MetaDataRegistry registry) { def.requiredAttributeWithConstraints(ATTR_OP).ofType(StringAttribute.SUBTYPE_STRING) .withEnum("gt", "gte", "lt", "lte", "ne", "eq"); def.requiredAttributeWithConstraints(ATTR_RIGHT).ofType(StringAttribute.SUBTYPE_STRING).asSingle(); + def.optionalAttributeWithConstraints(ATTR_CONDITION) + .ofType(com.metaobjects.attr.ExpressionAttribute.SUBTYPE_EXPRESSION).asSingle(); }); } diff --git a/server/java/metadata/src/main/java/com/metaobjects/validator/ExactlyOneValidator.java b/server/java/metadata/src/main/java/com/metaobjects/validator/ExactlyOneValidator.java new file mode 100644 index 0000000000..09243591de --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/validator/ExactlyOneValidator.java @@ -0,0 +1,57 @@ +/* + * 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 — exactly one of the named fields ({@code @fields}) + * is present (NOT NULL) and the others are null; it needs two or more fields. 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 ExactlyOneValidator extends MetaValidator { + + public final static String SUBTYPE_EXACTLY_ONE = "exactlyOne"; + + /** Alternative field names; exactly one must be present. */ + public final static String ATTR_FIELDS = "fields"; + + public static void registerTypes(MetaDataRegistry registry) { + registry.registerType(ExactlyOneValidator.class, def -> { + def.type(TYPE_VALIDATOR).subType(SUBTYPE_EXACTLY_ONE) + .description("Presence cardinality — exactly one of @fields must be present") + .inheritsFrom(TYPE_VALIDATOR, SUBTYPE_BASE); + + def.requiredAttributeWithConstraints(ATTR_FIELDS).ofType(StringAttribute.SUBTYPE_STRING).asArray(); + def.optionalAttributeWithConstraints(ATTR_CONDITION) + .ofType(com.metaobjects.attr.ExpressionAttribute.SUBTYPE_EXPRESSION).asSingle(); + }); + } + + public ExactlyOneValidator(String name) { + super(SUBTYPE_EXACTLY_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/MetaValidator.java b/server/java/metadata/src/main/java/com/metaobjects/validator/MetaValidator.java index 656b463353..c0ba1630c3 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/validator/MetaValidator.java +++ b/server/java/metadata/src/main/java/com/metaobjects/validator/MetaValidator.java @@ -43,6 +43,12 @@ public abstract class MetaValidator extends MetaData { /** Cross-port maximum-bound attribute ({@code @max}). */ public final static String ATTR_MAX = "max"; + /** A cross-field validator's @condition: the rule applies only where this expression holds. */ + public final static String ATTR_CONDITION = "condition"; + + /** The database name of the CHECK a CHECK-generating validator (or a field.enum's membership) generates. */ + public final static String ATTR_CONSTRAINT_NAME = "constraintName"; + /** * Register this type with the MetaDataRegistry (called by provider) * @param registry the MetaDataRegistry to register with 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 index 198bf92574..49f4cd049f 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/validator/PresentIffValidator.java +++ b/server/java/metadata/src/main/java/com/metaobjects/validator/PresentIffValidator.java @@ -50,6 +50,8 @@ public static void registerTypes(MetaDataRegistry registry) { 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(); + def.optionalAttributeWithConstraints(ATTR_CONDITION) + .ofType(com.metaobjects.attr.ExpressionAttribute.SUBTYPE_EXPRESSION).asSingle(); }); } 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 index c4b718d2f9..689ed22cda 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/validator/RequiredWhenValidator.java +++ b/server/java/metadata/src/main/java/com/metaobjects/validator/RequiredWhenValidator.java @@ -49,6 +49,8 @@ public static void registerTypes(MetaDataRegistry registry) { 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(); + def.optionalAttributeWithConstraints(ATTR_CONDITION) + .ofType(com.metaobjects.attr.ExpressionAttribute.SUBTYPE_EXPRESSION).asSingle(); }); } 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 a353fc6a7d..572c49b5d7 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 @@ -46,6 +46,7 @@ public void registerTypes(MetaDataRegistry registry) { RequiredWhenValidator.registerTypes(registry); PresentIffValidator.registerTypes(registry); AtLeastOneValidator.registerTypes(registry); + ExactlyOneValidator.registerTypes(registry); log.debug("Validator types registered via provider"); } diff --git a/server/java/metadata/src/test/java/com/metaobjects/config/WireConformanceTest.java b/server/java/metadata/src/test/java/com/metaobjects/config/WireConformanceTest.java index 9333e30ae9..ec4649f3dd 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/config/WireConformanceTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/config/WireConformanceTest.java @@ -63,9 +63,10 @@ public void everyValidBlockResolvesToItsValues() throws IOException { WireFormat wire = parse(c.get("wire"), "case:" + name); assertEquals(name + ": decimals", c.get("decimals").getAsString(), wire.decimals().wireName()); assertEquals(name + ": nullMembers", c.get("nullMembers").getAsString(), wire.nullMembers().wireName()); + assertEquals(name + ": durations", c.get("durations").getAsString(), wire.durations().wireName()); n++; } - assertEquals(6, n); + assertEquals(7, n); } @Test @@ -77,6 +78,6 @@ public void everyInvalidBlockIsRefused() throws IOException { assertThrows(name, MetaDataException.class, () -> parse(c.get("wire"), "invalid-case:" + name)); n++; } - assertEquals(8, n); + assertEquals(10, n); } } diff --git a/server/java/metadata/src/test/java/com/metaobjects/database/ColumnNamingTest.java b/server/java/metadata/src/test/java/com/metaobjects/database/ColumnNamingTest.java index 49807ae50d..395e1d9866 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/database/ColumnNamingTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/database/ColumnNamingTest.java @@ -68,7 +68,7 @@ public void snakeCaseMatchesTheCrossPortAlgorithm() { public void anUnknownStrategyIsRefusedNotSilentlyDefaulted() { // A typo would otherwise bind a whole schema to the wrong columns and succeed. IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, - () -> ColumnNaming.apply("createdAt", "PascalCase")); + () -> ColumnNaming.apply("createdAt", "camelCase")); org.junit.Assert.assertTrue(ex.getMessage(), ex.getMessage().contains("snake_case")); } } diff --git a/server/java/metadata/src/test/java/com/metaobjects/database/EnumLengthConformanceTest.java b/server/java/metadata/src/test/java/com/metaobjects/database/EnumLengthConformanceTest.java new file mode 100644 index 0000000000..ae4386fc07 --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/database/EnumLengthConformanceTest.java @@ -0,0 +1,112 @@ +package com.metaobjects.database; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.metaobjects.ErrorCode; +import com.metaobjects.MetaDataException; +import com.metaobjects.config.EnumDefaults; +import com.metaobjects.config.NeutralConfig; +import com.metaobjects.field.MetaField; +import com.metaobjects.loader.FileSource; +import com.metaobjects.loader.LoaderOptions; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.object.MetaObject; +import com.metaobjects.registry.SharedRegistryTestBase; +import org.junit.Test; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * The shared cross-port enum-length corpus ({@code fixtures/enum-length-conformance/}) run + * against the Java port: each case's {@code enums} block parsed with {@link NeutralConfig} + * (the reader every project-aware load uses), put on the loaded {@code meta.enums.json} root, + * and every Ticket enum's column length resolved through {@link EnumLength#resolveMaxLength}. + * Each {@code overflow} case is refused with {@code ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH}, and + * each {@code invalid} block when the config is read. + */ +public class EnumLengthConformanceTest extends SharedRegistryTestBase { + + private static Path corpusDir() { + Path dir = Paths.get("").toAbsolutePath(); + while (dir != null && !Files.isDirectory(dir.resolve("fixtures"))) dir = dir.getParent(); + if (dir == null) throw new IllegalStateException("could not locate the repository fixtures/ directory"); + return dir.resolve("fixtures/enum-length-conformance"); + } + + private static JsonObject corpus() throws IOException { + return JsonParser.parseString(new String( + Files.readAllBytes(corpusDir().resolve("cases.json")), StandardCharsets.UTF_8)).getAsJsonObject(); + } + + private static EnumDefaults parseEnums(JsonElement enums) { + JsonObject doc = new JsonObject(); + doc.addProperty("schema_version", 1); + if (enums != null && !enums.isJsonNull()) doc.add("enums", enums); + return NeutralConfig.parse(doc.toString(), "enum-length-corpus").getEnumDefaults(); + } + + private MetaField ticketField(EnumDefaults defaults, String name) throws IOException { + MetaDataLoader loader = new MetaDataLoader( + LoaderOptions.create(false, false, true), MetaDataLoader.SUBTYPE_MANUAL, "enum_length_conformance"); + loader.init(); + loader.load(List.of(new FileSource(corpusDir().resolve("meta.enums.json")))); + if (!loader.getErrors().isEmpty()) fail("expected a clean load, got: " + loader.getErrors()); + loader.getRoot().setEnumDefaults(defaults); + for (MetaObject obj : loader.getMetaObjects()) { + if (obj.getShortName().equals("Ticket")) return obj.getMetaField(name); + } + throw new AssertionError("no Ticket in the corpus model"); + } + + @Test + public void resolvesEveryEnumColumnLength() throws IOException { + for (JsonElement el : corpus().getAsJsonArray("cases")) { + JsonObject c = el.getAsJsonObject(); + EnumDefaults defaults = parseEnums(c.get("enums")); + for (Map.Entry e : c.getAsJsonObject("lengths").entrySet()) { + Long expected = e.getValue().isJsonNull() ? null : e.getValue().getAsLong(); + assertEquals(c.get("name").getAsString() + ": " + e.getKey(), + expected, EnumLength.resolveMaxLength(ticketField(defaults, e.getKey()))); + } + } + } + + @Test + public void refusesAMemberLongerThanTheProjectDefault() throws IOException { + for (JsonElement el : corpus().getAsJsonArray("overflow")) { + JsonObject o = el.getAsJsonObject(); + MetaField field = ticketField(parseEnums(o.get("enums")), o.get("field").getAsString()); + try { + EnumLength.resolveMaxLength(field); + fail(o.get("name").getAsString() + ": expected a refusal"); + } catch (MetaDataException e) { + assertEquals(java.util.Optional.of(ErrorCode.ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH), e.getCode()); + assertTrue(e.getMessage(), e.getMessage().contains(o.get("member").getAsString())); + } + } + } + + @Test + public void refusesAnInvalidEnumsBlock() throws IOException { + for (JsonElement el : corpus().getAsJsonArray("invalid")) { + JsonObject bad = el.getAsJsonObject(); + try { + parseEnums(bad.get("enums")); + fail(bad.get("name").getAsString() + ": expected the config to be refused"); + } catch (MetaDataException expected) { + // refused when read + } + } + } +} diff --git a/server/java/metadata/src/test/java/com/metaobjects/database/PhysicalNamingConformanceTest.java b/server/java/metadata/src/test/java/com/metaobjects/database/PhysicalNamingConformanceTest.java index e66269d11f..6087ea55ac 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/database/PhysicalNamingConformanceTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/database/PhysicalNamingConformanceTest.java @@ -22,6 +22,7 @@ import com.metaobjects.MetaDataException; import com.metaobjects.config.NeutralConfig; import com.metaobjects.field.MetaField; +import com.metaobjects.field.ObjectField; import com.metaobjects.loader.LoaderOptions; import com.metaobjects.loader.MetaDataLoader; import com.metaobjects.object.MetaObject; @@ -164,10 +165,18 @@ public void resolvesEveryTableAndColumnUnderThisNamingRule() throws IOException } for (Map.Entry e : testCase.columns().entrySet()) { - String[] parts = e.getKey().split("\\.", 2); + String[] parts = e.getKey().split("\\.", 3); MetaObject obj = findObject(loader, parts[0]); MetaField field = obj.getMetaField(parts[1]); - String actual = ColumnNaming.resolve(field); + String actual; + if (parts.length == 2) { + actual = ColumnNaming.resolve(field); + } else { + // `..`: a member of the flattened value object the + // field embeds, named by core naming. + MetaField member = ((ObjectField) field).getObjectRef().getMetaField(parts[2]); + actual = ColumnNaming.resolveFlattened(field, member); + } assertEquals(caseName + ": column name for " + e.getKey(), e.getValue(), actual); } } diff --git a/server/java/metadata/src/test/java/com/metaobjects/field/DurationWireVectorsTest.java b/server/java/metadata/src/test/java/com/metaobjects/field/DurationWireVectorsTest.java new file mode 100644 index 0000000000..d4c5236fee --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/field/DurationWireVectorsTest.java @@ -0,0 +1,179 @@ +package com.metaobjects.field; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.Test; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.time.temporal.ChronoUnit; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * The shared {@code field.duration} vectors ({@code fixtures/duration-wire/vectors.json}) against + * the production helper, {@link DurationWire}: every {@code wire} group, at this port's precision + * ({@code "9"}: a {@link Duration} holds nanoseconds), and the {@code codecs} groups OMDB uses in + * production — {@code JdbcCodecs.DurationCodec} reads a Postgres interval as its text + * ({@code postgres}, {@code postgresRefused}), {@code PostgresDriver} binds the interval spelling + * ({@code postgresText}), and SQLite / MySQL store a microsecond count ({@code storedMicros}). + */ +public class DurationWireVectorsTest { + + /** This port's key in {@code wire.precision[].out}: java.time.Duration holds nine fractional digits. */ + private static final String PRECISION = "9"; + + private static JsonObject file() throws Exception { + Path dir = Path.of(System.getProperty("user.dir")).toAbsolutePath(); + while (dir != null && !Files.isDirectory(dir.resolve("fixtures/duration-wire"))) dir = dir.getParent(); + if (dir == null) throw new IllegalStateException("fixtures/duration-wire not found above " + System.getProperty("user.dir")); + return JsonParser.parseString(Files.readString(dir.resolve("fixtures/duration-wire/vectors.json"))).getAsJsonObject(); + } + + private static JsonObject wire() throws Exception { + return file().getAsJsonObject("wire"); + } + + private static JsonObject codecs() throws Exception { + return file().getAsJsonObject("codecs"); + } + + @Test + public void parseYieldsTheCanonicalForm() throws Exception { + for (JsonElement e : wire().getAsJsonArray("parse")) { + String in = e.getAsJsonObject().get("in").getAsString(); + String out = e.getAsJsonObject().get("out").getAsString(); + assertTrue("accepted: " + in, DurationWire.isDuration(in)); + assertEquals("parse " + in, out, DurationWire.canonicalize(in)); + // The canonical form is a fixed point. + assertEquals("re-parse " + out, out, DurationWire.canonicalize(out)); + } + } + + @Test + public void precisionKeepsNineDigitsAndTruncatesTheRestTowardZero() throws Exception { + for (JsonElement e : wire().getAsJsonArray("precision")) { + String in = e.getAsJsonObject().get("in").getAsString(); + String out = e.getAsJsonObject().getAsJsonObject("out").get(PRECISION).getAsString(); + assertTrue("accepted: " + in, DurationWire.isDuration(in)); + assertEquals("precision " + in, out, DurationWire.canonicalize(in)); + assertEquals("re-parse " + out, out, DurationWire.canonicalize(out)); + } + } + + @Test + public void refusedInputIsRejected() throws Exception { + for (JsonElement e : wire().getAsJsonArray("refused")) { + String in = e.getAsJsonPrimitive().getAsString(); + assertFalse("refused: '" + in + "'", DurationWire.isDuration(in)); + try { + DurationWire.parse(in); + fail("parse must refuse '" + in + "'"); + } catch (IllegalArgumentException expected) { + // refused + } + } + } + + @Test + public void postgresTextReadsAsTheCanonicalForm() throws Exception { + for (JsonElement e : codecs().getAsJsonArray("postgres")) { + String in = e.getAsJsonObject().get("in").getAsString(); + String out = e.getAsJsonObject().get("out").getAsString(); + assertEquals("postgres " + in, out, DurationWire.format(DurationWire.parsePostgres(in))); + assertEquals("stored " + in, out, DurationWire.canonicalizeStored(in)); + } + } + + @Test + public void postgresCalendarIntervalsAreRefused() throws Exception { + for (JsonElement e : codecs().getAsJsonArray("postgresRefused")) { + String in = e.getAsJsonPrimitive().getAsString(); + try { + DurationWire.fromStored(in); + fail("must refuse the interval '" + in + "'"); + } catch (IllegalArgumentException expected) { + // refused + } + } + } + + @Test + public void postgresTextSpelling() throws Exception { + for (JsonElement e : codecs().getAsJsonArray("postgresText")) { + String in = e.getAsJsonObject().get("in").getAsString(); + String out = e.getAsJsonObject().get("out").getAsString(); + assertEquals("postgresText " + in, out, DurationWire.formatPostgres(DurationWire.parse(in))); + // An interval written that way reads back as the canonical value. + assertEquals("read back " + out, in, DurationWire.format(DurationWire.parsePostgres(out))); + } + } + + @Test + public void formatKeepsNanosAndTheStoredFormsTruncateToMicros() { + assertEquals("PT1.123456789S", DurationWire.format(Duration.ofSeconds(1, 123_456_789))); + assertEquals("-PT1.123456789S", DurationWire.format(Duration.ofSeconds(1, 123_456_789).negated())); + assertEquals("-PT0.000000999S", DurationWire.format(Duration.ofNanos(-999))); + assertEquals("-PT1M30S", DurationWire.format(Duration.ofSeconds(-90))); + // A Postgres interval holds microseconds: the interval spelling truncates toward zero. + assertEquals("00:00:01.123456", DurationWire.formatPostgres(Duration.ofSeconds(1, 123_456_789))); + assertEquals("-00:00:01.123456", DurationWire.formatPostgres(Duration.ofSeconds(1, 123_456_789).negated())); + assertEquals("00:00:00", DurationWire.formatPostgres(Duration.ofNanos(-999))); + } + + @Test + public void formattingIgnoresTheDefaultLocalesDigits() { + java.util.Locale saved = java.util.Locale.getDefault(); + try { + java.util.Locale.setDefault(java.util.Locale.forLanguageTag("ar-EG-u-nu-arab")); + assertEquals("-PT1M30.000005S", DurationWire.format(Duration.ofSeconds(-90, -5_000))); + assertEquals("-00:01:30.000005", DurationWire.formatPostgres(Duration.ofSeconds(-90, -5_000))); + } finally { + java.util.Locale.setDefault(saved); + } + } + + @Test + public void storedMicrosVectors() throws Exception { + for (JsonElement e : codecs().getAsJsonArray("storedMicros")) { + String in = e.getAsJsonObject().get("in").getAsString(); + long out = Long.parseLong(e.getAsJsonObject().get("out").getAsString()); + Duration d = DurationWire.parse(in); + assertEquals("micros " + in, out, DurationWire.toMicros(d)); + // The stored count reads back as the value truncated to microseconds, toward zero. + assertEquals("from micros " + out, DurationWire.format(d.truncatedTo(ChronoUnit.MICROS)), + DurationWire.format(DurationWire.fromMicros(out))); + } + // Sub-microsecond precision truncates toward zero on both sides of zero. + assertEquals(-1_000_000L, DurationWire.toMicros(Duration.ofSeconds(-1, -500))); + assertEquals(1_000_000L, DurationWire.toMicros(Duration.ofSeconds(1, 500))); + } + + @Test + public void javaDurationsOwnRangeIsTheOnlyLimitAndIsRefusedNeverWrapped() { + // Far past any other port's range: Java takes it. + assertEquals("PT2562047788015215H", DurationWire.canonicalize("PT2562047788015215H")); + // Past a long count of seconds: refused, never wrapped. + for (String tooLong : new String[]{"PT9223372036854775808S", "P106751991167301D", "PT99999999999999999999H"}) { + assertFalse(tooLong, DurationWire.isDuration(tooLong)); + try { + DurationWire.parse(tooLong); + fail("must refuse " + tooLong); + } catch (IllegalArgumentException expected) { + // refused + } + } + // A BIGINT column holds about 292,000 years of microseconds: past it, the write is refused. + try { + DurationWire.toMicros(Duration.ofSeconds(Long.MAX_VALUE / 1_000_000L + 1)); + fail("past a 64-bit microsecond count must be refused"); + } catch (IllegalArgumentException expected) { + // refused + } + } +} diff --git a/server/python/src/metaobjects/config/neutral_config.py b/server/python/src/metaobjects/config/neutral_config.py index 93bfd7495c..cb0e043240 100644 --- a/server/python/src/metaobjects/config/neutral_config.py +++ b/server/python/src/metaobjects/config/neutral_config.py @@ -7,7 +7,7 @@ from metaobjects.errors import ErrorCode, ParseError from metaobjects.naming import COLUMN_NAMING_STRATEGIES, TABLE_NAMING_RULES, NamingRules -from metaobjects.wire import WIRE_DECIMALS, WIRE_NULL_MEMBERS, WireFormat +from metaobjects.wire import WIRE_DECIMALS, WIRE_DURATIONS, WIRE_NULL_MEMBERS, WireFormat #: The DEFAULT value of `sources` when the key is absent or empty — never a #: requirement, and never assumed to exist by any other code path. @@ -175,6 +175,7 @@ def read_neutral_config(config_dir: Path) -> NeutralConfig | None: libraries = _read_libraries(raw, path) naming = _read_naming(raw, path) wire = _read_wire(raw, path) + _check_enums(raw, path) # Unknown top-level keys are IGNORED by design — see the module docstring. return NeutralConfig( @@ -305,12 +306,48 @@ def _read_naming(raw: dict[str, object], path: Path) -> NamingRules: return NamingRules(columns=columns, tables=tables) +#: Every key the `enums` block accepts, and the ONLY ones it accepts (refused, not ignored, like `naming`). +_ENUMS_KEYS = ("maxLength",) + + +def _check_enums(raw: dict[str, object], path: Path) -> None: + """The `enums` block: `maxLength`, a positive integer, is the column length a port that + sizes a string-stored enum's column gives one with no @maxLength. This port sizes no + column (it emits no DDL and no column type), so it reads nothing from the block, but a + project shared with a port that does has one: the block is accepted, and its shape is + checked so a typo is refused here as it is there (a non-object block, an unknown key, or + a value that is not a positive integer).""" + if "enums" not in raw: + return + enums = raw["enums"] + if not isinstance(enums, dict): + raise ParseError(f"{path}: 'enums' must be an object", code=ErrorCode.ERR_BAD_ATTR_VALUE) + unknown = sorted(set(enums) - set(_ENUMS_KEYS)) + if unknown: + raise ParseError( + f"{path}: 'enums' has unknown key(s) {unknown}; accepted: {list(_ENUMS_KEYS)}", + code=ErrorCode.ERR_BAD_ATTR_VALUE, + ) + if "maxLength" not in enums: + return + value = enums["maxLength"] + # A whole number however JSON spells it (32, 32.0, 1e2): a reader that parses numbers as + # doubles (TypeScript's) cannot tell those apart, so every port accepts all three. + if isinstance(value, float) and value.is_integer(): + value = int(value) + if isinstance(value, bool) or not isinstance(value, int) or value <= 0: + raise ParseError( + f"{path}: 'enums.maxLength' must be a positive integer, got {value!r}", + code=ErrorCode.ERR_BAD_ATTR_VALUE, + ) + + #: Every key the `wire` block accepts, and the ONLY ones it accepts (refused, not ignored, like `naming`). -_WIRE_KEYS = ("decimals", "nullMembers") +_WIRE_KEYS = ("decimals", "nullMembers", "durations") def _read_wire(raw: dict[str, object], path: Path) -> WireFormat: - """ADR-0019 Amendment 2 — the `wire` block. Absent or `{}` ⇒ `native` for both keys. + """ADR-0019 Amendment 2 — the `wire` block. Absent or `{}` ⇒ `native` for every key. Refuses a non-object `wire`, an unknown key, or a value outside the closed set a key accepts: a mistake must not silently fall back to the default and change the JSON an API writes.""" @@ -322,11 +359,11 @@ def _read_wire(raw: dict[str, object], path: Path) -> WireFormat: for key in wire: if key not in _WIRE_KEYS: raise ParseError( - f'{path}: "wire" has an unknown key "{key}" (expected "decimals" and/or "nullMembers")', + f'{path}: "wire" has an unknown key "{key}" (expected "decimals", "nullMembers" and/or "durations")', code=ErrorCode.ERR_COLLECTION_NOT_FOUND, ) values: dict[str, str] = {} - for key, allowed in (("decimals", WIRE_DECIMALS), ("nullMembers", WIRE_NULL_MEMBERS)): + for key, allowed in (("decimals", WIRE_DECIMALS), ("nullMembers", WIRE_NULL_MEMBERS), ("durations", WIRE_DURATIONS)): value = wire.get(key, allowed[0]) if not isinstance(value, str) or value not in allowed: choices = ", ".join(f'"{a}"' for a in allowed) @@ -335,7 +372,7 @@ def _read_wire(raw: dict[str, object], path: Path) -> WireFormat: code=ErrorCode.ERR_COLLECTION_NOT_FOUND, ) values[key] = value - return WireFormat(decimals=values["decimals"], null_members=values["nullMembers"]) + return WireFormat(decimals=values["decimals"], null_members=values["nullMembers"], durations=values["durations"]) def _validate_dependency_spec(dep: object, path: Path) -> dict[str, str]: diff --git a/server/python/src/metaobjects/core_types.py b/server/python/src/metaobjects/core_types.py index 5a00288a48..f08b8d23f4 100644 --- a/server/python/src/metaobjects/core_types.py +++ b/server/python/src/metaobjects/core_types.py @@ -1078,6 +1078,8 @@ def _register_subtypes( AttrSchema(name=vc.VALIDATOR_ATTR_MIN, value_type=ATTR_SUBTYPE_INT), AttrSchema(name=vc.VALIDATOR_ATTR_MAX, value_type=ATTR_SUBTYPE_INT), ] +# A cross-field validator's optional @condition (an expression over the entity's fields). +_VALIDATOR_CONDITION_ATTR = AttrSchema(name=vc.VALIDATOR_ATTR_CONDITION, value_type=ATTR_SUBTYPE_EXPRESSION) _VALIDATOR_ATTRS_BY_SUBTYPE: dict[str, list[AttrSchema]] = { SUBTYPE_BASE: list(_VALIDATOR_MIN_MAX_ATTRS), vc.VALIDATOR_SUBTYPE_REQUIRED: [], @@ -1094,19 +1096,27 @@ def _register_subtypes( 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), + _VALIDATOR_CONDITION_ATTR, ], 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), + _VALIDATOR_CONDITION_ATTR, ], 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), + _VALIDATOR_CONDITION_ATTR, ], vc.VALIDATOR_SUBTYPE_AT_LEAST_ONE: [ AttrSchema(name=vc.VALIDATOR_ATTR_FIELDS, value_type=ATTR_SUBTYPE_STRING, required=True, is_array=True), + _VALIDATOR_CONDITION_ATTR, + ], + vc.VALIDATOR_SUBTYPE_EXACTLY_ONE: [ + AttrSchema(name=vc.VALIDATOR_ATTR_FIELDS, value_type=ATTR_SUBTYPE_STRING, required=True, is_array=True), + _VALIDATOR_CONDITION_ATTR, ], } for _sub, _attrs in _VALIDATOR_ATTRS_BY_SUBTYPE.items(): diff --git a/server/python/src/metaobjects/errors.py b/server/python/src/metaobjects/errors.py index eac4041d32..3fdcb7d610 100644 --- a/server/python/src/metaobjects/errors.py +++ b/server/python/src/metaobjects/errors.py @@ -208,6 +208,13 @@ class ErrorCode(str, Enum): # folds `userId` and `userID` together, or two fields share an @column). ERR_COLUMN_NAME_COLLISION = "ERR_COLUMN_NAME_COLLISION" ERR_STORAGE_FLATTENED_ARRAY = "ERR_STORAGE_FLATTENED_ARRAY" + ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH = "ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH" + ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP = "ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP" + ERR_SEQUENCE_WITHOUT_INCREMENT = "ERR_SEQUENCE_WITHOUT_INCREMENT" + ERR_SEQUENCE_START_OUT_OF_RANGE = "ERR_SEQUENCE_START_OUT_OF_RANGE" + ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE = ( + "ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE" + ) ERR_STORAGE_WITHOUT_OBJECT_REF = "ERR_STORAGE_WITHOUT_OBJECT_REF" # ADR-0013: a field.object REQUIRES @objectRef (open/untyped JSON uses the # physical @dbColumnType: jsonb escape hatch on field.string). diff --git a/server/python/src/metaobjects/loader/merge.py b/server/python/src/metaobjects/loader/merge.py index 95a61dd231..bc761c60dc 100644 --- a/server/python/src/metaobjects/loader/merge.py +++ b/server/python/src/metaobjects/loader/merge.py @@ -32,6 +32,7 @@ from typing import Optional from ..errors import ErrorCode, MetaError +from ..meta.core.field.field_constants import FIELD_ATTR_FLATTENED_COLUMN_PREFIX from ..meta.meta_data import MetaData from ..serializer_json import canonical_serialize from ..shared.separators import PACKAGE_SEP @@ -273,14 +274,19 @@ def _build_contributors(files: tuple[str, ...]) -> tuple[Contributor, ...]: ) -def _is_empty_value(v: object) -> bool: +#: Attrs whose empty string is a meaningful value rather than "not set" (FR5c). +_EMPTY_STRING_IS_A_VALUE: frozenset[str] = frozenset({FIELD_ATTR_FLATTENED_COLUMN_PREFIX}) + + +def _is_empty_value(v: object, attr_name: str) -> bool: """Mirror TS ``isEmptyValue``: ``None``, ``""``, and ``[]`` are empty; - everything else is set. + everything else is set. ``@flattenedColumnPrefix: ""`` is a value (keep the + members' own columns), so ``""`` against ``"home_"`` is a conflict. """ if v is None: return True if isinstance(v, str) and v == "": - return True + return attr_name not in _EMPTY_STRING_IS_A_VALUE if isinstance(v, list) and not v: return True return False @@ -333,7 +339,7 @@ def _detect_attr_merge_conflicts( existing_val = pre_attrs[attr_name] new_val = getattr(src_attr, "value", None) - if _is_empty_value(new_val) or _is_empty_value(existing_val): + if _is_empty_value(new_val, attr_name) or _is_empty_value(existing_val, attr_name): continue if _attr_values_equal(existing_val, new_val): continue diff --git a/server/python/src/metaobjects/loader/validation_passes.py b/server/python/src/metaobjects/loader/validation_passes.py index bb2c548cee..0f80640e6a 100644 --- a/server/python/src/metaobjects/loader/validation_passes.py +++ b/server/python/src/metaobjects/loader/validation_passes.py @@ -7,6 +7,7 @@ """ from __future__ import annotations +import json import math import re from typing import Callable, NamedTuple @@ -28,9 +29,12 @@ FIELD_ATTR_COERCE_DEFAULT, FIELD_ATTR_DEFAULT, FIELD_ATTR_INT_VALUE_MAP, + FIELD_ATTR_MAX_LENGTH, FIELD_ATTR_OBJECT_REF, FIELD_ATTR_REQUIRED, FIELD_ATTR_SORTABLE, + FIELD_ATTR_FLATTENED_COLUMN_PREFIX, + STORAGE_FLATTENED, FIELD_ATTR_STORAGE, FIELD_ATTR_VALUE_TYPE, FIELD_ATTR_VALUES, @@ -44,6 +48,7 @@ FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_INET, + FIELD_SUBTYPE_DURATION, FIELD_SUBTYPE_MAP, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_STRING, @@ -85,6 +90,7 @@ TYPE_REQUIREMENT, TYPE_SOURCE, TYPE_TEMPLATE, + TYPE_VALIDATOR, ) from ..meta.template import template_constants as tc from ..meta.presentation.layout.layout_constants import ( @@ -247,6 +253,12 @@ def run_validations( # FR-015 — source.rdb @parameterRef typed-input rules. validate_source_parameter_ref(root, errors) _validate_field_object_storage(root, errors) + # field.enum @maxLength — every member fits; refused with @intValueMap. + _validate_enum_max_length(root, errors) + # identity.primary @sequenceStart / @sequenceName need @generation "increment". + _validate_identity_sequence(root, errors) + # cross-field validators — exactlyOne names two or more fields; @condition names entity fields. + _validate_cross_field_validators(root, errors) # field.map — exactly one of @valueType (scalar) or @objectRef (VO), and # @valueType (when set) must name a known scalar subtype. _validate_field_map(root, errors) @@ -1097,6 +1109,10 @@ def ops_for_subtype(field_subtype: str) -> frozenset[str]: return _OPS_STRING if field_subtype in ("uuid", FIELD_SUBTYPE_INET): return _OPS_UUID + # field.duration is an orderable span: the numeric/temporal band. Every engine orders it + # (a Postgres interval; a microsecond integer on SQLite, D1 and MySQL). + if field_subtype == FIELD_SUBTYPE_DURATION: + return _OPS_NUMERIC_TEMPORAL if field_subtype == "boolean": return _OPS_BOOLEAN if field_subtype in _NUMERIC_TEMPORAL_SUBTYPES: @@ -3711,6 +3727,282 @@ def _validate_sortable_has_supported_subtype( # skipped when @objectRef is absent). # 2. @storage="flattened" + isArray → ERR_STORAGE_FLATTENED_ARRAY (flattened # materialises one-column-per-field; arrays require @storage="jsonb"). +# 3. @flattenedColumnPrefix without @storage="flattened" → +# ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE (no other storage gives the +# members columns of their own to prefix). + + +# identity.primary @sequenceStart / @sequenceName describe the sequence an @generation +# "increment" key draws from; on any other generation there is none, so either one is refused +# (ERR_SEQUENCE_WITHOUT_INCREMENT). Read RESOLVING (ADR-0039), reported on the identity that +# declares one of them. Mirrors TS validateIdentitySequence. +#: The largest value a field.int key (a 32-bit integer column) can hold. +_SEQUENCE_INT_MAX = 2147483647 + + +def _validate_identity_sequence(root: MetaData, errors: list[MetaError]) -> None: + from ..meta.core.identity.identity_constants import ( + GENERATION_INCREMENT, + IDENTITY_ATTR_GENERATION, + IDENTITY_SUBTYPE_PRIMARY, + ) + from ..meta.persistence.db.db_constants import ( + IDENTITY_PRIMARY_ATTR_SEQUENCE_NAME, + IDENTITY_PRIMARY_ATTR_SEQUENCE_START, + ) + + for obj in root.children(): + if obj.type != TYPE_OBJECT: + continue + for ident in obj.own_children(): + if ident.type != TYPE_IDENTITY or ident.sub_type != IDENTITY_SUBTYPE_PRIMARY: + continue + declared = [ + f"@{a}" + for a in (IDENTITY_PRIMARY_ATTR_SEQUENCE_START, IDENTITY_PRIMARY_ATTR_SEQUENCE_NAME) + if ident.attr(a) is not None + ] + generation = ident.get_meta_attr(IDENTITY_ATTR_GENERATION) + if not declared: + continue + if generation == GENERATION_INCREMENT: + # The key field's own range bounds the start (Postgres refuses the sequence + # otherwise, at apply time): 1 to the largest value of a field.int / field.long. + start = ident.get_meta_attr(IDENTITY_PRIMARY_ATTR_SEQUENCE_START) + if isinstance(start, int) and not isinstance(start, bool): + fields = ident.get_meta_attr(IDENTITY_ATTR_FIELDS) + key_name = fields[0] if isinstance(fields, list) and fields else fields + key = next((f for f in obj.fields() if f.name == key_name), None) + maximum = _SEQUENCE_INT_MAX if key is not None and key.sub_type == FIELD_SUBTYPE_INT else None + if start < 1 or (maximum is not None and start > maximum): + kind = f" (field.{key.sub_type})" if key is not None else "" + upper = f" and at most {maximum}" if maximum is not None else "" + errors.append(MetaError( + code=ErrorCode.ERR_SEQUENCE_START_OUT_OF_RANGE, + message=( + f'identity.primary "{obj.name}.{ident.name}" @sequenceStart {start} is out ' + f'of range for its key "{key_name}"{kind}: it must be at least 1{upper}.' + ), + envelope=ident.source, + )) + continue + shown = f'"{generation}"' if isinstance(generation, str) else "not set" + names = " and ".join(declared) + errors.append(MetaError( + code=ErrorCode.ERR_SEQUENCE_WITHOUT_INCREMENT, + message=( + f'identity.primary "{obj.name}.{ident.name}" sets {names}, but its @generation ' + f'is {shown}; only an @generation "increment" key draws from a sequence. Set ' + f'@generation "increment", or remove {names}.' + ), + envelope=ident.source, + )) + + +# Cross-field validators (comparison / requiredWhen / presentIff / atLeastOne / exactlyOne), which +# the database enforces as CHECKs: exactlyOne @fields names two or more fields, +# every field a @condition names a field of the owning entity, and a @condition that can +# become a CHECK guard (see _condition_problem) — all ERR_BAD_ATTR_VALUE. Read RESOLVING +# (ADR-0039). Mirrors TS validateCrossFieldValidators. +def _expr_field_refs(node: object, out: set[str]) -> None: + if not isinstance(node, dict): + return + if isinstance(node.get("field"), str): + out.add(node["field"]) + for key in ("left", "right", "arg"): + _expr_field_refs(node.get(key), out) + args = node.get("args") + if isinstance(args, list): + for a in args: + _expr_field_refs(a, out) + + +# Field subtypes whose values a condition compares with a number literal. +_CONDITION_NUMBER_SUBTYPES = frozenset({ + FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, + FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_CURRENCY, +}) + + +def _json_literal(value: object) -> str: + """A literal as JSON.stringify writes it, so the message matches the TS port's.""" + if isinstance(value, float) and value.is_integer(): + value = int(value) + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +def _literal_is_kind(value: object, kind: str) -> bool: + # bool is a subclass of int in Python: a bool literal is not a number, nor a number a bool. + if kind == "boolean": + return isinstance(value, bool) + if kind == "number": + return isinstance(value, (int, float)) and not isinstance(value, bool) + return isinstance(value, str) + + +def _condition_problem(condition: object, obj: MetaData) -> str | None: + """Why a cross-field validator's ``@condition`` cannot become a CHECK guard, or None when + it can. Its names are already known to be fields. The rule, the same in every port: + - it types cleanly (the origin.computed inference) and its root is boolean, since a + CHECK negates it; + - a literal compared with a field is of that field's kind (a boolean for field.boolean, + a number for a numeric field, a string otherwise) and, for field.enum, one of its + @values; + - no comparison has a null literal: ``x = NULL`` is never true, so use isNull / isNotNull. + Mirrors TS conditionProblem.""" + fields = {f.name: f for f in obj.fields()} + inferred = infer_expr_type( + condition, lambda name: fields[name].sub_type if name in fields else None + ) + if inferred.errors: + return f"is not a valid expression: {'; '.join(inferred.errors)}." + if inferred.type != FIELD_SUBTYPE_BOOLEAN: + return ( + "must be a boolean expression (a comparison, a null test, or and / or / not of " + f"them), not a field.{inferred.type}." + ) + + def visit(node: object) -> str | None: + if not isinstance(node, dict): + return None + if node.get("left") is not None and node.get("right") is not None: + for lit, ref in ((node["left"], node["right"]), (node["right"], node["left"])): + if not isinstance(lit, dict) or "value" not in lit: + continue + value = lit["value"] + if value is None: + return "compares with null, which is never true: use isNull or isNotNull." + name = ref.get("field") if isinstance(ref, dict) else None + field = fields.get(name) if isinstance(name, str) else None + if field is None: + continue + sub = field.sub_type + kind = ( + "boolean" if sub == FIELD_SUBTYPE_BOOLEAN + else "number" if sub in _CONDITION_NUMBER_SUBTYPES + else "string" + ) + if not _literal_is_kind(value, kind): + return ( + f'compares "{field.name}" (field.{sub}) with {_json_literal(value)}; ' + f"it needs a {kind}." + ) + members = field.get_meta_attr(FIELD_ATTR_VALUES) if sub == FIELD_SUBTYPE_ENUM else None + if isinstance(members, list) and str(value) not in [str(m) for m in members]: + return ( + f'compares "{field.name}" with {_json_literal(value)}, which is not one ' + f"of its @values ({', '.join(str(m) for m in members)})." + ) + for key in ("left", "right", "arg"): + found = visit(node.get(key)) + if found is not None: + return found + args = node.get("args") + if isinstance(args, list): + for a in args: + found = visit(a) + if found is not None: + return found + return None + + return visit(condition) + + +def _validate_cross_field_validators(root: MetaData, errors: list[MetaError]) -> None: + from ..meta.core.validator import validator_constants as vc + + for obj in root.children(): + if obj.type != TYPE_OBJECT: + continue + field_names = {f.name for f in obj.fields()} + for v in obj.own_children(): + if v.type != TYPE_VALIDATOR or v.sub_type not in vc.CROSS_FIELD_VALIDATOR_SUBTYPES: + continue + if v.sub_type == vc.VALIDATOR_SUBTYPE_EXACTLY_ONE: + fields = v.get_meta_attr(vc.VALIDATOR_ATTR_FIELDS) + count = len(fields) if isinstance(fields, list) else (0 if fields is None else 1) + if count < 2: + plural = "" if count == 1 else "s" + errors.append(MetaError( + code=ErrorCode.ERR_BAD_ATTR_VALUE, + message=( + f'validator.exactlyOne "{obj.name}.{v.name}" @fields names {count} ' + f"field{plural}; it needs two or more. For one field, make that field required." + ), + envelope=v.source, + )) + continue + condition = v.get_meta_attr(vc.VALIDATOR_ATTR_CONDITION) + if condition is None: + continue + label = f'validator.{v.sub_type} "{obj.name}.{v.name}" @condition' + refs: set[str] = set() + _expr_field_refs(condition, refs) + unknown = sorted(refs - field_names) + if unknown: + names = ", ".join(f'"{u}"' for u in unknown) + verb = "is not a field" if len(unknown) == 1 else "are not fields" + errors.append(MetaError( + code=ErrorCode.ERR_BAD_ATTR_VALUE, + message=f'{label} names {names}, which {verb} of "{obj.name}".', + envelope=v.source, + )) + continue + problem = _condition_problem(condition, obj) + if problem is not None: + errors.append(MetaError( + code=ErrorCode.ERR_BAD_ATTR_VALUE, + message=f"{label} {problem}", + envelope=v.source, + )) + + +# field.enum @maxLength — the column length of a string-stored enum: +# 1. every member of @values fits in it (ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH), the longest +# offender named; +# 2. it is refused beside @intValueMap (ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP). +# Read RESOLVING (ADR-0039), reported on the node that DECLARES one of the three, so an enum +# that only inherits them is not reported twice. Mirrors TS validateEnumMaxLength. +def _validate_enum_max_length(root: MetaData, errors: list[MetaError]) -> None: + for node in _walk(root): + if node.type != TYPE_FIELD or node.sub_type != FIELD_SUBTYPE_ENUM: + continue + declares = any( + node.attr(a) is not None + for a in (FIELD_ATTR_MAX_LENGTH, FIELD_ATTR_VALUES, FIELD_ATTR_INT_VALUE_MAP) + ) + max_length = node.get_meta_attr(FIELD_ATTR_MAX_LENGTH) # ADR-0039 resolving + if not declares or not isinstance(max_length, int) or isinstance(max_length, bool): + continue + owner = node.parent + label = f"{owner.name}.{node.name}" if owner is not None and owner.type == TYPE_OBJECT else node.name + if node.get_meta_attr(FIELD_ATTR_INT_VALUE_MAP) is not None: + errors.append(MetaError( + code=ErrorCode.ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP, + message=( + f'field.enum "{label}" sets @maxLength {max_length} and @intValueMap; an ' + f"@intValueMap enum is stored as an integer, so a column length does not " + f"apply. Remove @maxLength." + ), + envelope=node.source, + )) + continue + values = node.get_meta_attr(FIELD_ATTR_VALUES) + too_long = sorted( + (v for v in (values if isinstance(values, list) else []) if isinstance(v, str) and len(v) > max_length), + key=len, + reverse=True, + ) + if too_long: + errors.append(MetaError( + code=ErrorCode.ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH, + message=( + f'field.enum "{label}" @maxLength {max_length} is shorter than its member ' + f'"{too_long[0]}" ({len(too_long[0])} characters). Raise @maxLength to at ' + f"least {len(too_long[0])}, or shorten the member." + ), + envelope=node.source, + )) def _validate_field_object_storage(root: MetaData, errors: list[MetaError]) -> None: @@ -3737,9 +4029,33 @@ def _validate_field_object_storage(root: MetaData, errors: list[MetaError]) -> N )) continue storage = node.get_meta_attr(FIELD_ATTR_STORAGE) # ADR-0039 resolving + # @flattenedColumnPrefix names the columns a flattened value object adds to the + # owning table; under any other storage it would change nothing, so it is refused + # rather than silently ignored. + # Only a field an object owns is checked (as TS and C# do): a root-level abstract + # field.object is a template, and the concrete field that extends it — resolving + # both the prefix and @storage — is the one checked. + owner = node.parent + if ( + owner is not None + and owner.type == TYPE_OBJECT + and node.get_meta_attr(FIELD_ATTR_FLATTENED_COLUMN_PREFIX) is not None + and storage != STORAGE_FLATTENED + ): + storage_text = "not set (jsonb)" if storage is None else f'"{storage}"' + errors.append(MetaError( + code=ErrorCode.ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE, + message=( + f'field.object "{owner.name}.{node.name}" sets @flattenedColumnPrefix, but its @storage ' + f"is {storage_text}; only a @storage \"flattened\" value object puts its " + f"members' columns in the owning table. Set @storage \"flattened\", or " + f"remove @flattenedColumnPrefix." + ), + envelope=node.source, + )) if storage is None: continue - if storage == "flattened" and node.resolved_is_array(): # ADR-0039 resolving isArray + if storage == STORAGE_FLATTENED and node.resolved_is_array(): # ADR-0039 resolving isArray errors.append(MetaError( code=ErrorCode.ERR_STORAGE_FLATTENED_ARRAY, message=( @@ -4205,6 +4521,46 @@ def _has_explicit_column(field: MetaData) -> bool: return isinstance(col, str) and col != "" +class _PhysicalColumn(NamedTuple): + """One physical column a field contributes to its object's table.""" + + column: str + #: ``street``, or ``shipTo.street`` for a member of a flattened value object. + label: str + #: The field that names it: the field itself, or the flattened value object's member. + named: MetaData + #: Set for a flattened member: the embedding field.object. + embedding: MetaData | None = None + + +def _physical_columns_of(field: MetaData) -> list[_PhysicalColumn]: + from ..naming import resolve_column_name, resolve_flattened_column_name + # ADR-0039: resolving -- @storage and @objectRef may be inherited via extends. + if field.sub_type == FIELD_SUBTYPE_OBJECT and field.get_meta_attr(FIELD_ATTR_STORAGE) == STORAGE_FLATTENED: + target = bound_ref_target(field, FIELD_ATTR_OBJECT_REF) + if not isinstance(target, MetaObject): + return [] + # Every member, as migrate's flattenObjectField expands it. + return [ + _PhysicalColumn( + resolve_flattened_column_name(field, member), + f"{field.name}.{member.name}", + member, + field, + ) + for member in target.fields() + ] + return [_PhysicalColumn(resolve_column_name(field), field.name, field)] + + +def _collision_hint(a: _PhysicalColumn, b: _PhysicalColumn) -> str: + if a.embedding is not None or b.embedding is not None: + return "Give one flattened field a different @flattenedColumnPrefix, or rename one field." + if _has_explicit_column(a.named) and _has_explicit_column(b.named): + return "Rename one field, or point one @column at a different column." + return "Rename one field, or give one an explicit @column so the two columns differ." + + def _validate_column_name_collisions(root: MetaData, errors: list[MetaError]) -> None: """ERR_COLUMN_NAME_COLLISION: two fields of one stored object resolving to the SAME physical column (``userId`` + ``userID`` both snake_case to ``user_id``, or two @@ -4213,8 +4569,11 @@ def _validate_column_name_collisions(root: MetaData, errors: list[MetaError]) -> Only the RESOLVED name is compared, through :func:`resolve_column_name`, so an explicit ``@column``, an inherited field and the project's column strategy are honoured. Scope: a concrete object declaring at least one source (a source-less - object generates nothing); derived fields and ``field.object`` (may flatten to - several columns) are skipped. ADR-0039: effective children (inherited fields count). + object generates nothing); derived fields are skipped. A ``field.object @storage + "flattened"`` has no column of its own: each member of its value object is a column, + named by :func:`resolve_flattened_column_name` exactly as migrate creates it. Any + other ``field.object`` is one column under its own resolved name. + ADR-0039: effective children (inherited fields count). Exempt: an object whose every source is read-only and at least one is a ``source.rdb @kind: view`` with an ``@sql`` body. The author's SQL defines that view's @@ -4223,7 +4582,6 @@ def _validate_column_name_collisions(root: MetaData, errors: list[MetaError]) -> from its fields, and an object with a writable source has a real table whose columns MetaObjects creates; both stay checked. """ - from ..naming import resolve_column_name from ..meta.core.field.meta_field import MetaField for obj in (c for c in root.children() if c.type == TYPE_OBJECT): if obj.is_abstract: @@ -4236,29 +4594,23 @@ def _validate_column_name_collisions(root: MetaData, errors: list[MetaError]) -> for s in sources ): continue - seen: dict[str, MetaData] = {} + seen: dict[str, _PhysicalColumn] = {} for field in obj.children(): - if not isinstance(field, MetaField) or field.sub_type == FIELD_SUBTYPE_OBJECT \ - or field.is_derived(): + if not isinstance(field, MetaField) or field.is_derived(): continue - column = resolve_column_name(field) - first = seen.get(column) - if first is None: - seen[column] = field - continue - errors.append(MetaError( - code=ErrorCode.ERR_COLUMN_NAME_COLLISION, - message=( - f'fields "{first.name}" and "{field.name}" on "{obj.name}" both resolve ' - f'to physical column "{column}". ' - + ( - "Rename one field, or point one @column at a different column." - if _has_explicit_column(first) and _has_explicit_column(field) - else "Rename one field, or give one an explicit @column so the two columns differ." - ) - ), - envelope=field.source, - )) + for col in _physical_columns_of(field): + first = seen.get(col.column) + if first is None: + seen[col.column] = col + continue + errors.append(MetaError( + code=ErrorCode.ERR_COLUMN_NAME_COLLISION, + message=( + f'fields "{first.label}" and "{col.label}" on "{obj.name}" both resolve ' + f'to physical column "{col.column}". ' + _collision_hint(first, col) + ), + envelope=field.source, + )) def _validate_retired_requirement_links(root: MetaData, errors: list[MetaError]) -> None: diff --git a/server/python/src/metaobjects/meta/core/field/field_constants.py b/server/python/src/metaobjects/meta/core/field/field_constants.py index 98f1e6d337..6cf388650b 100644 --- a/server/python/src/metaobjects/meta/core/field/field_constants.py +++ b/server/python/src/metaobjects/meta/core/field/field_constants.py @@ -35,6 +35,14 @@ # build-time codegen concern. DB column is the Postgres-native inet type. Ships # WITHOUT @kind in v1 (the native type subsumes ipv4/ipv6). Mirrors TS FIELD_SUBTYPE_INET. FIELD_SUBTYPE_INET = "inet" +# field.duration: a fixed-length span of time (days, hours, minutes, seconds). A +# concept WITH a native type + behavior → a subtype (the field.inet precedent). +# String-backed on the wire: an ISO-8601 duration, written in the canonical form +# ``[-]PT[nH][nM][n[.f]S]`` (fixtures/duration-wire). The native binding is +# ``datetime.timedelta``; DB column is the Postgres-native interval; SQLite and MySQL +# store a 64-bit integer count of microseconds. +# Calendar spans (years, months) are refused; a week is exactly 7 days. Mirrors TS FIELD_SUBTYPE_DURATION. +FIELD_SUBTYPE_DURATION = "duration" FIELD_SUBTYPES = ( SUBTYPE_BASE, @@ -54,6 +62,7 @@ FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, + FIELD_SUBTYPE_DURATION, # Note: FIELD_SUBTYPE_ENUM is intentionally excluded here; it is registered # separately in core_types.py with its dedicated @values AttrSchema. ) @@ -129,9 +138,15 @@ NORMALIZE_MODES = (NORMALIZE_NONE, NORMALIZE_COLLAPSE, NORMALIZE_STRIP) # Persistence-side storage shape for owned field.object data. Cross-port values. FIELD_ATTR_STORAGE = "storage" -STORAGE_VALUES = ("flattened", "jsonb", "subdocument") +#: @storage "flattened": the nested object's columns expand into the parent table. +STORAGE_FLATTENED = "flattened" +STORAGE_VALUES = (STORAGE_FLATTENED, "jsonb", "subdocument") # Physical column name override (cross-port; renamed from @dbColumn). FIELD_ATTR_COLUMN = "column" +# @flattenedColumnPrefix on a field.object @storage "flattened": the text before each member's column, +# verbatim ("" allowed). Absent: the field's resolved column and "_" +# (metaobjects.naming.resolve_flattened_column_prefix). +FIELD_ATTR_FLATTENED_COLUMN_PREFIX = "flattenedColumnPrefix" # ADR-0036/0037 Wave 3 — @stringFormat: a closed validation format for a plain # field.string that has NO native type or behavior of its own (email | hostname). diff --git a/server/python/src/metaobjects/meta/core/field/meta_field.py b/server/python/src/metaobjects/meta/core/field/meta_field.py index 8f210c23e7..dd8ab1ed37 100644 --- a/server/python/src/metaobjects/meta/core/field/meta_field.py +++ b/server/python/src/metaobjects/meta/core/field/meta_field.py @@ -31,6 +31,9 @@ # are surfaced at codegen time (see codegen/type_map.py). fc.FIELD_SUBTYPE_URI: DataType.STRING, fc.FIELD_SUBTYPE_INET: DataType.STRING, + # field.duration is string-backed on the wire (an ISO-8601 duration); the native + # datetime.timedelta binding is surfaced at codegen time and by the runtime codec. + fc.FIELD_SUBTYPE_DURATION: DataType.STRING, } 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 5c16eae8b2..522f802ea3 100644 --- a/server/python/src/metaobjects/meta/core/validator/validator_constants.py +++ b/server/python/src/metaobjects/meta/core/validator/validator_constants.py @@ -16,6 +16,7 @@ VALIDATOR_SUBTYPE_REQUIRED_WHEN = "requiredWhen" VALIDATOR_SUBTYPE_PRESENT_IFF = "presentIff" VALIDATOR_SUBTYPE_AT_LEAST_ONE = "atLeastOne" +VALIDATOR_SUBTYPE_EXACTLY_ONE = "exactlyOne" # Validator attr keys (read by codegen when lowering validator children). VALIDATOR_ATTR_MIN = "min" @@ -29,3 +30,15 @@ VALIDATOR_ATTR_WHEN = "when" VALIDATOR_ATTR_EQUALS = "equals" VALIDATOR_ATTR_FIELDS = "fields" +# A cross-field validator's @condition: the rule applies only where this expression holds. +VALIDATOR_ATTR_CONDITION = "condition" +# The database name of the CHECK a CHECK-generating validator generates (db provider). +VALIDATOR_ATTR_CONSTRAINT_NAME = "constraintName" +# The cross-field validators (entity-scoped; the database enforces them as CHECKs). +CROSS_FIELD_VALIDATOR_SUBTYPES = ( + VALIDATOR_SUBTYPE_COMPARISON, + VALIDATOR_SUBTYPE_REQUIRED_WHEN, + VALIDATOR_SUBTYPE_PRESENT_IFF, + VALIDATOR_SUBTYPE_AT_LEAST_ONE, + VALIDATOR_SUBTYPE_EXACTLY_ONE, +) diff --git a/server/python/src/metaobjects/meta/persistence/db/db_constants.py b/server/python/src/metaobjects/meta/persistence/db/db_constants.py index 968978fb64..6fc5ee76f2 100644 --- a/server/python/src/metaobjects/meta/persistence/db/db_constants.py +++ b/server/python/src/metaobjects/meta/persistence/db/db_constants.py @@ -76,6 +76,10 @@ IDENTITY_SECONDARY_ATTR_USING = "using" # identity.reference: physical FK constraint-name override. IDENTITY_REFERENCE_ATTR_CONSTRAINT_NAME = "constraintName" +#: identity.primary (@generation "increment"): the sequence's first value. Absent → 1. +IDENTITY_PRIMARY_ATTR_SEQUENCE_START = "sequenceStart" +#: identity.primary (@generation "increment"): the sequence's physical name. Absent → the database's default. +IDENTITY_PRIMARY_ATTR_SEQUENCE_NAME = "sequenceName" # identity.reference: referential action on parent delete — the explicit per-FK override (ADR-0047). IDENTITY_REFERENCE_ATTR_ON_DELETE = "onDelete" # identity.reference: referential action on key update — the explicit per-FK override (ADR-0047). diff --git a/server/python/src/metaobjects/meta/persistence/db/db_provider.py b/server/python/src/metaobjects/meta/persistence/db/db_provider.py index a6b8aaad26..25a3a4615c 100644 --- a/server/python/src/metaobjects/meta/persistence/db/db_provider.py +++ b/server/python/src/metaobjects/meta/persistence/db/db_provider.py @@ -16,19 +16,23 @@ """ from __future__ import annotations -from ...core.attr.attr_constants import ATTR_SUBTYPE_BOOLEAN, ATTR_SUBTYPE_STRING +from ...core.attr.attr_constants import ATTR_SUBTYPE_BOOLEAN, ATTR_SUBTYPE_LONG, ATTR_SUBTYPE_STRING from ...core.field import field_constants as fc from ...core.identity.identity_constants import ( + IDENTITY_SUBTYPE_PRIMARY, IDENTITY_SUBTYPE_REFERENCE, IDENTITY_SUBTYPE_SECONDARY, ) from ...core.index.index_constants import INDEX_SUBTYPE_LOOKUP +from ...core.validator import validator_constants as vc from ....provider import Provider from ....registry import AttrSchema, TypeRegistry -from ....shared.base_types import TYPE_FIELD, TYPE_IDENTITY, TYPE_INDEX +from ....shared.base_types import TYPE_FIELD, TYPE_IDENTITY, TYPE_INDEX, TYPE_VALIDATOR from .db_constants import ( FIELD_ATTR_DB_COLUMN_TYPE, FIELD_ATTR_LOCAL_TIME, + IDENTITY_PRIMARY_ATTR_SEQUENCE_NAME, + IDENTITY_PRIMARY_ATTR_SEQUENCE_START, IDENTITY_REFERENCE_ATTR_CONSTRAINT_NAME, IDENTITY_REFERENCE_ATTR_ON_DELETE, IDENTITY_REFERENCE_ATTR_ON_UPDATE, @@ -74,6 +78,15 @@ required=False, ) +# @flattenedColumnPrefix — the flattened-member column prefix (string, "" allowed). +# Registered on field.object only; the description is sourced from the embedded +# spec/metamodel/db.json by apply_spec_descriptions (single-source). +_FLATTENED_COLUMN_PREFIX_SCHEMA = AttrSchema( + name=fc.FIELD_ATTR_FLATTENED_COLUMN_PREFIX, + value_type=ATTR_SUBTYPE_STRING, + required=False, +) + # DB-domain physical attrs EXTENDING identity subtypes — RDB index / FK-constraint # concerns, NOT core identity. Mirrors spec/metamodel/db.json's identity extends and # the TS/Java/C# db providers. Descriptions are byte-identical to the canonical. @@ -121,6 +134,18 @@ "expression. RDB-physical — contributed by the db provider." ), ) +_SEQUENCE_START_SCHEMA = AttrSchema( + name=IDENTITY_PRIMARY_ATTR_SEQUENCE_START, + value_type=ATTR_SUBTYPE_LONG, + required=False, + description='The first value an @generation "increment" key\'s sequence hands out (Postgres: GENERATED BY DEFAULT AS IDENTITY (START WITH n)). Absent: 1. Lets a model reproduce a table that keeps a low id range for seeded rows. Refused without @generation "increment" (ERR_SEQUENCE_WITHOUT_INCREMENT). RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1.', +) +_SEQUENCE_NAME_SCHEMA = AttrSchema( + name=IDENTITY_PRIMARY_ATTR_SEQUENCE_NAME, + value_type=ATTR_SUBTYPE_STRING, + required=False, + description='The physical name of an @generation "increment" key\'s sequence (Postgres: GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME ...)). Absent: the database\'s own default,

__seq on Postgres. Lets a model adopt a sequence that kept its name across a table rename. Refused without @generation "increment" (ERR_SEQUENCE_WITHOUT_INCREMENT). RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1.', +) _CONSTRAINT_NAME_SCHEMA = AttrSchema( name=IDENTITY_REFERENCE_ATTR_CONSTRAINT_NAME, value_type=ATTR_SUBTYPE_STRING, @@ -162,6 +187,38 @@ ) +_ENUM_CONSTRAINT_NAME_SCHEMA = AttrSchema( + name=vc.VALIDATOR_ATTR_CONSTRAINT_NAME, + value_type=ATTR_SUBTYPE_STRING, + required=False, + description=( + "The database name of the CHECK constraint this enum's member list generates. " + "Absent:
__chk. Lets a model adopt a database whose CHECKs were " + "named by hand. RDB-physical — contributed by the db provider. Justified in " + "ADR-0023 Amendment 1." + ), +) +_VALIDATOR_CONSTRAINT_NAME_SCHEMA = AttrSchema( + name=vc.VALIDATOR_ATTR_CONSTRAINT_NAME, + value_type=ATTR_SUBTYPE_STRING, + required=False, + description=( + "The database name of the CHECK constraint this validator generates (numeric, length, " + "regex and the cross-field validators; the others generate no CHECK). Absent: the " + "derived
___chk. Lets a model adopt a database whose CHECKs " + "were named by hand. RDB-physical — contributed by the db provider. Justified in " + "ADR-0023 Amendment 1." + ), +) +# The validators meta migrate renders as a CHECK: required is NOT NULL; array and base have none. +_VALIDATOR_SUBTYPES_FOR_CONSTRAINT_NAME = ( + vc.VALIDATOR_SUBTYPE_NUMERIC, + vc.VALIDATOR_SUBTYPE_LENGTH, + vc.VALIDATOR_SUBTYPE_REGEX, + *vc.CROSS_FIELD_VALIDATOR_SUBTYPES, +) + + def _register(registry: TypeRegistry) -> None: for sub_type in _DB_FIELD_SUBTYPES: registry.extend( @@ -176,11 +233,22 @@ def _register(registry: TypeRegistry) -> None: fc.FIELD_SUBTYPE_TIMESTAMP, attributes=[_LOCAL_TIME_SCHEMA], ) + # @flattenedColumnPrefix is field.object-only (mirrors db.json's field.object extends). + registry.extend( + TYPE_FIELD, + fc.FIELD_SUBTYPE_OBJECT, + attributes=[_FLATTENED_COLUMN_PREFIX_SCHEMA], + ) registry.extend( TYPE_IDENTITY, IDENTITY_SUBTYPE_SECONDARY, attributes=[_ORDERS_SCHEMA, _WHERE_SCHEMA, _EXPR_SCHEMA, _USING_SCHEMA], ) + registry.extend( + TYPE_IDENTITY, + IDENTITY_SUBTYPE_PRIMARY, + attributes=[_SEQUENCE_START_SCHEMA, _SEQUENCE_NAME_SCHEMA], + ) registry.extend( TYPE_IDENTITY, IDENTITY_SUBTYPE_REFERENCE, @@ -190,6 +258,10 @@ def _register(registry: TypeRegistry) -> None: _REFERENCE_ON_UPDATE_SCHEMA, ], ) + # @constraintName names the CHECK a validator / a field.enum's membership generates. + registry.extend(TYPE_FIELD, fc.FIELD_SUBTYPE_ENUM, attributes=[_ENUM_CONSTRAINT_NAME_SCHEMA]) + for sub_type in _VALIDATOR_SUBTYPES_FOR_CONSTRAINT_NAME: + registry.extend(TYPE_VALIDATOR, sub_type, attributes=[_VALIDATOR_CONSTRAINT_NAME_SCHEMA]) # index.lookup: same RDB physical attrs as identity.secondary (@orders/@where/@expr/@using). registry.extend( TYPE_INDEX, diff --git a/server/python/src/metaobjects/naming.py b/server/python/src/metaobjects/naming.py index 3ff359909b..04a6aadecf 100644 --- a/server/python/src/metaobjects/naming.py +++ b/server/python/src/metaobjects/naming.py @@ -37,18 +37,23 @@ COLUMN_NAMING_SNAKE_CASE = "snake_case" COLUMN_NAMING_LITERAL = "literal" COLUMN_NAMING_KEBAB_CASE = "kebab-case" +#: The first letter upper-cased, nothing else changed (``requestURL`` → ``RequestURL``), so +#: a camelCase model maps with no ``@column`` onto a schema whose columns are spelled that +#: way. Not a word split. +COLUMN_NAMING_UPPER_FIRST = "upperFirst" COLUMN_NAMING_STRATEGIES = ( COLUMN_NAMING_SNAKE_CASE, COLUMN_NAMING_LITERAL, COLUMN_NAMING_KEBAB_CASE, + COLUMN_NAMING_UPPER_FIRST, ) #: ADR-0058 — the project default, in every port: what ``meta migrate`` has always #: created. FLIPPED in 1.1.0 (was ``literal`` in this port only). DEFAULT_COLUMN_NAMING = COLUMN_NAMING_SNAKE_CASE -#: ADR-0058 — ``tables`` accepts only these two: no ``kebab-case`` table rule. +#: ADR-0058 — ``tables`` accepts only these two: no ``kebab-case`` or ``upperFirst`` table rule. TABLE_NAMING_SNAKE_CASE = "snake_case" TABLE_NAMING_LITERAL = "literal" @@ -132,6 +137,21 @@ def to_snake_case(name: str) -> str: return "".join(out) +def to_upper_first_column(name: str) -> str: + """The ``upperFirst`` column rule: the first letter upper-cased, the rest verbatim. + + One UTF-16 unit in, one out, as C#'s ``char.ToUpperInvariant`` and Java's + ``Character.toUpperCase`` do: a first character whose upper case is not one character + (``ß`` → ``SS``), or that is outside the Basic Multilingual Plane, is kept as written, so + every port names it alike. + """ + first = name[:1] + upper = first.upper() + if len(upper) != 1 or ord(first) > 0xFFFF: + upper = first + return upper + name[1:] + + def apply_column_naming_strategy(name: str, strategy: str = DEFAULT_COLUMN_NAMING) -> str: """Apply a strategy to a bare name. @@ -144,6 +164,8 @@ def apply_column_naming_strategy(name: str, strategy: str = DEFAULT_COLUMN_NAMIN return to_snake_case(name) if strategy == COLUMN_NAMING_KEBAB_CASE: return to_snake_case(name).replace("_", "-") + if strategy == COLUMN_NAMING_UPPER_FIRST: + return to_upper_first_column(name) raise ValueError( f"unknown column-naming strategy {strategy!r}; " f"expected one of: {', '.join(COLUMN_NAMING_STRATEGIES)}" @@ -169,6 +191,33 @@ def resolve_column_name(field: MetaField, strategy: str | None = None) -> str: return apply_column_naming_strategy(field.name, effective) +def resolve_flattened_column_prefix(field: MetaField, strategy: str | None = None) -> str: + """The text before each member's column when *field* (a ``field.object @storage + "flattened"``) expands its value object into the owning table: its + ``@flattenedColumnPrefix`` verbatim (``""`` allowed), else its own resolved column and + ``_``. Core naming, not one generator's: the migration creates these columns and the + generated ORM mappings address them, so every port spells them the same way. + + ADR-0039: read RESOLVING — the prefix and ``@column`` may be inherited through + ``extends``. ``strategy`` as for :func:`resolve_column_name`. + """ + prefix = field.get_meta_attr(fc.FIELD_ATTR_FLATTENED_COLUMN_PREFIX) + if isinstance(prefix, str): + return prefix + return resolve_column_name(field, strategy) + "_" + + +def resolve_flattened_column_name( + field: MetaField, member: MetaField, strategy: str | None = None +) -> str: + """The column a member of a flattened value object gets in the owning table: the + embedding field's :func:`resolve_flattened_column_prefix` followed by the member's own + resolved column. The member's rule is the embedding field's, so one project setting + names both halves.""" + effective = strategy if strategy is not None else naming_rules_of(field).columns + return resolve_flattened_column_prefix(field, effective) + resolve_column_name(member, effective) + + def strip_package(name: str) -> str: """The bare, package-less segment of a metadata name — ``acme::demo::by_name`` → ``by_name``. diff --git a/server/python/src/metaobjects/registry_manifest.py b/server/python/src/metaobjects/registry_manifest.py index 0340fb466b..9e99593953 100644 --- a/server/python/src/metaobjects/registry_manifest.py +++ b/server/python/src/metaobjects/registry_manifest.py @@ -255,7 +255,7 @@ def _sorted_children(definition: TypeDefinition) -> list[dict[str, object]]: return children -METAMODEL_VERSION = "1.2" +METAMODEL_VERSION = "1.3" """Rolled-up spec-version for the cross-port registry manifest. ``"0"`` = pre-1.0 / unstable (semver major-0). Flips to ``"1.0"`` at the diff --git a/server/python/src/metaobjects/spec_metamodel/db.json b/server/python/src/metaobjects/spec_metamodel/db.json index 44c1048d73..4e087e72f0 100644 --- a/server/python/src/metaobjects/spec_metamodel/db.json +++ b/server/python/src/metaobjects/spec_metamodel/db.json @@ -21,6 +21,7 @@ "type": "field", "subType": "object", "children": [ + { "type": "attr", "subType": "string", "name": "flattenedColumnPrefix", "min": 0, "max": 1, "description": "On a field.object with @storage \"flattened\": the text put before each member's column, verbatim (\"\" allowed, so the members keep their own column names). Absent: this field's resolved column name and \"_\" (with @column \"bill\", the members are bill_). A fact about the owning table, so it lives on the embedding field, never on the value object. Refused on any other storage (ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE). Justified in ADR-0023 Amendment 1." }, { "type": "attr", "subType": "string", "name": "storage", "min": 0, "max": 1, "allowedValues": ["flattened", "jsonb", "subdocument"], "description": "Storage strategy for an object-typed field (set with @objectRef). \"flattened\" expands the nested value into prefixed columns on the parent table. \"jsonb\" stores the structured value in a single jsonb column (supports isArray=true for arrays of values). \"subdocument\" is a hint for document-store codegen targets and emits no Postgres column." } ] }, @@ -58,6 +59,28 @@ { "type": "attr", "subType": "string", "name": "using", "min": 0, "max": 1, "description": "Index access method (e.g. \"gin\", \"gist\", \"hash\"); default \"btree\" (not rendered). Pair with @expr for e.g. a GIN index over an array/jsonb expression. RDB-physical — contributed by the db provider." } ] }, + { + "type": "field", + "subType": "enum", + "children": [ + { "type": "attr", "subType": "string", "name": "constraintName", "min": 0, "max": 1, "description": "The database name of the CHECK constraint this enum's member list generates. Absent:
__chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." } + ] + }, + { + "type": "validator", + "subType": ["numeric", "length", "regex", "comparison", "requiredWhen", "presentIff", "atLeastOne", "exactlyOne"], + "children": [ + { "type": "attr", "subType": "string", "name": "constraintName", "min": 0, "max": 1, "description": "The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." } + ] + }, + { + "type": "identity", + "subType": "primary", + "children": [ + { "type": "attr", "subType": "long", "name": "sequenceStart", "min": 0, "max": 1, "description": "The first value an @generation \"increment\" key's sequence hands out (Postgres: GENERATED BY DEFAULT AS IDENTITY (START WITH n)). Absent: 1. Lets a model reproduce a table that keeps a low id range for seeded rows. Refused without @generation \"increment\" (ERR_SEQUENCE_WITHOUT_INCREMENT). RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." }, + { "type": "attr", "subType": "string", "name": "sequenceName", "min": 0, "max": 1, "description": "The physical name of an @generation \"increment\" key's sequence (Postgres: GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME ...)). Absent: the database's own default,
__seq on Postgres. Lets a model adopt a sequence that kept its name across a table rename. Refused without @generation \"increment\" (ERR_SEQUENCE_WITHOUT_INCREMENT). RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." } + ] + }, { "type": "identity", "subType": "reference", diff --git a/server/python/src/metaobjects/spec_metamodel/field.json b/server/python/src/metaobjects/spec_metamodel/field.json index b054bda765..3b4a4ce9fc 100644 --- a/server/python/src/metaobjects/spec_metamodel/field.json +++ b/server/python/src/metaobjects/spec_metamodel/field.json @@ -151,6 +151,7 @@ "children": [ { "type": "attr", "subType": "string", "name": "values", "isArray": true, "min": 1, "max": 1, "description": "Member symbols of an enum-subtype field. Declaration order is significant; each is a legal identifier and its own stored string." }, { "type": "attr", "subType": "boolean", "name": "provided", "min": 0, "max": 1, "description": "FR-019: marks an abstract package-level field.enum as externally provided — codegen references the type (resolved via per-port codegen config) instead of materializing it. Default false. Not a field attr — it lives on the type declaration." }, + { "type": "attr", "subType": "int", "name": "maxLength", "min": 0, "max": 1, "description": "Column length of a string-stored enum (drives VARCHAR(n)); every member must fit. Overrides the project's enums.maxLength. Refused with @intValueMap, whose column is an integer. Justified in ADR-0023 Amendment 1." }, { "type": "attr", "subType": "intMap", "name": "intValueMap", "min": 0, "max": 1, "description": "Optional per-member int values ({member: int}) switching this enum field's DB persistence from string+CHECK to integer+CHECK. Keys must exactly match @values; values must be unique integers. The generated native type and wire format are unchanged in every language." } ] }, @@ -183,6 +184,14 @@ "children": [ { "type": "attr", "subType": "boolean", "name": "lenient", "min": 0, "max": 1, "description": "#234: opt this field out of strict well-formedness enforcement. When true, codegen binds a plain string (no URL/IP validator, no native URI/InetAddress type; field.inet uses a text column, not the native inet type) so a not-strictly-valid value round-trips unchanged. Default (absent/false) is strict: an absolute-scheme URI / an IPv4-or-IPv6 literal." } ] + }, + { + "type": "field", + "subType": "duration", + "dataType": "string", + "extendsBase": true, + "description": "A fixed-length span of time: days, hours, minutes and seconds (ADR-0036/0037 — a concept with a native type + behavior). Binds to TS string (TS has no native duration type); other ports bind to their native type (C# System.TimeSpan, Java and Kotlin java.time.Duration, Python datetime.timedelta). DB column is the Postgres-native interval type; SQLite, D1 and MySQL store a 64-bit integer count of microseconds, which orders and compares as the span does. The wire form is an ISO-8601 duration: every port writes [-]PT[nH][nM][n[.f]S] (hours never folded into days, as many fractional digits as its native type holds, zero is PT0S) and reads that form plus leading week and day counts (nW, exactly 7 days; nD, exactly 24 hours) and a fraction of any length, truncated toward zero. Years and months are refused: a calendar span is not a fixed length. Justified in ADR-0023 Amendment 1.", + "whenToUse": "A field holds an elapsed time, a timeout or a delay. Use it for a native Postgres interval column and a native duration type instead of a string or a number of seconds. A calendar span (\"every 3 months\") is not a duration: model it as an int and a unit." } ] } diff --git a/server/python/src/metaobjects/spec_metamodel/validator.json b/server/python/src/metaobjects/spec_metamodel/validator.json index 9520605bd7..4aba88dbd3 100644 --- a/server/python/src/metaobjects/spec_metamodel/validator.json +++ b/server/python/src/metaobjects/spec_metamodel/validator.json @@ -66,7 +66,8 @@ "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": "attr", "subType": "string", "name": "right", "min": 1, "max": 1, "description": "Name of the right-hand field of the owning entity." }, + { "type": "attr", "subType": "expression", "name": "condition", "min": 0, "max": 1, "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." } ] }, { @@ -78,7 +79,8 @@ "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": "attr", "subType": "string", "name": "equals", "min": 1, "max": 1, "description": "The gating value; when @when equals this, @field must be present." }, + { "type": "attr", "subType": "expression", "name": "condition", "min": 0, "max": 1, "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." } ] }, { @@ -90,7 +92,8 @@ "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": "attr", "subType": "string", "name": "equals", "min": 1, "max": 1, "description": "The gating value; @field is present exactly when @when equals this." }, + { "type": "attr", "subType": "expression", "name": "condition", "min": 0, "max": 1, "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." } ] }, { @@ -100,7 +103,19 @@ "whenToUse": "At least one of several fields must be present (email or phone). Declare it instead of hand-written OR-presence checks.", "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." } + { "type": "attr", "subType": "string", "name": "fields", "isArray": true, "min": 1, "max": 1, "description": "Names of the candidate fields; at least one must be present." }, + { "type": "attr", "subType": "expression", "name": "condition", "min": 0, "max": 1, "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." } + ] + }, + { + "type": "validator", + "subType": "exactlyOne", + "description": "Cardinality of presence: exactly one of the named fields (@fields) is present (NOT NULL), and the others are null. Entity-scoped; references fields by name (same @fields-by-name pattern as identity.*). Justified in ADR-0023 Amendment 1.", + "whenToUse": "A row belongs to exactly one of several alternatives (a store or a kiosk, never both, never neither). Declare it instead of hand-written count-of-non-nulls checks.", + "rules": "@fields names two or more fields of the owning entity (ERR_BAD_ATTR_VALUE): one field would just be a required field. Satisfied when exactly one of them is non-null.", + "children": [ + { "type": "attr", "subType": "string", "name": "fields", "isArray": true, "min": 1, "max": 1, "description": "Names of the alternative fields; exactly one must be present." }, + { "type": "attr", "subType": "expression", "name": "condition", "min": 0, "max": 1, "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." } ] } ] diff --git a/server/python/tests/config/test_neutral_config.py b/server/python/tests/config/test_neutral_config.py index 474b7cab82..a1aa8447bf 100644 --- a/server/python/tests/config/test_neutral_config.py +++ b/server/python/tests/config/test_neutral_config.py @@ -177,3 +177,23 @@ def test_unknown_library_token_raises_loudly(tmp_path: Path) -> None: read_neutral_config(tmp_path) assert e.value.code == ErrorCode.ERR_UNKNOWN_LIBRARY assert "ai-trace" in str(e.value) + + +@pytest.mark.parametrize("enums", [{}, {"maxLength": 32}, {"maxLength": 32.0}, {"maxLength": 1e2}]) +def test_an_enums_block_is_accepted_and_ignored(tmp_path: Path, enums: object) -> None: + """A project shared with a port that sizes a string-stored enum's column has an `enums` + block. This port sizes no column, so it reads nothing from the block, but accepts it.""" + _write_config(tmp_path, {"schema_version": 1, "sources": [], "enums": enums}) + assert read_neutral_config(tmp_path) is not None + + +@pytest.mark.parametrize( + "enums", + [[], "32", {"maxLen": 32}, {"maxLength": 0}, {"maxLength": -1}, {"maxLength": 1.5}, {"maxLength": True}], + ids=repr, +) +def test_a_malformed_enums_block_is_refused(tmp_path: Path, enums: object) -> None: + _write_config(tmp_path, {"schema_version": 1, "sources": [], "enums": enums}) + with pytest.raises(ParseError) as e: + read_neutral_config(tmp_path) + assert e.value.code == ErrorCode.ERR_BAD_ATTR_VALUE diff --git a/server/python/tests/conformance/test_naming_conformance.py b/server/python/tests/conformance/test_naming_conformance.py index 053bb9f038..19f59dd3b1 100644 --- a/server/python/tests/conformance/test_naming_conformance.py +++ b/server/python/tests/conformance/test_naming_conformance.py @@ -18,7 +18,9 @@ from metaobjects.config.neutral_config import read_neutral_config from metaobjects.errors import ParseError from metaobjects.meta.core.object.meta_object import MetaObject -from metaobjects.naming import resolve_column_name +from metaobjects.meta.core.field.field_constants import FIELD_ATTR_OBJECT_REF +from metaobjects.naming import resolve_column_name, resolve_flattened_column_name +from metaobjects.naming_refs import ref_target from metaobjects.shared.base_types import TYPE_OBJECT from metaobjects.source_resolution import primary_rdb_source @@ -79,12 +81,19 @@ def test_physical_names_case(case: dict[str, object], tmp_path: Path) -> None: ) for key, expected_column in case["columns"].items(): # type: ignore[union-attr] - obj_name, field_name = key.split(".", 1) + obj_name, field_name, *member_name = key.split(".", 2) obj = objects[obj_name] field = next(f for f in obj.fields() if f.name == field_name) - assert resolve_column_name(field) == expected_column, ( - f"case {case['name']!r}: {key} column" - ) + if not member_name: + actual = resolve_column_name(field) + else: + # `..`: a member of the flattened value object the field + # embeds, named by core naming. + target = ref_target(field, FIELD_ATTR_OBJECT_REF) + assert isinstance(target, MetaObject), f"{key}: @objectRef does not resolve" + member = next(f for f in target.fields() if f.name == member_name[0]) + actual = resolve_flattened_column_name(field, member) + assert actual == expected_column, f"case {case['name']!r}: {key} column" @pytest.mark.parametrize("invalid", _INVALID, ids=lambda c: c["name"]) diff --git a/server/python/tests/conformance/test_wire_conformance.py b/server/python/tests/conformance/test_wire_conformance.py index 6ef0def705..2d247cbd09 100644 --- a/server/python/tests/conformance/test_wire_conformance.py +++ b/server/python/tests/conformance/test_wire_conformance.py @@ -1,7 +1,7 @@ """ADR-0019 Amendment 2 cross-port conformance: ``fixtures/wire-conformance/config-blocks.json``. Every port parses each case's ``wire`` block with its OWN config reader -(``metaobjects.config.neutral_config``) and must resolve ``decimals`` and ``nullMembers`` to +(``metaobjects.config.neutral_config``) and must resolve ``decimals``, ``nullMembers`` and ``durations`` to the recorded values; every ``invalid`` block must be refused when the config is read. """ from __future__ import annotations @@ -37,6 +37,7 @@ def test_wire_block_resolves(case: dict[str, object], tmp_path: Path) -> None: assert cfg is not None assert cfg.wire.decimals == case["decimals"] assert cfg.wire.null_members == case["nullMembers"] + assert cfg.wire.durations == case["durations"] @pytest.mark.parametrize("case", _INVALID, ids=lambda c: c["name"]) @@ -51,4 +52,4 @@ def test_the_corpora_pin_the_portable_form() -> None: for corpus in ("api-contract-conformance", "persistence-conformance"): cfg = read_neutral_config(REPO / "fixtures" / corpus) assert cfg is not None - assert (cfg.wire.decimals, cfg.wire.null_members) == ("string", "omit"), corpus + assert (cfg.wire.decimals, cfg.wire.null_members, cfg.wire.durations) == ("string", "omit", "iso"), corpus diff --git a/server/python/tests/unit/test_column_naming_strategy.py b/server/python/tests/unit/test_column_naming_strategy.py index 63bb83dafd..94c69acb4b 100644 --- a/server/python/tests/unit/test_column_naming_strategy.py +++ b/server/python/tests/unit/test_column_naming_strategy.py @@ -78,7 +78,7 @@ def test_an_unknown_strategy_is_refused_not_silently_defaulted() -> None: # A typo'd strategy would otherwise bind an entire schema to the wrong columns and # report success. with pytest.raises(ValueError, match="snake_case"): - apply_column_naming_strategy("createdAt", "PascalCase") + apply_column_naming_strategy("createdAt", "camelCase") # WHERE THE STRATEGY ACTUALLY REACHES, in this port. @@ -122,3 +122,12 @@ def test_object_manager_column_naming_argument_is_refused() -> None: "ObjectManager(column_naming=...). See " "https://github.com/metaobjectsdev/metaobjects/blob/v1.1.0/docs/features/migrations/physical-naming.md" ) + + +def test_upper_first_keeps_a_first_character_whose_upper_case_is_not_one_character() -> None: + # C# and Java map one UTF-16 unit to one; Python's str.upper gives "SS" for "ß". + from metaobjects.naming import to_upper_first_column + + assert to_upper_first_column("requestURL") == "RequestURL" + assert to_upper_first_column("ßeta") == "ßeta" + assert to_upper_first_column("") == "" diff --git a/server/typescript/packages/integration-tests/src/load-metadata.ts b/server/typescript/packages/integration-tests/src/load-metadata.ts index cb4d6c4ecb..488602d90b 100644 --- a/server/typescript/packages/integration-tests/src/load-metadata.ts +++ b/server/typescript/packages/integration-tests/src/load-metadata.ts @@ -6,10 +6,10 @@ import { existsSync, readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { pathToFileURL } from "node:url"; import { DEFAULT_WIRE_FORMAT, loadDirectory, loadUris, type MetaRoot, type WireFormat } from "@metaobjectsdev/metadata"; -import { ConfigSchema, physicalNamingFromConfig, wireFormatFromConfig, type Config } from "@metaobjectsdev/sdk"; +import { ConfigSchema, enumDefaultsFromConfig, physicalNamingFromConfig, wireFormatFromConfig, type Config } from "@metaobjectsdev/sdk"; /** - * ADR-0058 — set `root`'s physical naming and wire format from the nearest `.metaobjects/config.json` at or + * ADR-0058 — set `root`'s physical naming, wire format and enum defaults from the nearest `.metaobjects/config.json` at or * above `startDir`, as a project-aware load does. A corpus that pins a committed schema * declares the naming that schema uses there, and every port's runner reads that one file. * No file up to the repository root leaves the default. @@ -21,6 +21,8 @@ export function applyNearestNaming(root: MetaRoot, startDir: string): MetaRoot { // ADR-0019 Amendment 2: the corpus pins its wire form in the same file, so generated // routes are emitted with it and no lane carries a literal. root.wireFormat = wireFormatFromConfig(config); + // `enums.maxLength` sizes an enum column, so a corpus that sets it gets the schema it pins. + root.enumDefaults = enumDefaultsFromConfig(config); return root; } diff --git a/server/typescript/packages/integration-tests/test/load-metadata-config.test.ts b/server/typescript/packages/integration-tests/test/load-metadata-config.test.ts new file mode 100644 index 0000000000..3e9b9f6c8c --- /dev/null +++ b/server/typescript/packages/integration-tests/test/load-metadata-config.test.ts @@ -0,0 +1,30 @@ +// A corpus's `.metaobjects/config.json` reaches the loaded root the way a project load does: +// naming, wire format and enum defaults, so the schema a lane builds is the one the corpus pins. +import { test, expect, afterAll } from "bun:test"; +import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveEnumMaxLength } from "@metaobjectsdev/metadata"; +import { loadMetadataDir } from "../src/load-metadata.ts"; + +const dirs: string[] = []; +afterAll(async () => { for (const d of dirs) await rm(d, { recursive: true, force: true }); }); + +test("enums.maxLength from the corpus config sizes an enum without its own @maxLength", async () => { + const dir = await mkdtemp(join(tmpdir(), "it-load-config-")); + dirs.push(dir); + await mkdir(join(dir, ".metaobjects")); + await writeFile(join(dir, ".metaobjects", "config.json"), JSON.stringify({ schema_version: 1, enums: { maxLength: 40 } })); + await mkdir(join(dir, "meta")); + await writeFile(join(dir, "meta", "meta.json"), JSON.stringify({ "metadata.root": { package: "p", children: [ + { "object.entity": { name: "Ticket", children: [ + { "source.rdb": { "@table": "tickets" } }, + { "field.long": { name: "id" } }, + { "field.enum": { name: "state", "@values": ["OPEN", "CLOSED"] } }, + { "identity.primary": { name: "pk", "@fields": ["id"] } }, + ] } }, + ] } })); + const root = await loadMetadataDir(join(dir, "meta")); + const state = root.findObject("Ticket")!.fields().find((f) => f.name === "state")!; + expect(resolveEnumMaxLength(state)).toBe(40); +}); diff --git a/server/typescript/packages/metadata/src/core/field/duration-wire.ts b/server/typescript/packages/metadata/src/core/field/duration-wire.ts new file mode 100644 index 0000000000..3970d7d085 --- /dev/null +++ b/server/typescript/packages/metadata/src/core/field/duration-wire.ts @@ -0,0 +1,182 @@ +// The field.duration wire rule: a fixed-length span of time on the wire is an ISO-8601 +// duration, and every port answers the same canonical form for it. The rule and its +// cross-port vectors are in fixtures/duration-wire/. Shared by runtime-ts (reads, writes, +// filters, validation) and migrate-ts (a declared @default); generated code carries its own +// copy, held to the same vectors. +// +// Canonical: `[-]PT[H][M][[.]S]` — hours never folded into days, zero components +// left out, zero is `PT0S`, one leading `-` for a negative span. TypeScript holds a value as a +// bigint count of microseconds, so a span of any length is exact and a longer fraction is +// truncated toward zero to six digits (the `precision` vectors' "6" entries). + +/** The accepted ISO-8601 duration form: an optional sign, `P`, optional weeks (7 days) and days, + * then an optional `T` with hours, minutes and seconds in that order; the seconds may have a + * fraction of any length. At least one component; no years or months (a calendar span has no + * fixed length). */ +export const DURATION_PATTERN = "^[-+]?P(?!$)(\\d+W)?(\\d+D)?(T(?=\\d)(\\d+H)?(\\d+M)?(\\d+(\\.\\d+)?S)?)?$"; +export const DURATION_RE = new RegExp(DURATION_PATTERN); + +const DURATION_PARTS_RE = /^([-+])?P(?:(\d+)W)?(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)(?:\.(\d+))?S)?)?$/; + +// The default (`IntervalStyle = postgres`) text of an interval: signed year / mon / day parts, +// then an optionally signed `H:MM:SS[.ffffff]`. Years and months are matched so they can be +// refused by name rather than as unreadable text. +const PG_INTERVAL_RE = + /^(?:([-+]?\d+) years? ?)?(?:([-+]?\d+) mons? ?)?(?:([-+]?\d+) days? ?)?(?:([-+])?(\d+):(\d{2}):(\d{2})(?:\.(\d{1,6}))?)?$/; + +const MICROS_PER_SECOND = 1_000_000n; +const MICROS_PER_MINUTE = 60n * MICROS_PER_SECOND; +const MICROS_PER_HOUR = 60n * MICROS_PER_MINUTE; +const MICROS_PER_DAY = 24n * MICROS_PER_HOUR; + +/** A 64-bit integer column (SQLite, D1, MySQL `BIGINT`) stores a duration as microseconds; it holds + * this range either way. A database's own limit, so it is checked only when binding to one. */ +export const DURATION_MICROS_MIN = -9223372036854775808n; +export const DURATION_MICROS_MAX = 9223372036854775807n; + +/** Fractional-second digits as microseconds, truncated toward zero. */ +function fractionMicros(digits: string | undefined): bigint { + if (digits === undefined || digits.length === 0) return 0n; + return BigInt(digits.padEnd(6, "0").slice(0, 6)); +} + +/** The microseconds an accepted ISO-8601 duration spans, or undefined when `text` is not one. */ +export function parseDurationLiteral(text: string): bigint | undefined { + if (!DURATION_RE.test(text)) return undefined; + const m = DURATION_PARTS_RE.exec(text); + if (m === null) return undefined; + const [, sign, w, d, h, min, s, f] = m; + const magnitude = + BigInt(w ?? "0") * 7n * MICROS_PER_DAY + + BigInt(d ?? "0") * MICROS_PER_DAY + + BigInt(h ?? "0") * MICROS_PER_HOUR + + BigInt(min ?? "0") * MICROS_PER_MINUTE + + BigInt(s ?? "0") * MICROS_PER_SECOND + + fractionMicros(f); + return sign === "-" ? -magnitude : magnitude; +} + +/** The magnitude of a span split into hours, minutes, seconds and microseconds of a second. */ +function splitMicros(micros: bigint): { negative: boolean; h: bigint; m: bigint; s: bigint; frac: bigint } { + const negative = micros < 0n; + let rest = negative ? -micros : micros; + const h = rest / MICROS_PER_HOUR; + rest %= MICROS_PER_HOUR; + const m = rest / MICROS_PER_MINUTE; + rest %= MICROS_PER_MINUTE; + const s = rest / MICROS_PER_SECOND; + return { negative, h, m, s, frac: rest % MICROS_PER_SECOND }; +} + +/** `.` and the microseconds with trailing zeros trimmed, or `""` when there is no fraction. */ +function fractionSuffix(frac: bigint): string { + return frac > 0n ? "." + String(frac).padStart(6, "0").replace(/0+$/, "") : ""; +} + +/** The canonical wire form of a span of `micros` microseconds. */ +export function formatDuration(micros: bigint): string { + const { negative, h, m, s, frac } = splitMicros(micros); + let out = "PT"; + if (h > 0n) out += `${h}H`; + if (m > 0n) out += `${m}M`; + if (s > 0n || frac > 0n) out += `${s}${fractionSuffix(frac)}S`; + if (out === "PT") return "PT0S"; + return negative ? "-" + out : out; +} + +/** + * A span as the text Postgres reads as interval input AND writes back for it: `[-]HH:MM:SS[.f]`, + * hours unbounded, no day part. Postgres refuses an ISO-8601 duration with a leading sign + * (`-PT1M30S`), so a value bound as text, and a declared column default, use this form; an + * interval written this way reads back in this exact spelling, so a default does not drift. + */ +export function formatPostgresInterval(micros: bigint): string { + const { negative, h, m, s, frac } = splitMicros(micros); + const two = (n: bigint): string => String(n).padStart(2, "0"); + const out = `${two(h)}:${two(m)}:${two(s)}${fractionSuffix(frac)}`; + return negative && micros !== 0n ? "-" + out : out; +} + +/** The canonical form of an accepted ISO-8601 duration, or undefined when `text` is not one. */ +export function canonicalDuration(text: string): string | undefined { + const micros = parseDurationLiteral(text); + return micros === undefined ? undefined : formatDuration(micros); +} + +/** Write side: a caller's value as the canonical string to bind, or undefined when it is not a + * duration. Postgres reads the canonical ISO form as interval input; a text column stores it, so + * equal spans are equal strings. */ +export function parseDurationInput(value: unknown): string | undefined { + return typeof value === "string" ? canonicalDuration(value) : undefined; +} + +/** The microseconds of the default-style text of a Postgres interval. Throws for an interval + * with a year or month part (not a fixed length) and for text that is not an interval. */ +export function parsePostgresInterval(text: string): bigint { + const m = text.length === 0 ? null : PG_INTERVAL_RE.exec(text); + if (m === null) throw new Error(`not a Postgres interval: ${JSON.stringify(text)}`); + const [, years, months, days, timeSign, h, min, s, f] = m; + if ((years !== undefined && BigInt(years) !== 0n) || (months !== undefined && BigInt(months) !== 0n)) { + throw new Error(`the interval ${JSON.stringify(text)} has a year or month part, so it is not a fixed-length field.duration`); + } + let time = 0n; + if (h !== undefined) { + time = BigInt(h) * MICROS_PER_HOUR + BigInt(min!) * MICROS_PER_MINUTE + BigInt(s!) * MICROS_PER_SECOND + fractionMicros(f); + if (timeSign === "-") time = -time; + } + return BigInt(days ?? "0") * MICROS_PER_DAY + time; +} + +/** The fields node-postgres's default interval parser (`postgres-interval`) hands back. */ +interface PostgresIntervalLike { + years?: number; months?: number; days?: number; + hours?: number; minutes?: number; seconds?: number; milliseconds?: number; +} + +function isIntervalObject(value: unknown): value is PostgresIntervalLike { + if (value === null || typeof value !== "object" || Array.isArray(value)) return false; + return ["days", "hours", "minutes", "seconds", "milliseconds", "years", "months"].some((k) => k in value) + || typeof (value as { toPostgres?: unknown }).toPostgres === "function"; +} + +function intervalObjectMicros(v: PostgresIntervalLike): bigint { + if ((v.years ?? 0) !== 0 || (v.months ?? 0) !== 0) { + throw new Error(`the interval ${JSON.stringify(v)} has a year or month part, so it is not a fixed-length field.duration`); + } + const whole = (n: number | undefined): bigint => BigInt(Math.trunc(n ?? 0)); + // postgres-interval carries microseconds as a fractional millisecond count. + const micros = BigInt(Math.round((v.milliseconds ?? 0) * 1000)); + return whole(v.days) * MICROS_PER_DAY + whole(v.hours) * MICROS_PER_HOUR + + whole(v.minutes) * MICROS_PER_MINUTE + whole(v.seconds) * MICROS_PER_SECOND + micros; +} + +/** + * Write side, for a 64-bit integer column (SQLite, D1, MySQL): the span as the microsecond count the + * column stores — a number when it is a safe integer, else a bigint. Throws past the 64-bit range, + * which is that column's limit and no other database's. + */ +export function durationToStoredMicros(micros: bigint): number | bigint { + if (micros > DURATION_MICROS_MAX || micros < DURATION_MICROS_MIN) { + throw new Error(`a span of ${formatDuration(micros)} does not fit the 64-bit microsecond column this database stores a field.duration in`); + } + const n = Number(micros); + return Number.isSafeInteger(n) ? n : micros; +} + +/** Read side: what a driver hands back for a duration column — the microsecond integer of a SQLite, + * D1 or MySQL column, an ISO string (Postgres under `IntervalStyle = iso_8601`), the default + * Postgres interval text, or a node-postgres interval object — as the canonical wire string. + * Throws for anything else. */ +export function durationToWire(value: unknown): string { + // A 64-bit integer column (SQLite, D1, MySQL) holds microseconds; a driver may return it as a + // number, a bigint or a digit string. + if (typeof value === "bigint") return formatDuration(value); + if (typeof value === "number" && Number.isInteger(value)) return formatDuration(BigInt(value)); + if (typeof value === "string" && /^-?\d+$/.test(value)) return formatDuration(BigInt(value)); + if (typeof value === "string") { + const iso = canonicalDuration(value); + return iso ?? formatDuration(parsePostgresInterval(value)); + } + if (isIntervalObject(value)) return formatDuration(intervalObjectMicros(value)); + throw new Error(`not a field.duration value: ${JSON.stringify(value)}`); +} diff --git a/server/typescript/packages/metadata/src/core/field/field-constants.ts b/server/typescript/packages/metadata/src/core/field/field-constants.ts index 1e470bf7ee..0c340412a1 100644 --- a/server/typescript/packages/metadata/src/core/field/field-constants.ts +++ b/server/typescript/packages/metadata/src/core/field/field-constants.ts @@ -34,6 +34,12 @@ export const FIELD_SUBTYPE_URI = "uri"; * IP (InetAddress / IPAddress / ipaddress). DB column is Postgres-native `inet`. * Codegen emits IP validation accepting v4+v6 (Zod .ip()). */ export const FIELD_SUBTYPE_INET = "inet"; +/** A fixed-length span of time (days, hours, minutes, seconds). Concept with a + * native type + behavior → a subtype. TS binds to `string` (an ISO-8601 + * duration; TS has no native duration type); other ports bind to native + * (TimeSpan / java.time.Duration / timedelta). DB column is Postgres-native + * `interval`, a 64-bit microsecond integer elsewhere. Years, months and weeks are refused. */ +export const FIELD_SUBTYPE_DURATION = "duration"; export const FIELD_SUBTYPES = [ SUBTYPE_BASE, @@ -54,6 +60,7 @@ export const FIELD_SUBTYPES = [ FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, + FIELD_SUBTYPE_DURATION, ] as const; export type FieldSubType = (typeof FIELD_SUBTYPES)[number]; diff --git a/server/typescript/packages/metadata/src/core/field/field-definition.embedded.ts b/server/typescript/packages/metadata/src/core/field/field-definition.embedded.ts index 75209e3a82..d3eb2bf19b 100644 --- a/server/typescript/packages/metadata/src/core/field/field-definition.embedded.ts +++ b/server/typescript/packages/metadata/src/core/field/field-definition.embedded.ts @@ -285,6 +285,14 @@ export const FIELD_DEFINITION: ProviderDefinition = { "max": 1, "description": "FR-019: marks an abstract package-level field.enum as externally provided — codegen references the type (resolved via per-port codegen config) instead of materializing it. Default false. Not a field attr — it lives on the type declaration." }, + { + "type": "attr", + "subType": "int", + "name": "maxLength", + "min": 0, + "max": 1, + "description": "Column length of a string-stored enum (drives VARCHAR(n)); every member must fit. Overrides the project's enums.maxLength. Refused with @intValueMap, whose column is an integer. Justified in ADR-0023 Amendment 1." + }, { "type": "attr", "subType": "intMap", @@ -338,6 +346,14 @@ export const FIELD_DEFINITION: ProviderDefinition = { "description": "#234: opt this field out of strict well-formedness enforcement. When true, codegen binds a plain string (no URL/IP validator, no native URI/InetAddress type; field.inet uses a text column, not the native inet type) so a not-strictly-valid value round-trips unchanged. Default (absent/false) is strict: an absolute-scheme URI / an IPv4-or-IPv6 literal." } ] + }, + { + "type": "field", + "subType": "duration", + "dataType": "string", + "extendsBase": true, + "description": "A fixed-length span of time: days, hours, minutes and seconds (ADR-0036/0037 — a concept with a native type + behavior). Binds to TS string (TS has no native duration type); other ports bind to their native type (C# System.TimeSpan, Java and Kotlin java.time.Duration, Python datetime.timedelta). DB column is the Postgres-native interval type; SQLite, D1 and MySQL store a 64-bit integer count of microseconds, which orders and compares as the span does. The wire form is an ISO-8601 duration: every port writes [-]PT[nH][nM][n[.f]S] (hours never folded into days, as many fractional digits as its native type holds, zero is PT0S) and reads that form plus leading week and day counts (nW, exactly 7 days; nD, exactly 24 hours) and a fraction of any length, truncated toward zero. Years and months are refused: a calendar span is not a fixed length. Justified in ADR-0023 Amendment 1.", + "whenToUse": "A field holds an elapsed time, a timeout or a delay. Use it for a native Postgres interval column and a native duration type instead of a string or a number of seconds. A calendar span (\"every 3 months\") is not a duration: model it as an int and a unit." } ] }; diff --git a/server/typescript/packages/metadata/src/core/field/meta-field.ts b/server/typescript/packages/metadata/src/core/field/meta-field.ts index 7bdb2ea246..971f9ab26e 100644 --- a/server/typescript/packages/metadata/src/core/field/meta-field.ts +++ b/server/typescript/packages/metadata/src/core/field/meta-field.ts @@ -34,6 +34,7 @@ import { FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, + FIELD_SUBTYPE_DURATION, FIELD_ATTR_REQUIRED, FIELD_ATTR_UNIQUE, FIELD_ATTR_DEFAULT, @@ -59,6 +60,9 @@ const FIELD_DATA_TYPE: Readonly> = { // URI/IP type in TS). Other ports bind to their native URI/IP type. [FIELD_SUBTYPE_URI]: DATA_TYPE_STRING, [FIELD_SUBTYPE_INET]: DATA_TYPE_STRING, + // field.duration binds to TS `string` (an ISO-8601 duration); other ports bind + // to their native duration type. + [FIELD_SUBTYPE_DURATION]: DATA_TYPE_STRING, [FIELD_SUBTYPE_INT]: DATA_TYPE_INT, [FIELD_SUBTYPE_LONG]: DATA_TYPE_LONG, [FIELD_SUBTYPE_CURRENCY]: DATA_TYPE_LONG, diff --git a/server/typescript/packages/metadata/src/core/query/query-constants.ts b/server/typescript/packages/metadata/src/core/query/query-constants.ts index 8eba954b2c..df65704691 100644 --- a/server/typescript/packages/metadata/src/core/query/query-constants.ts +++ b/server/typescript/packages/metadata/src/core/query/query-constants.ts @@ -1,4 +1,4 @@ -import { FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_CURRENCY, FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, FIELD_ATTR_INT_VALUE_MAP } from "../field/field-constants.js"; +import { FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_CURRENCY, FIELD_SUBTYPE_ENUM, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, FIELD_SUBTYPE_DURATION, FIELD_ATTR_INT_VALUE_MAP } from "../field/field-constants.js"; // Query concern constants — filter operators, sort order values. // @@ -61,6 +61,9 @@ export const OPS_BY_SUBTYPE: Readonly> = { // inet: an IP address — identity-comparison ops only (no `like`; ordering // gt/lt is not exposed). Same op band as uuid. [FIELD_SUBTYPE_INET]: [FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_IN, FILTER_OP_IS_NULL], + // duration: ordered on every database — a Postgres interval, and the 64-bit + // microsecond integer SQLite / D1 / MySQL store it as. + [FIELD_SUBTYPE_DURATION]: [FILTER_OP_EQ, FILTER_OP_NE, FILTER_OP_GT, FILTER_OP_GTE, FILTER_OP_LT, FILTER_OP_LTE, FILTER_OP_IN, FILTER_OP_IS_NULL], } as const; export function opsForSubType(subType: string): readonly FilterOp[] { 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 719828e1f9..acbbbd0f74 100644 --- a/server/typescript/packages/metadata/src/core/validator/validator-constants.ts +++ b/server/typescript/packages/metadata/src/core/validator/validator-constants.ts @@ -16,6 +16,7 @@ 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_SUBTYPE_EXACTLY_ONE = "exactlyOne"; export const VALIDATOR_SUBTYPES = [ SUBTYPE_BASE, @@ -28,6 +29,7 @@ export const VALIDATOR_SUBTYPES = [ VALIDATOR_SUBTYPE_REQUIRED_WHEN, VALIDATOR_SUBTYPE_PRESENT_IFF, VALIDATOR_SUBTYPE_AT_LEAST_ONE, + VALIDATOR_SUBTYPE_EXACTLY_ONE, ] as const; export type ValidatorSubType = (typeof VALIDATOR_SUBTYPES)[number]; @@ -50,3 +52,16 @@ 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]; + +/** comparison / requiredWhen / presentIff / atLeastOne / exactlyOne: the case the rule applies in (an attr.expression). */ +export const VALIDATOR_ATTR_CONDITION = "condition"; +/** Every validator (db provider): the database name of the CHECK it generates. */ +export const VALIDATOR_ATTR_CONSTRAINT_NAME = "constraintName"; +/** The cross-field validators: the database enforces them, nothing else evaluates them. */ +export const CROSS_FIELD_VALIDATOR_SUBTYPES: readonly string[] = [ + VALIDATOR_SUBTYPE_COMPARISON, + VALIDATOR_SUBTYPE_REQUIRED_WHEN, + VALIDATOR_SUBTYPE_PRESENT_IFF, + VALIDATOR_SUBTYPE_AT_LEAST_ONE, + VALIDATOR_SUBTYPE_EXACTLY_ONE, +]; 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 c8f5c6a83d..1786cca426 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 @@ -180,6 +180,14 @@ export const VALIDATOR_DEFINITION: ProviderDefinition = { "min": 1, "max": 1, "description": "Name of the right-hand field of the owning entity." + }, + { + "type": "attr", + "subType": "expression", + "name": "condition", + "min": 0, + "max": 1, + "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." } ] }, @@ -213,6 +221,14 @@ export const VALIDATOR_DEFINITION: ProviderDefinition = { "min": 1, "max": 1, "description": "The gating value; when @when equals this, @field must be present." + }, + { + "type": "attr", + "subType": "expression", + "name": "condition", + "min": 0, + "max": 1, + "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." } ] }, @@ -246,6 +262,14 @@ export const VALIDATOR_DEFINITION: ProviderDefinition = { "min": 1, "max": 1, "description": "The gating value; @field is present exactly when @when equals this." + }, + { + "type": "attr", + "subType": "expression", + "name": "condition", + "min": 0, + "max": 1, + "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." } ] }, @@ -264,6 +288,40 @@ export const VALIDATOR_DEFINITION: ProviderDefinition = { "min": 1, "max": 1, "description": "Names of the candidate fields; at least one must be present." + }, + { + "type": "attr", + "subType": "expression", + "name": "condition", + "min": 0, + "max": 1, + "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." + } + ] + }, + { + "type": "validator", + "subType": "exactlyOne", + "description": "Cardinality of presence: exactly one of the named fields (@fields) is present (NOT NULL), and the others are null. Entity-scoped; references fields by name (same @fields-by-name pattern as identity.*). Justified in ADR-0023 Amendment 1.", + "whenToUse": "A row belongs to exactly one of several alternatives (a store or a kiosk, never both, never neither). Declare it instead of hand-written count-of-non-nulls checks.", + "rules": "@fields names two or more fields of the owning entity (ERR_BAD_ATTR_VALUE): one field would just be a required field. Satisfied when exactly one of them is non-null.", + "children": [ + { + "type": "attr", + "subType": "string", + "name": "fields", + "isArray": true, + "min": 1, + "max": 1, + "description": "Names of the alternative fields; exactly one must be present." + }, + { + "type": "attr", + "subType": "expression", + "name": "condition", + "min": 0, + "max": 1, + "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." } ] } diff --git a/server/typescript/packages/metadata/src/enum-length.ts b/server/typescript/packages/metadata/src/enum-length.ts new file mode 100644 index 0000000000..af3d3e5a90 --- /dev/null +++ b/server/typescript/packages/metadata/src/enum-length.ts @@ -0,0 +1,66 @@ +// The column length of a string-stored field.enum: the field's @maxLength, else the +// project's default (`enums.maxLength` in .metaobjects/config.json, carried on the loaded +// root beside the naming rules), else none — an unbounded text column, as before. + +import type { MetaData } from "./shared/meta-data.js"; +import { MetaModelError } from "./errors.js"; +import { + FIELD_ATTR_INT_VALUE_MAP, + FIELD_ATTR_MAX_LENGTH, + FIELD_ATTR_VALUES, + FIELD_SUBTYPE_ENUM, +} from "./core/field/field-constants.js"; +import { TYPE_OBJECT } from "./shared/base-types.js"; + +/** The `enums` block in `.metaobjects/config.json`. */ +export const ENUMS_KEY_MAX_LENGTH = "maxLength"; + +/** The project's enum storage defaults: `enums` in `.metaobjects/config.json`. */ +export interface EnumDefaults { + /** The column length of a string-stored enum that declares no @maxLength. */ + readonly maxLength?: number | undefined; +} + +/** No project default: an enum with no @maxLength is an unbounded text column. */ +export const DEFAULT_ENUM_DEFAULTS: EnumDefaults = Object.freeze({}); + +/** The enum defaults for the tree `node` belongs to: what its root carries, else none. */ +export function enumDefaultsOf(node: MetaData): EnumDefaults { + const defaults = (node.root() as { enumDefaults?: EnumDefaults }).enumDefaults; + return defaults ?? DEFAULT_ENUM_DEFAULTS; +} + +/** + * The column length of `field`, a string-stored `field.enum`: its @maxLength (ADR-0039 — + * resolving, it may be inherited), else the project default, else undefined. Undefined for + * an @intValueMap enum, whose column is an integer, for an array enum, a text[] column whose + * elements carry no length, and for any other field. + * + * The loader refuses an @maxLength a member does not fit; it does not see the project + * config, so a member longer than the project DEFAULT is refused here, by every consumer + * that sizes the column (`meta migrate`, the generated bindings). + */ +export function resolveEnumMaxLength(field: MetaData): number | undefined { + if (field.subType !== FIELD_SUBTYPE_ENUM || field.attr(FIELD_ATTR_INT_VALUE_MAP) !== undefined) return undefined; + // An array enum is a text[] column, whose elements carry no length (ADR-0039: resolving). + if ((field as { resolvedIsArray?: () => boolean }).resolvedIsArray?.() === true) return undefined; + const own = field.attr(FIELD_ATTR_MAX_LENGTH); + if (typeof own === "number") return own; + const fallback = enumDefaultsOf(field).maxLength; + if (fallback === undefined) return undefined; + const values = field.attr(FIELD_ATTR_VALUES); + const longest = (Array.isArray(values) ? values : []) + .filter((v): v is string => typeof v === "string") + .reduce((a, v) => (a === undefined || v.length > a.length ? v : a), undefined); + if (longest !== undefined && longest.length > fallback) { + const owner = field.parent; + const label = owner !== undefined && owner.type === TYPE_OBJECT ? `${owner.name}.${field.name}` : field.name; + throw new MetaModelError( + `field.enum "${label}" has the member "${longest}" (${longest.length} characters), longer than ` + + `the project's enums.maxLength ${fallback}. Give the field its own @maxLength of at least ` + + `${longest.length}, raise enums.maxLength, or shorten the member.`, + { code: "ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH" }, + ); + } + return fallback; +} diff --git a/server/typescript/packages/metadata/src/errors.ts b/server/typescript/packages/metadata/src/errors.ts index e9616a15a8..4f8b3fac21 100644 --- a/server/typescript/packages/metadata/src/errors.ts +++ b/server/typescript/packages/metadata/src/errors.ts @@ -153,6 +153,14 @@ export const ERROR_CODES = [ "ERR_TEMPLATE_ENGINE_UNSUPPORTED", "ERR_BAD_ATTR_FILTER", "ERR_STORAGE_FLATTENED_ARRAY", + "ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE", + // field.enum @maxLength: a member longer than the column, or a length on an int-stored enum. + "ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH", + "ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP", + // identity.primary @sequenceStart / @sequenceName without @generation "increment". + "ERR_SEQUENCE_WITHOUT_INCREMENT", + // identity.primary @sequenceStart below 1, or past what its key field can hold. + "ERR_SEQUENCE_START_OUT_OF_RANGE", "ERR_STORAGE_WITHOUT_OBJECT_REF", // ADR-0013 — a field.object REQUIRES @objectRef (open/untyped JSON uses // the physical @dbColumnType: jsonb escape hatch on field.string instead). diff --git a/server/typescript/packages/metadata/src/index.ts b/server/typescript/packages/metadata/src/index.ts index 6ddc3032ef..73f4655a6b 100644 --- a/server/typescript/packages/metadata/src/index.ts +++ b/server/typescript/packages/metadata/src/index.ts @@ -27,6 +27,7 @@ export * from "./shared/base-types.js"; export * from "./shared/structural.js"; export * from "./core/object/object-constants.js"; export * from "./core/field/field-constants.js"; +export * from "./core/field/duration-wire.js"; // FR-037 R1 — THE mutability accessors. Every consumer deciding "may this be // written, and when?" must go through these rather than reading the attr, so the // absent-means-readWrite default lives in exactly one place per port. @@ -94,6 +95,7 @@ export { } from "./core/requirement/resolve-claim.js"; export * from "./core/relationship/relationship-constants.js"; export * from "./core/query/query-constants.js"; +export { EXPR_OP_IS_NOT_NULL, EXPR_OP_NOT, EXPR_FN_COALESCE, type ExprNode } from "./core/attr/meta-attr-expression.js"; export * from "./persistence/source/source-constants.js"; export * from "./persistence/origin/origin-constants.js"; export * from "./template/template-constants.js"; @@ -368,25 +370,29 @@ export { // Naming — hoisted from runtime-ts in v0.2.3 so multiple consumers (runtime-ts, migrate-ts, codegen-ts) // share identical name resolution. See spec §4.1. export { - toSnakeCase, toKebabCase, pluralize, pluralizeTableNameLegacy, + toSnakeCase, toKebabCase, toUpperFirstColumn, pluralize, pluralizeTableNameLegacy, applyColumnNamingStrategy, DEFAULT_COLUMN_NAMING_STRATEGY, COLUMN_NAMING_STRATEGIES, TABLE_NAMING_RULES, DEFAULT_PHYSICAL_NAMING, physicalNamingOf, applyTableNamingRule, PHYSICAL_NAMING_MIGRATION_GUIDE, PHYSICAL_NAMING_MIGRATION_GUIDE_PATH, physicalNamingRemovedMessage, - resolveTableName, resolveColumnName, resolveTableSchema, resolveIndexName, + resolveTableName, resolveColumnName, resolveFlattenedColumnPrefix, resolveFlattenedColumnName, + resolveTableSchema, resolveIndexName, primaryRdbSource, sourceAddressKey, buildNameMap, stripPackage, packageOfResolutionKey, } from "./naming.js"; export type { EntityNameMap, ColumnNamingStrategy, PhysicalNaming, TableNamingRule } from "./naming.js"; +export { resolveEnumMaxLength, enumDefaultsOf, DEFAULT_ENUM_DEFAULTS, ENUMS_KEY_MAX_LENGTH } from "./enum-length.js"; +export type { EnumDefaults } from "./enum-length.js"; // Wire format (ADR-0019 Amendment 2): the `wire` block of .metaobjects/config.json. export { - WIRE_DECIMALS, WIRE_NULL_MEMBERS, WIRE_KEY_DECIMALS, WIRE_KEY_NULL_MEMBERS, + WIRE_DECIMALS, WIRE_NULL_MEMBERS, WIRE_DURATIONS, WIRE_KEY_DECIMALS, WIRE_KEY_NULL_MEMBERS, WIRE_KEY_DURATIONS, WIRE_DECIMALS_NATIVE, WIRE_DECIMALS_STRING, WIRE_NULL_MEMBERS_NATIVE, WIRE_NULL_MEMBERS_EMIT, WIRE_NULL_MEMBERS_OMIT, + WIRE_DURATIONS_NATIVE, WIRE_DURATIONS_ISO, DEFAULT_WIRE_FORMAT, wireFormatOf, } from "./wire.js"; -export type { WireFormat, WireDecimals, WireNullMembers } from "./wire.js"; +export type { WireFormat, WireDecimals, WireNullMembers, WireDurations } from "./wire.js"; // Retired vocabulary: the map the loader reads to explain a retirement, and the raw-document // rewriter `meta upgrade` drives from the SAME entries — so the error message and the fix diff --git a/server/typescript/packages/metadata/src/loader/meta-data-loader.ts b/server/typescript/packages/metadata/src/loader/meta-data-loader.ts index f939431360..726c1671b3 100644 --- a/server/typescript/packages/metadata/src/loader/meta-data-loader.ts +++ b/server/typescript/packages/metadata/src/loader/meta-data-loader.ts @@ -25,7 +25,8 @@ import { ParseError } from "../errors.js"; import type { LoaderWarning } from "../source.js"; import { codeSource, resolvedSource } from "../source.js"; import { parseJson } from "../parser-json.js"; -import { validateDataGridSortFields, validateFilterableHasIndex, validateFilterableHasSupportedOps, validateSortableHasSupportedSubtype, validateOriginPaths, validateDerivedFieldProvidability, validateDataGridFilterValues, validateFieldObjectStorage, validateFieldMap, validateTemplatePayloadRefs, validateFieldDefaults, validateRelationships, validateOneSideReferenceResolution, validateM2MJunctionPairing, validateIndexLookupFields, validateProjectionFilter, validateRetiredRequirementLinks, validateColumnNameCollisions } from "./validation-passes.js"; +import { validateDataGridSortFields, validateFilterableHasIndex, validateFilterableHasSupportedOps, validateSortableHasSupportedSubtype, validateOriginPaths, validateDerivedFieldProvidability, validateDataGridFilterValues, validateFieldObjectStorage, validateFieldMap, + validateEnumMaxLength, validateIdentitySequence, validateCrossFieldValidators, validateTemplatePayloadRefs, validateFieldDefaults, validateRelationships, validateOneSideReferenceResolution, validateM2MJunctionPairing, validateIndexLookupFields, validateProjectionFilter, validateRetiredRequirementLinks, validateColumnNameCollisions } from "./validation-passes.js"; import { validateReporting } from "./reporting-validation.js"; import { runRegisteredValidation } from "./validation-registry.js"; import { validateSourceRoles } from "../persistence/source/validate-source-roles.js"; @@ -724,6 +725,15 @@ export class MetaDataLoader { // @valueType (when set) must name a known scalar subtype. errors.push(...validateFieldMap(root)); + // field.enum @maxLength — every member fits; refused with @intValueMap. + errors.push(...validateEnumMaxLength(root)); + + // identity.primary @sequenceStart / @sequenceName need @generation "increment". + errors.push(...validateIdentitySequence(root)); + + // Cross-field validators: exactlyOne names two or more fields; @condition names real fields. + errors.push(...validateCrossFieldValidators(root)); + // Tenth pass: one-primary multi-source rule — if an object has ≥1 source, // exactly one must carry role "primary" (ERR_SOURCE_NO_PRIMARY / // ERR_SOURCE_MULTIPLE_PRIMARY). diff --git a/server/typescript/packages/metadata/src/loader/validation-passes.ts b/server/typescript/packages/metadata/src/loader/validation-passes.ts index 3d72cc712b..e3b328a128 100644 --- a/server/typescript/packages/metadata/src/loader/validation-passes.ts +++ b/server/typescript/packages/metadata/src/loader/validation-passes.ts @@ -10,6 +10,13 @@ // Private: _findObject, _findField, _findRelationship, // _validateFromPath, _validateViaPath (helpers, not exported). +import { TYPE_VALIDATOR } from "../shared/base-types.js"; +import { + CROSS_FIELD_VALIDATOR_SUBTYPES, + VALIDATOR_ATTR_CONDITION, + VALIDATOR_ATTR_FIELDS, + VALIDATOR_SUBTYPE_EXACTLY_ONE, +} from "../core/validator/validator-constants.js"; import type { MetaData } from "../shared/meta-data.js"; import type { MetaObject } from "../core/object/meta-object.js"; import type { MetaReferenceIdentity } from "../core/identity/meta-identity.js"; @@ -33,7 +40,7 @@ import { INDEX_SUBTYPE_LOOKUP, INDEX_ATTR_FIELDS, } from "../core/index/index-constants.js"; -import { IDENTITY_SUBTYPE_SECONDARY } from "../core/identity/identity-constants.js"; +import { IDENTITY_SUBTYPE_SECONDARY, IDENTITY_SUBTYPE_PRIMARY, IDENTITY_ATTR_GENERATION, GENERATION_INCREMENT } from "../core/identity/identity-constants.js"; import type { MetaIdentity } from "../core/identity/meta-identity.js"; import { MetaIndex } from "../core/index/meta-index.js"; import { @@ -55,8 +62,8 @@ import { OBJECT_PROJECTION_ATTR_FILTER, } from "../core/object/object-constants.js"; import { MetaSource } from "../persistence/source/meta-source.js"; -import { resolveColumnName } from "../naming.js"; -import { isMetaField } from "../shared/node-guards.js"; +import { resolveColumnName, resolveFlattenedColumnName } from "../naming.js"; +import { isMetaField, isMetaObject } from "../shared/node-guards.js"; import { LAYOUT_SUBTYPE_DATA_GRID, LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_FIELD, @@ -77,6 +84,9 @@ import { FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_BOOLEAN, FIELD_SUBTYPE_ENUM, + FIELD_ATTR_INT_VALUE_MAP, + FIELD_ATTR_MAX_LENGTH, + FIELD_ATTR_VALUES, FIELD_SUBTYPE_OBJECT, FIELD_SUBTYPE_MAP, FIELD_ATTR_REQUIRED, @@ -87,7 +97,7 @@ import { FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_UUID, } from "../core/field/field-constants.js"; -import { FIELD_ATTR_COLUMN, FIELD_ATTR_DB_INDEXED, IDENTITY_ATTR_EXPR } from "../persistence/db/db-constants.js"; +import { FIELD_ATTR_COLUMN, FIELD_ATTR_FLATTENED_COLUMN_PREFIX, FIELD_ATTR_DB_INDEXED, IDENTITY_ATTR_EXPR, IDENTITY_ATTR_SEQUENCE_NAME, IDENTITY_ATTR_SEQUENCE_START } from "../persistence/db/db-constants.js"; import { SOURCE_KIND_VIEW } from "../persistence/source/source-constants.js"; import { IDENTITY_ATTR_FIELDS, @@ -1677,6 +1687,222 @@ export function validateDerivedFieldProvidability(root: MetaData): ParseError[] return errors; } +// --------------------------------------------------------------------------- +// Cross-field validators (comparison / requiredWhen / presentIff / atLeastOne / exactlyOne), +// which the database enforces as CHECKs: +// - exactlyOne @fields names two or more fields (ERR_BAD_ATTR_VALUE): one is a required field; +// - every field a @condition expression names is a field of the owning entity +// (ERR_BAD_ATTR_VALUE), so the CHECK it renders to references a real column. +// Read RESOLVING (ADR-0039): an entity's validators and fields include inherited ones. +// --------------------------------------------------------------------------- + +/** The field names an attr.expression tree references. */ +function expressionFieldRefs(node: unknown, out: string[] = []): string[] { + if (typeof node !== "object" || node === null) return out; + const n = node as Record; + if (typeof n["field"] === "string") out.push(n["field"]); + for (const key of ["left", "right", "arg"]) expressionFieldRefs(n[key], out); + for (const key of ["args"]) if (Array.isArray(n[key])) for (const a of n[key] as unknown[]) expressionFieldRefs(a, out); + return out; +} + +/** Field subtypes whose values a condition compares with a number literal. */ +const _CONDITION_NUMBER_SUBTYPES = new Set([ + FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, + FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_CURRENCY, +]); + +/** + * Why a cross-field validator's `@condition` cannot become a CHECK guard, or undefined when it + * can. Its names are already known to be fields. The rule, the same in every port: + * - it types cleanly (the origin.computed inference) and its root is boolean, since a CHECK + * negates it; + * - a literal compared with a field is of that field's kind (a boolean for field.boolean, a + * number for a numeric field, a string otherwise) and, for field.enum, one of its @values; + * - no comparison has a null literal: `x = NULL` is never true, so use isNull / isNotNull. + */ +function conditionProblem(condition: unknown, obj: MetaObject): string | undefined { + const fields = new Map(obj.fields().map((f) => [f.name, f] as const)); + const inferred = inferExprType(condition as ExprNode, (name) => fields.get(name)?.subType); + if (inferred.errors.length > 0) return `is not a valid expression: ${inferred.errors.join("; ")}.`; + if (inferred.type !== FIELD_SUBTYPE_BOOLEAN) { + return `must be a boolean expression (a comparison, a null test, or and / or / not of them), not a field.${String(inferred.type)}.`; + } + let problem: string | undefined; + const visit = (node: unknown): void => { + if (problem !== undefined || typeof node !== "object" || node === null) return; + const n = node as Record; + if (n["left"] !== undefined && n["right"] !== undefined) { + for (const [a, b] of [[n["left"], n["right"]], [n["right"], n["left"]]] as const) { + const lit = a as Record; + const ref = b as Record; + if (!("value" in lit)) continue; + if (lit["value"] === null) { + problem = `compares with null, which is never true: use isNull or isNotNull.`; + return; + } + const field = typeof ref["field"] === "string" ? fields.get(ref["field"]) : undefined; + if (field === undefined) continue; + const value = lit["value"]; + const kind = field.subType === FIELD_SUBTYPE_BOOLEAN ? "boolean" + : _CONDITION_NUMBER_SUBTYPES.has(field.subType) ? "number" : "string"; + if (typeof value !== kind) { + problem = `compares "${field.name}" (field.${field.subType}) with ${JSON.stringify(value)}; it needs a ${kind}.`; + return; + } + const members = field.subType === FIELD_SUBTYPE_ENUM ? field.attr(FIELD_ATTR_VALUES) : undefined; + if (Array.isArray(members) && !members.map(String).includes(String(value))) { + problem = `compares "${field.name}" with ${JSON.stringify(value)}, which is not one of its @values (${members.join(", ")}).`; + return; + } + } + } + for (const key of ["left", "right", "arg"]) visit(n[key]); + if (Array.isArray(n["args"])) for (const a of n["args"]) visit(a); + }; + visit(condition); + return problem; +} + +export function validateCrossFieldValidators(root: MetaData): ParseError[] { + const errors: ParseError[] = []; + for (const obj of root.children().filter((c) => c.type === TYPE_OBJECT)) { + const fieldNames = new Set((obj as MetaObject).fields().map((f) => f.name)); + // ADR-0039: own — each validator is checked where it is declared. + for (const v of obj.ownChildren()) { + if (v.type !== TYPE_VALIDATOR || !CROSS_FIELD_VALIDATOR_SUBTYPES.includes(v.subType)) continue; + if (v.subType === VALIDATOR_SUBTYPE_EXACTLY_ONE) { + const fields = [v.attr(VALIDATOR_ATTR_FIELDS)].flat().filter((f) => typeof f === "string"); + if (fields.length < 2) { + errors.push(new ParseError( + `validator.exactlyOne "${obj.name}.${v.name}" @fields names ${fields.length} field${fields.length === 1 ? "" : "s"}; ` + + `it needs two or more. For one field, make that field required.`, + { code: "ERR_BAD_ATTR_VALUE", source: v.source }, + )); + continue; + } + } + const condition = v.attr(VALIDATOR_ATTR_CONDITION); + if (condition !== undefined) { + const label = `validator.${v.subType} "${obj.name}.${v.name}" @condition`; + const unknown = [...new Set(expressionFieldRefs(condition))].filter((f) => !fieldNames.has(f)).sort(); + if (unknown.length > 0) { + errors.push(new ParseError( + `${label} names ${unknown.map((f) => `"${f}"`).join(", ")}, ` + + `which ${unknown.length === 1 ? "is not a field" : "are not fields"} of "${obj.name}".`, + { code: "ERR_BAD_ATTR_VALUE", source: v.source }, + )); + continue; + } + const problem = conditionProblem(condition, obj as MetaObject); + if (problem !== undefined) { + errors.push(new ParseError(`${label} ${problem}`, { code: "ERR_BAD_ATTR_VALUE", source: v.source })); + } + } + } + } + return errors; +} + +// --------------------------------------------------------------------------- +// identity.primary @sequenceStart / @sequenceName describe the sequence an @generation +// "increment" key draws from. On any other generation there is no sequence, so either one +// is refused (ERR_SEQUENCE_WITHOUT_INCREMENT) rather than silently ignored. Read RESOLVING +// (ADR-0039), reported on the identity that declares one of them. +// --------------------------------------------------------------------------- + +/** The largest value a field.int key (a 32-bit integer column) can hold. */ +const SEQUENCE_INT_MAX = 2147483647; + +export function validateIdentitySequence(root: MetaData): ParseError[] { + const errors: ParseError[] = []; + for (const obj of root.children().filter((c) => c.type === TYPE_OBJECT)) { + for (const id of obj.ownChildren()) { + if (id.type !== TYPE_IDENTITY || id.subType !== IDENTITY_SUBTYPE_PRIMARY) continue; + const declared = [IDENTITY_ATTR_SEQUENCE_START, IDENTITY_ATTR_SEQUENCE_NAME].filter((a) => id.ownAttr(a) !== undefined); + if (declared.length === 0) continue; + if (id.attr(IDENTITY_ATTR_GENERATION) === GENERATION_INCREMENT) { + const start = id.attr(IDENTITY_ATTR_SEQUENCE_START); + if (typeof start === "number") { + // The key field's own range bounds the start (Postgres refuses the sequence + // otherwise, at apply time): 1 to the largest value of a field.int / field.long. + const keyName = [id.attr(IDENTITY_ATTR_FIELDS)].flat()[0]; + const key = (obj as MetaObject).fields().find((f) => f.name === keyName); + const max = key?.subType === FIELD_SUBTYPE_INT ? SEQUENCE_INT_MAX : undefined; + if (start < 1 || (max !== undefined && start > max)) { + errors.push(new ParseError( + `identity.primary "${obj.name}.${id.name}" @sequenceStart ${start} is out of range for its key ` + + `"${String(keyName)}"${key !== undefined ? ` (field.${key.subType})` : ""}: it must be at least 1` + + `${max !== undefined ? ` and at most ${max}` : ""}.`, + { code: "ERR_SEQUENCE_START_OUT_OF_RANGE", source: id.source }, + )); + } + } + continue; + } + const generation = id.attr(IDENTITY_ATTR_GENERATION); + const shown = typeof generation === "string" ? `"${generation}"` : "not set"; + errors.push(new ParseError( + `identity.primary "${obj.name}.${id.name}" sets ${declared.map((a) => `@${a}`).join(" and ")}, but its @generation is ${shown}; ` + + `only an @generation "increment" key draws from a sequence. Set @generation "increment", or remove ${declared.map((a) => `@${a}`).join(" and ")}.`, + { code: "ERR_SEQUENCE_WITHOUT_INCREMENT", source: id.source }, + )); + } + } + return errors; +} + +// --------------------------------------------------------------------------- +// field.enum @maxLength — the column length of a string-stored enum: +// 1. every member of @values fits in it (ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH), the +// longest offender named; +// 2. it is refused beside @intValueMap (ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP): an +// int-stored enum's column is an integer, so a length would change nothing. +// Read RESOLVING (ADR-0039: all three may be inherited through extends), and reported on +// the node that DECLARES one of them, so an enum that only inherits them is not +// reported a second time. Every field.enum in the tree is checked, root-level abstract +// enums included. The project default (enums.maxLength) is not this pass's: the loader +// does not see the project config; resolveEnumMaxLength refuses an overflow of it. +// --------------------------------------------------------------------------- + +export function validateEnumMaxLength(root: MetaData): ParseError[] { + const errors: ParseError[] = []; + const visit = (node: MetaData): void => { + // ADR-0039: own children — every node is visited once, where it is declared. + for (const child of node.ownChildren()) { + if (child.type === TYPE_FIELD && child.subType === FIELD_SUBTYPE_ENUM) checkEnum(child); + if (child.type !== TYPE_FIELD) visit(child); + } + }; + const checkEnum = (field: MetaData): void => { + const declares = [FIELD_ATTR_MAX_LENGTH, FIELD_ATTR_VALUES, FIELD_ATTR_INT_VALUE_MAP] + .some((a) => field.ownAttr(a) !== undefined); + const maxLength = field.attr(FIELD_ATTR_MAX_LENGTH); + if (!declares || typeof maxLength !== "number") return; + const owner = field.parent; + const label = owner !== undefined && owner.type === TYPE_OBJECT ? `${owner.name}.${field.name}` : field.name; + if (field.attr(FIELD_ATTR_INT_VALUE_MAP) !== undefined) { + errors.push(new ParseError( + `field.enum "${label}" sets @maxLength ${maxLength} and @intValueMap; an @intValueMap enum is stored as an integer, so a column length does not apply. Remove @maxLength.`, + { code: "ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP", source: field.source }, + )); + return; + } + const values = field.attr(FIELD_ATTR_VALUES); + const tooLong = (Array.isArray(values) ? values : []) + .filter((v): v is string => typeof v === "string" && v.length > maxLength) + .sort((a, b) => b.length - a.length); + if (tooLong.length > 0) { + errors.push(new ParseError( + `field.enum "${label}" @maxLength ${maxLength} is shorter than its member "${tooLong[0]}" (${tooLong[0]!.length} characters). Raise @maxLength to at least ${tooLong[0]!.length}, or shorten the member.`, + { code: "ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH", source: field.source }, + )); + } + }; + visit(root); + return errors; +} + // --------------------------------------------------------------------------- // field.object + @storage cross-attribute validation // @@ -1696,6 +1922,8 @@ export function validateDerivedFieldProvidability(root: MetaData): ParseError[] // node: when @objectRef is absent we skip the flattened/array check below.) // 2. @storage "flattened" requires isArray to be absent or false (cannot // flatten a variable-length array into a fixed column set). +// 3. @flattenedColumnPrefix requires @storage "flattened" (ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE): +// no other storage gives the members columns of their own to prefix. // --------------------------------------------------------------------------- export function validateFieldObjectStorage(root: MetaData): ParseError[] { @@ -1724,6 +1952,17 @@ export function validateFieldObjectStorage(root: MetaData): ParseError[] { // ADR-0039: resolving — @storage and array-ness may be inherited via extends. const storage = field.attr(FIELD_ATTR_STORAGE); + // @flattenedColumnPrefix names the columns a flattened value object adds to the owning table. + // Under any other storage (absent is jsonb) there are no such columns, so the prefix + // would change nothing: refuse it rather than ignore it silently. + if (field.attr(FIELD_ATTR_FLATTENED_COLUMN_PREFIX) !== undefined && storage !== STORAGE_FLATTENED) { + errors.push( + new ParseError( + `field.object "${obj.name}.${field.name}" sets @flattenedColumnPrefix, but its @storage is ${storage === undefined || storage === null ? "not set (jsonb)" : `"${String(storage)}"`}; only a @storage "flattened" value object puts its members' columns in the owning table. Set @storage "flattened", or remove @flattenedColumnPrefix.`, + { code: "ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE", source: field.source }, + ), + ); + } if (storage === undefined || storage === null) continue; if (storage === STORAGE_FLATTENED && field.resolvedIsArray()) { errors.push( @@ -2775,8 +3014,12 @@ export function validateRetiredRequirementLinks(root: MetaData): ParseError[] { // is compared, through the single door `resolveColumnName`, so an explicit @column // override, an inherited field and the project's column strategy are all honoured. // Scope: a concrete object that declares at least one source (a source-less -// object generates nothing); derived fields (origin.*) and field.object (which may -// flatten to several columns) are not columns of this table and are skipped. +// object generates nothing); derived fields (origin.*) are not columns of this table +// and are skipped. A `field.object @storage "flattened"` has no column of its own: each +// member of its value object is a column, named by `resolveFlattenedColumnName` exactly +// as migrate creates it, so two embeddings with one `@flattenedColumnPrefix` (or a `""` +// prefix beside a field of the member's name) collide here. Any other field.object is +// one jsonb column under its own resolved name. // ADR-0039: effective fields, so an inherited field collides like an own one. // Exempt: an object whose every source is read-only and at least one is a // `source.rdb @kind: view` with an `@sql` body. The author's SQL defines that view's @@ -2799,31 +3042,66 @@ function hasExplicitColumn(field: MetaData): boolean { return typeof col === "string" && col !== ""; } +/** One physical column a field contributes to its object's table. */ +interface PhysicalColumn { + readonly column: string; + /** `street`, or `shipTo.street` for a member of a flattened value object. */ + readonly label: string; + /** The field that names it: the field itself, or the flattened value object's member. */ + readonly named: MetaData; + /** Set for a flattened member: the embedding field.object. */ + readonly embedding?: MetaData; +} + +function physicalColumnsOf(field: MetaData): PhysicalColumn[] { + // ADR-0039: resolving — @storage and @objectRef may be inherited via extends. + if (field.subType === FIELD_SUBTYPE_OBJECT && field.attr(FIELD_ATTR_STORAGE) === STORAGE_FLATTENED) { + const target = refTarget(field, FIELD_ATTR_OBJECT_REF); + if (target === undefined || !isMetaObject(target)) return []; + // Every member, as migrate's flattenObjectField expands it. + return target.fields().map((member) => ({ + column: resolveFlattenedColumnName(field, member), + label: `${field.name}.${member.name}`, + named: member, + embedding: field, + })); + } + return [{ column: resolveColumnName(field), label: field.name, named: field }]; +} + +function collisionHint(a: PhysicalColumn, b: PhysicalColumn): string { + if (a.embedding !== undefined || b.embedding !== undefined) { + return "Give one flattened field a different @flattenedColumnPrefix, or rename one field."; + } + return hasExplicitColumn(a.named) && hasExplicitColumn(b.named) + ? "Rename one field, or point one @column at a different column." + : "Rename one field, or give one an explicit @column so the two columns differ."; +} + export function validateColumnNameCollisions(root: MetaData): ParseError[] { const errors: ParseError[] = []; for (const obj of root.children().filter((c) => c.type === TYPE_OBJECT)) { if (obj.isAbstract) continue; if (!obj.children().some((c) => c.type === TYPE_SOURCE)) continue; if (readsThroughSqlView(obj)) continue; - const seen = new Map(); + const seen = new Map(); for (const field of obj.children()) { - if (!isMetaField(field) || field.subType === FIELD_SUBTYPE_OBJECT || field.isDerived()) continue; - const column = resolveColumnName(field); - const first = seen.get(column); - if (first === undefined) { - seen.set(column, field); - continue; + if (!isMetaField(field) || field.isDerived()) continue; + for (const col of physicalColumnsOf(field)) { + const first = seen.get(col.column); + if (first === undefined) { + seen.set(col.column, col); + continue; + } + errors.push( + new ParseError( + `fields "${first.label}" and "${col.label}" on "${obj.name}" both resolve to physical column ` + + `"${col.column}". ` + + collisionHint(first, col), + { code: "ERR_COLUMN_NAME_COLLISION", source: field.source }, + ), + ); } - errors.push( - new ParseError( - `fields "${first.name}" and "${field.name}" on "${obj.name}" both resolve to physical column ` + - `"${column}". ` + - (hasExplicitColumn(first) && hasExplicitColumn(field) - ? "Rename one field, or point one @column at a different column." - : "Rename one field, or give one an explicit @column so the two columns differ."), - { code: "ERR_COLUMN_NAME_COLLISION", source: field.source }, - ), - ); } } return errors; diff --git a/server/typescript/packages/metadata/src/naming.ts b/server/typescript/packages/metadata/src/naming.ts index 794335465f..6e1f7c2e25 100644 --- a/server/typescript/packages/metadata/src/naming.ts +++ b/server/typescript/packages/metadata/src/naming.ts @@ -2,7 +2,7 @@ import { docsBlobUrl } from "./docs-ref.js"; import type { MetaData } from "./shared/meta-data.js"; import { TYPE_FIELD } from "./shared/base-types.js"; import { PACKAGE_SEPARATOR } from "./shared/structural.js"; -import { FIELD_ATTR_COLUMN } from "./persistence/db/db-constants.js"; +import { FIELD_ATTR_COLUMN, FIELD_ATTR_FLATTENED_COLUMN_PREFIX } from "./persistence/db/db-constants.js"; import { SOURCE_ATTR_SCHEMA, SOURCE_ROLE_PRIMARY, @@ -53,6 +53,22 @@ export function toKebabCase(s: string): string { return toSnakeCase(s).replace(/_/g, "-"); } +/** + * The `upperFirst` column rule: the first letter upper-cased, nothing else changed + * (`createdAt` → `CreatedAt`, `requestURL` → `RequestURL`, `address_line1` → + * `Address_line1`). Not a word split: every other character keeps the case it was written + * in, so a model maps onto a schema whose columns are its field names with a capital first + * letter (EF Core's default, for one) with no `@column`. + */ +export function toUpperFirstColumn(s: string): string { + // One UTF-16 unit in, one out — as C#'s char.ToUpperInvariant and Java's + // Character.toUpperCase do — so `ß` (whose JavaScript upper case is "SS") stays `ß` in + // every port. + const first = s.charAt(0); + const upper = first.toUpperCase(); + return (upper.length === 1 ? upper : first) + s.slice(1); +} + /** * How a field with no `@column` becomes a column name. One project setting, * `naming.columns` in `.metaobjects/config.json`, read by every port (ADR-0058): the @@ -60,10 +76,10 @@ export function toKebabCase(s: string): string { * and verify resolve the same name. Not a metadata attr: `@column` is the per-field * override, this is the rule for the rest. */ -export type ColumnNamingStrategy = "snake_case" | "literal" | "kebab-case"; +export type ColumnNamingStrategy = "snake_case" | "literal" | "kebab-case" | "upperFirst"; /** Every column-naming rule, in the order the conformance corpus lists them. */ -export const COLUMN_NAMING_STRATEGIES: readonly ColumnNamingStrategy[] = ["snake_case", "literal", "kebab-case"]; +export const COLUMN_NAMING_STRATEGIES: readonly ColumnNamingStrategy[] = ["snake_case", "literal", "kebab-case", "upperFirst"]; /** The default in EVERY port: what `meta migrate` has always created. */ export const DEFAULT_COLUMN_NAMING_STRATEGY: ColumnNamingStrategy = "snake_case"; @@ -134,6 +150,7 @@ export function applyColumnNamingStrategy(name: string, strategy: ColumnNamingSt case "literal": return name; case "kebab-case": return toKebabCase(name); case "snake_case": return toSnakeCase(name); + case "upperFirst": return toUpperFirstColumn(name); } } @@ -316,6 +333,40 @@ export function resolveColumnName( return applyColumnNamingStrategy(field.name, strategy); } +/** + * The text before each member's column when `field` (a `field.object @storage "flattened"`) + * expands its value object into the owning table: its `@flattenedColumnPrefix` verbatim (`""` allowed), + * else its own resolved column and `_`. With `@column: "bill"` the members are + * `bill_`; under the `upperFirst` rule an `address` field gives `Address_`, + * which is EF Core's owned-type default. + * + * Core naming, not migrate's: generated ORM mappings (the Prisma schema, the C# EF mapping, + * the Kotlin Exposed tables) address these columns too, and must spell them exactly as the + * migration creates them. + * ADR-0039: resolving — `@flattenedColumnPrefix` and `@column` may be inherited through `extends`. + */ +export function resolveFlattenedColumnPrefix( + field: MetaData, + strategy: ColumnNamingStrategy = physicalNamingOf(field).columns, +): string { + const prefix = field.attr(FIELD_ATTR_FLATTENED_COLUMN_PREFIX); + if (typeof prefix === "string") return prefix; + return resolveColumnName(field, strategy) + "_"; +} + +/** + * The column a member of a flattened value object gets in the owning table: the embedding + * field's {@link resolveFlattenedColumnPrefix} followed by the member's own resolved column + * (its `@column`, else its name under the rule). + */ +export function resolveFlattenedColumnName( + field: MetaData, + member: MetaData, + strategy: ColumnNamingStrategy = physicalNamingOf(field).columns, +): string { + return resolveFlattenedColumnPrefix(field, strategy) + resolveColumnName(member, strategy); +} + /** * An index's DATABASE name — for an `identity.secondary` (a unique alternate key) or an * `index.lookup` (a non-unique retrieval index). diff --git a/server/typescript/packages/metadata/src/parser-core.ts b/server/typescript/packages/metadata/src/parser-core.ts index fc3798e939..fa9af783cc 100644 --- a/server/typescript/packages/metadata/src/parser-core.ts +++ b/server/typescript/packages/metadata/src/parser-core.ts @@ -65,6 +65,7 @@ import { PACKAGE_SEPARATOR, } from "./shared/structural.js"; import { ATTR_SUBTYPE_PROPERTIES, ATTR_SUBTYPE_STRINGARRAY } from "./core/attr/attr-constants.js"; +import { FIELD_ATTR_FLATTENED_COLUMN_PREFIX } from "./persistence/db/db-constants.js"; import { attrClassFor } from "./attr-class-map.js"; import type { AttrValue } from "./shared/meta-data.js"; @@ -1041,6 +1042,9 @@ function parseNodeInto( } } +/** Attrs whose empty string is a meaningful value rather than "not set" (FR5c). */ +const EMPTY_STRING_IS_A_VALUE: ReadonlySet = new Set([FIELD_ATTR_FLATTENED_COLUMN_PREFIX]); + /** FR5c — for every @-prefixed inline attr in `nodeData`, check whether the * target already declares the same attr (in `preAttrs`) with a different * non-empty value. If so, emit ERR_MERGE_CONFLICT with a `format: "merged"` @@ -1060,9 +1064,11 @@ function detectAttrMergeConflicts( const existingFiles = "files" in target.source ? [...target.source.files] : []; - function isEmptyValue(v: unknown): boolean { + function isEmptyValue(v: unknown, attrName: string): boolean { if (v === undefined || v === null) return true; - if (typeof v === "string" && v === "") return true; + // `@flattenedColumnPrefix: ""` is a value (keep the members' own columns), not "unset", + // so `""` in one file and `"home_"` in another is a conflict, not last-loaded-wins. + if (typeof v === "string" && v === "") return !EMPTY_STRING_IS_A_VALUE.has(attrName); if (Array.isArray(v) && v.length === 0) return true; return false; } @@ -1112,7 +1118,7 @@ function detectAttrMergeConflicts( const existingVal = preAttrs.get(attrName); if (existingVal === undefined) continue; - if (isEmptyValue(newValRaw) || isEmptyValue(existingVal)) continue; + if (isEmptyValue(newValRaw, attrName) || isEmptyValue(existingVal, attrName)) continue; // Both sides set a non-empty value — compare. If equal, last-writer-wins // is a no-op; if different, that's a conflict. if (attrValuesEqual(existingVal, newValRaw as AttrValue)) continue; diff --git a/server/typescript/packages/metadata/src/persistence/db/db-constants.ts b/server/typescript/packages/metadata/src/persistence/db/db-constants.ts index 1f2563f5ba..3ec3dfcdf5 100644 --- a/server/typescript/packages/metadata/src/persistence/db/db-constants.ts +++ b/server/typescript/packages/metadata/src/persistence/db/db-constants.ts @@ -6,6 +6,12 @@ import { /** Column name override on a field (maps to @column in metadata). */ export const FIELD_ATTR_COLUMN = "column"; +/** + * `@flattenedColumnPrefix` on a `field.object @storage "flattened"`: the text before each member's + * column, verbatim (`""` allowed). Absent → the field's resolved column and `_` + * ({@link resolveFlattenedColumnPrefix} in naming.ts). + */ +export const FIELD_ATTR_FLATTENED_COLUMN_PREFIX = "flattenedColumnPrefix"; /** When true, suppress the @filterable-without-index Loader warning (Project D drift check). */ export const FIELD_ATTR_DB_INDEXED = "db.indexed"; @@ -24,6 +30,10 @@ export const IDENTITY_ATTR_EXPR = "expr"; export const IDENTITY_ATTR_USING = "using"; /** identity.reference: physical FK constraint-name override. Absent → the auto-derived `
__fk`. */ export const IDENTITY_ATTR_CONSTRAINT_NAME = "constraintName"; +/** identity.primary (@generation "increment"): the sequence's first value. Absent → 1. */ +export const IDENTITY_ATTR_SEQUENCE_START = "sequenceStart"; +/** identity.primary (@generation "increment"): the sequence's physical name. Absent → the database's default. */ +export const IDENTITY_ATTR_SEQUENCE_NAME = "sequenceName"; /** * R6 Plan 2b: `@dbColumnType` — physical DB column-type override on a field. diff --git a/server/typescript/packages/metadata/src/persistence/db/db-definition.embedded.ts b/server/typescript/packages/metadata/src/persistence/db/db-definition.embedded.ts index 2b6f014b24..91d4fe284f 100644 --- a/server/typescript/packages/metadata/src/persistence/db/db-definition.embedded.ts +++ b/server/typescript/packages/metadata/src/persistence/db/db-definition.embedded.ts @@ -61,6 +61,14 @@ export const DB_DEFINITION: ProviderDefinition = { "type": "field", "subType": "object", "children": [ + { + "type": "attr", + "subType": "string", + "name": "flattenedColumnPrefix", + "min": 0, + "max": 1, + "description": "On a field.object with @storage \"flattened\": the text put before each member's column, verbatim (\"\" allowed, so the members keep their own column names). Absent: this field's resolved column name and \"_\" (with @column \"bill\", the members are bill_). A fact about the owning table, so it lives on the embedding field, never on the value object. Refused on any other storage (ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE). Justified in ADR-0023 Amendment 1." + }, { "type": "attr", "subType": "string", @@ -246,6 +254,65 @@ export const DB_DEFINITION: ProviderDefinition = { } ] }, + { + "type": "field", + "subType": "enum", + "children": [ + { + "type": "attr", + "subType": "string", + "name": "constraintName", + "min": 0, + "max": 1, + "description": "The database name of the CHECK constraint this enum's member list generates. Absent:
__chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." + } + ] + }, + { + "type": "validator", + "subType": [ + "numeric", + "length", + "regex", + "comparison", + "requiredWhen", + "presentIff", + "atLeastOne", + "exactlyOne" + ], + "children": [ + { + "type": "attr", + "subType": "string", + "name": "constraintName", + "min": 0, + "max": 1, + "description": "The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." + } + ] + }, + { + "type": "identity", + "subType": "primary", + "children": [ + { + "type": "attr", + "subType": "long", + "name": "sequenceStart", + "min": 0, + "max": 1, + "description": "The first value an @generation \"increment\" key's sequence hands out (Postgres: GENERATED BY DEFAULT AS IDENTITY (START WITH n)). Absent: 1. Lets a model reproduce a table that keeps a low id range for seeded rows. Refused without @generation \"increment\" (ERR_SEQUENCE_WITHOUT_INCREMENT). RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." + }, + { + "type": "attr", + "subType": "string", + "name": "sequenceName", + "min": 0, + "max": 1, + "description": "The physical name of an @generation \"increment\" key's sequence (Postgres: GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME ...)). Absent: the database's own default,
__seq on Postgres. Lets a model adopt a sequence that kept its name across a table rename. Refused without @generation \"increment\" (ERR_SEQUENCE_WITHOUT_INCREMENT). RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." + } + ] + }, { "type": "identity", "subType": "reference", diff --git a/server/typescript/packages/metadata/src/registry-manifest.ts b/server/typescript/packages/metadata/src/registry-manifest.ts index 3f1acdfe96..4fa5345bce 100644 --- a/server/typescript/packages/metadata/src/registry-manifest.ts +++ b/server/typescript/packages/metadata/src/registry-manifest.ts @@ -112,7 +112,7 @@ export interface ManifestType { * constant read `"0.10"`). Bump with that script — never by hand — so the manifest and * all four port constants move together. */ -export const METAMODEL_VERSION = "1.2"; +export const METAMODEL_VERSION = "1.3"; /** The full canonical manifest. All collections are sorted for byte-stability. */ interface RegistryManifest { diff --git a/server/typescript/packages/metadata/src/shared/meta-root.ts b/server/typescript/packages/metadata/src/shared/meta-root.ts index 3fd83218a8..feaabdcfe6 100644 --- a/server/typescript/packages/metadata/src/shared/meta-root.ts +++ b/server/typescript/packages/metadata/src/shared/meta-root.ts @@ -9,6 +9,7 @@ import type { MetaObject } from "../core/object/meta-object.js"; import type { MetaField } from "../core/field/meta-field.js"; import { DEFAULT_PHYSICAL_NAMING, type PhysicalNaming } from "../naming.js"; import { DEFAULT_WIRE_FORMAT, type WireFormat } from "../wire.js"; +import { DEFAULT_ENUM_DEFAULTS, type EnumDefaults } from "../enum-length.js"; import { PACKAGE_SEPARATOR } from "./structural.js"; import { lookupCanonical } from "../naming-refs.js"; @@ -26,6 +27,12 @@ export class MetaRoot extends MetaData { */ wireFormat: WireFormat = DEFAULT_WIRE_FORMAT; + /** + * The project's enum storage defaults (`enums` in `.metaobjects/config.json`), set by the + * project-aware load beside `physicalNaming` and read through `enumDefaultsOf`. + */ + enumDefaults: EnumDefaults = DEFAULT_ENUM_DEFAULTS; + /** Object entities defined at this root level. */ objects(): MetaObject[] { return this.cached("objects", () => diff --git a/server/typescript/packages/metadata/src/wire.ts b/server/typescript/packages/metadata/src/wire.ts index 1b7d8bbdac..d107f39ba7 100644 --- a/server/typescript/packages/metadata/src/wire.ts +++ b/server/typescript/packages/metadata/src/wire.ts @@ -15,25 +15,39 @@ export type WireDecimals = (typeof WIRE_DECIMALS)[number]; export const WIRE_NULL_MEMBERS = ["native", "emit", "omit"] as const; export type WireNullMembers = (typeof WIRE_NULL_MEMBERS)[number]; +/** + * `wire.durations`: how a `field.duration` is written and read. `native` = the language's own + * serializer for its duration type (System.Text.Json's `01:30:00` for a C# `TimeSpan`); `iso` = + * canonical ISO-8601 (`PT1H30M`) in every port. TypeScript has no duration type, so it writes + * ISO-8601 either way. + */ +export const WIRE_DURATIONS = ["native", "iso"] as const; +export type WireDurations = (typeof WIRE_DURATIONS)[number]; + /** The keys of the `wire` block. */ export const WIRE_KEY_DECIMALS = "decimals"; export const WIRE_KEY_NULL_MEMBERS = "nullMembers"; +export const WIRE_KEY_DURATIONS = "durations"; export const WIRE_DECIMALS_NATIVE: WireDecimals = "native"; export const WIRE_DECIMALS_STRING: WireDecimals = "string"; export const WIRE_NULL_MEMBERS_NATIVE: WireNullMembers = "native"; export const WIRE_NULL_MEMBERS_EMIT: WireNullMembers = "emit"; export const WIRE_NULL_MEMBERS_OMIT: WireNullMembers = "omit"; +export const WIRE_DURATIONS_NATIVE: WireDurations = "native"; +export const WIRE_DURATIONS_ISO: WireDurations = "iso"; export interface WireFormat { readonly decimals: WireDecimals; readonly nullMembers: WireNullMembers; + readonly durations: WireDurations; } /** The default in every port: each language's own standard output. */ export const DEFAULT_WIRE_FORMAT: WireFormat = Object.freeze({ decimals: WIRE_DECIMALS_NATIVE, nullMembers: WIRE_NULL_MEMBERS_NATIVE, + durations: WIRE_DURATIONS_NATIVE, }); /** The wire format for the tree `node` belongs to: what its root carries, else the default. */ diff --git a/server/typescript/packages/metadata/test/enum-max-length.test.ts b/server/typescript/packages/metadata/test/enum-max-length.test.ts new file mode 100644 index 0000000000..0145c4dd2e --- /dev/null +++ b/server/typescript/packages/metadata/test/enum-max-length.test.ts @@ -0,0 +1,70 @@ +// A string-stored field.enum's column length: its @maxLength, else the project's +// enums.maxLength (carried on the loaded root, like the naming rules), else none (an +// unbounded text column, as before). Every member must fit the length that applies; the +// loader checks an explicit @maxLength, and the resolver refuses a project default that a +// member overflows, so every consumer that sizes the column refuses the same model. + +import { describe, expect, test } from "bun:test"; +import { + MetaDataLoader, + InMemoryStringSource, + MetaModelError, + resolveEnumMaxLength, + type MetaField, + type MetaObject, + type MetaRoot, +} from "../src/index.js"; + +async function load(statusDecl: Record): Promise { + const doc = { "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Order", children: [ + { "source.rdb": {} }, + { "field.long": { name: "id" } }, + { "field.enum": { name: "status", ...statusDecl } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + ] } }, + ] } }; + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(doc))]); + expect(errors).toEqual([]); + return root as MetaRoot; +} + +const status = (root: MetaRoot): MetaField => + (root.findObject("Order") as MetaObject).fields().find((f) => f.name === "status")!; + +const VALUES = ["DRAFT", "PUBLISHED", "ARCHIVED"]; + +describe("resolveEnumMaxLength", () => { + test("@maxLength is the column length", async () => { + expect(resolveEnumMaxLength(status(await load({ "@values": VALUES, "@maxLength": 20 })))).toBe(20); + }); + + test("no @maxLength and no project default: none", async () => { + expect(resolveEnumMaxLength(status(await load({ "@values": VALUES })))).toBeUndefined(); + }); + + test("the project default applies when the field declares none", async () => { + const root = await load({ "@values": VALUES }); + root.enumDefaults = { maxLength: 32 }; + expect(resolveEnumMaxLength(status(root))).toBe(32); + }); + + test("@maxLength beats the project default, either way round", async () => { + const root = await load({ "@values": VALUES, "@maxLength": 9 }); + root.enumDefaults = { maxLength: 32 }; + expect(resolveEnumMaxLength(status(root))).toBe(9); + }); + + test("an @intValueMap enum has no length, whatever the default", async () => { + const root = await load({ "@values": VALUES, "@intValueMap": { DRAFT: 0, PUBLISHED: 1, ARCHIVED: 2 } }); + root.enumDefaults = { maxLength: 4 }; + expect(resolveEnumMaxLength(status(root))).toBeUndefined(); + }); + + test("a member longer than the project default is refused, naming the field and the member", async () => { + const root = await load({ "@values": ["DRAFT", "AWAITING_REVIEW"] }); + root.enumDefaults = { maxLength: 9 }; + expect(() => resolveEnumMaxLength(status(root))).toThrow(MetaModelError); + expect(() => resolveEnumMaxLength(status(root))).toThrow(/"Order\.status".*"AWAITING_REVIEW".*enums\.maxLength 9/); + }); +}); diff --git a/server/typescript/packages/metadata/test/field-definition-completeness.test.ts b/server/typescript/packages/metadata/test/field-definition-completeness.test.ts index 3c6a86f7fa..7d95baeaaf 100644 --- a/server/typescript/packages/metadata/test/field-definition-completeness.test.ts +++ b/server/typescript/packages/metadata/test/field-definition-completeness.test.ts @@ -12,7 +12,7 @@ // decimal → @precision, @scale // object → @objectRef // currency → @currency -// enum → @values, @provided, @intValueMap +// enum → @values, @provided, @intValueMap, @maxLength // int/long/double/float/boolean/date/time/timestamp/uuid → none // - the "any attr" wildcard is gone; a misplaced attr is now ERR_UNKNOWN_ATTR. // @@ -104,11 +104,19 @@ const ENUM_CORE_EXTRA: Record = { values: { valueType: "string", required: true }, provided: { valueType: "boolean", required: false }, intValueMap: { valueType: "intMap", required: false }, + // The column length of a string-stored enum (refused beside @intValueMap at load). + maxLength: { valueType: "int", required: false }, +}; + +// db provider: the name of the CHECK an enum's member list generates. +const ENUM_DB_EXTRA: Record = { + constraintName: { valueType: "string", required: false }, }; /** Scoped concern attrs (db / prompt). */ const STORAGE_EXTRA: Record = { storage: { valueType: "string", required: false }, + flattenedColumnPrefix: { valueType: "string", required: false }, }; const AUTO_SET_EXTRA: Record = { autoSet: { valueType: "string", required: false }, @@ -154,6 +162,8 @@ const EXPECTED_DATA_TYPE: Record = { // ADR-0036/0037 Wave 3 — uri/inet bind to TS string. uri: "string", inet: "string", + // a fixed-length duration binds to TS string (an ISO-8601 duration). + duration: "string", }; function expectedAttrsFor(subType: string): Record { @@ -163,7 +173,7 @@ function expectedAttrsFor(subType: string): Record { if (subType === FIELD_SUBTYPE_OBJECT) Object.assign(exp, OBJECT_CORE_EXTRA, STORAGE_EXTRA); if (subType === FIELD_SUBTYPE_MAP) Object.assign(exp, MAP_EXTRA); if (subType === FIELD_SUBTYPE_CURRENCY) Object.assign(exp, CURRENCY_EXTRA); - if (subType === FIELD_SUBTYPE_ENUM) Object.assign(exp, ENUM_CORE_EXTRA, ENUM_PROMPT_EXTRA); + if (subType === FIELD_SUBTYPE_ENUM) Object.assign(exp, ENUM_CORE_EXTRA, ENUM_PROMPT_EXTRA, ENUM_DB_EXTRA); if (TEMPORAL.has(subType)) Object.assign(exp, AUTO_SET_EXTRA); if (subType === FIELD_SUBTYPE_TIMESTAMP) Object.assign(exp, LOCAL_TIME_EXTRA); if (subType === "uri" || subType === "inet") Object.assign(exp, LENIENT_EXTRA); @@ -171,7 +181,7 @@ function expectedAttrsFor(subType: string): Record { } describe("field provider externalization — strict per-subtype completeness", () => { - test("registers all 18 field subtypes", () => { + test("registers all 19 field subtypes", () => { const registered = registry.allSubTypesOf(TYPE_FIELD).sort(); expect(registered).toEqual([...FIELD_SUBTYPES].sort()); }); diff --git a/server/typescript/packages/metadata/test/naming.test.ts b/server/typescript/packages/metadata/test/naming.test.ts index 73bda32885..565b772afd 100644 --- a/server/typescript/packages/metadata/test/naming.test.ts +++ b/server/typescript/packages/metadata/test/naming.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "bun:test"; +import { toUpperFirstColumn } from "../src/naming.js"; import { readFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; @@ -371,3 +372,17 @@ describe("fixtures/naming-conformance/already-plural-pluralize.json", () => { }, ); }); + +describe("toUpperFirstColumn", () => { + it("upper-cases the first letter only", () => { + expect(toUpperFirstColumn("requestURL")).toBe("RequestURL"); + expect(toUpperFirstColumn("")).toBe(""); + }); + + // C#'s char.ToUpperInvariant and Java's Character.toUpperCase map one character to one + // character, so `ß` stays `ß` there; JavaScript's toUpperCase gives "SS". Every port keeps a + // first character whose upper case is not a single character, so all five name it alike. + it("keeps a first character whose upper case is not one character", () => { + expect(toUpperFirstColumn("ßeta")).toBe("ßeta"); + }); +}); 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 0e2c247254..dd339e1ff8 100644 --- a/server/typescript/packages/metadata/test/validator-definition-completeness.test.ts +++ b/server/typescript/packages/metadata/test/validator-definition-completeness.test.ts @@ -41,19 +41,27 @@ const EXPECTED: Record/`); own files are absent and keep the * default `basename(path)`. Threaded to `loadMemory` so every node an artifact @@ -272,6 +282,9 @@ export async function resolveCollection( let libraries: readonly string[] = []; let physicalNaming: PhysicalNaming = DEFAULT_PHYSICAL_NAMING; let wireFormat: WireFormat = DEFAULT_WIRE_FORMAT; + let enumDefaults: EnumDefaults = DEFAULT_ENUM_DEFAULTS; + let schemaConventions: string | undefined; + let skipRedundantChecks = false; if (hasConfig) { // No try/catch here: a config.json that EXISTS but fails to load @@ -288,6 +301,9 @@ export async function resolveCollection( libraries = cfg.libraries; physicalNaming = physicalNamingFromConfig(cfg); wireFormat = wireFormatFromConfig(cfg); + enumDefaults = enumDefaultsFromConfig(cfg); + schemaConventions = cfg.migrate?.conventions; + skipRedundantChecks = cfg.migrate?.checks?.skipRedundant === true; // An unknown token is a hard config error naming the valid ones, while // `librarySources` keeps skipping one silently for a programmatic caller. The two // are deliberately different: an API caller asking for a library this version does @@ -402,6 +418,9 @@ export async function resolveCollection( libraries, physicalNaming, wireFormat, + enumDefaults, + schemaConventions, + skipRedundantChecks, fileIds: new Map(dependencies.map((d) => [d.artifactPath, d.sourceId])), importedPackages: [...importedPackages].sort(), importedNodes, diff --git a/server/typescript/packages/sdk/src/config.ts b/server/typescript/packages/sdk/src/config.ts index ae2e385592..714862c6dd 100644 --- a/server/typescript/packages/sdk/src/config.ts +++ b/server/typescript/packages/sdk/src/config.ts @@ -8,10 +8,15 @@ import { DEFAULT_PHYSICAL_NAMING, TABLE_NAMING_RULES, DEFAULT_WIRE_FORMAT, + DEFAULT_ENUM_DEFAULTS, + type EnumDefaults, WIRE_DECIMALS, WIRE_NULL_MEMBERS, + WIRE_DURATIONS, WIRE_KEY_DECIMALS, WIRE_KEY_NULL_MEMBERS, + WIRE_KEY_DURATIONS, + type WireDurations, type WireDecimals, type WireFormat, type WireNullMembers, @@ -81,6 +86,14 @@ const MigrateBlock = z.object({ * Include-only — there's no `migrate.scope.exclude`, since a migration run * is scoped to what it's touching, not filtered down from "everything". */ scope: z.array(z.string().min(1)), + /** Schema conventions for the database objects no code addresses: a preset name + * (`default`, `efcore`); meta migrate refuses a name it does not know. A project's own + * conventions object is supplied from metaobjects.config.ts instead. */ + conventions: z.string().min(1), + /** Which validator CHECKs the expected schema carries. `skipRedundant` (default false) leaves + * out a CHECK the column type already enforces, for a database whose CHECKs were written by + * hand only where the type does not; a CHECK with a declared `@constraintName` is kept. */ + checks: z.object({ skipRedundant: z.boolean() }).partial().strict(), // .strict() for the same reason as the top level and the source arms below, and // with sharper teeth here: `{ migrate: { scopee: [...], dialect: "postgres" } }` // used to parse to `{ dialect: "postgres" }`, so a typo'd `scope` key meant @@ -214,7 +227,9 @@ export const ConfigSchema = z.object({ * (default; the string the in-process type holds) or `string` (canonical W1 string). * `nullMembers`: `native` (default; as stored), `emit` (every declared member, `null` when * unset) or `omit` (drop null members) for a value object's members. Input rules do not - * follow it. Absent means the default for both. + * follow those two. `durations`: `native` (default; the language's own serializer for its + * duration type, both ways) or `iso` (canonical ISO-8601 in every port, both ways). Absent + * means the default for each. */ wire: z .strictObject({ @@ -224,12 +239,26 @@ export const ConfigSchema = z.object({ nullMembers: z.enum(WIRE_NULL_MEMBERS as unknown as [WireNullMembers, ...WireNullMembers[]], { message: `"wire.nullMembers" must be one of ${WIRE_NULL_MEMBERS.map((v) => `"${v}"`).join(", ")}`, }).optional(), + durations: z.enum(WIRE_DURATIONS as unknown as [WireDurations, ...WireDurations[]], { + message: `"wire.durations" must be one of ${WIRE_DURATIONS.map((v) => `"${v}"`).join(", ")}`, + }).optional(), }, { error: (iss) => iss.code === "unrecognized_keys" - ? `"wire" has an unknown key "${iss.keys[0]}" (expected "${WIRE_KEY_DECIMALS}" and/or "${WIRE_KEY_NULL_MEMBERS}")` + ? `"wire" has an unknown key "${iss.keys[0]}" (expected "${WIRE_KEY_DECIMALS}", "${WIRE_KEY_NULL_MEMBERS}" and/or "${WIRE_KEY_DURATIONS}")` : iss.code === "invalid_type" ? `"wire" must be an object` : undefined, }) .optional(), + /** + * How a string-stored `field.enum` is stored. `maxLength` is the column length (VARCHAR(n)) + * of an enum that declares no `@maxLength` of its own; every member must fit. Absent means + * no default: such an enum is an unbounded text column, as before. An `@intValueMap` enum is + * an integer column and takes no length. Read by every port's config reader. + */ + enums: z + .strictObject({ + maxLength: z.number().int().positive().optional(), + }) + .optional(), /** * C#-scoped settings. `outDir` is where `dotnet meta gen` writes and a bare `dotnet meta * verify` diffs, relative to the directory holding `.metaobjects/`; `namespace` is the C# @@ -262,11 +291,17 @@ export function physicalNamingFromConfig(cfg: Pick): PhysicalN }; } +/** The enum storage defaults a config declares (`enums`); none when absent. */ +export function enumDefaultsFromConfig(cfg: Pick): EnumDefaults { + return cfg.enums?.maxLength === undefined ? DEFAULT_ENUM_DEFAULTS : { maxLength: cfg.enums.maxLength }; +} + /** The wire format a config declares, each absent key at its default (ADR-0019 Amendment 2). */ export function wireFormatFromConfig(cfg: Pick): WireFormat { return { decimals: cfg.wire?.decimals ?? DEFAULT_WIRE_FORMAT.decimals, nullMembers: cfg.wire?.nullMembers ?? DEFAULT_WIRE_FORMAT.nullMembers, + durations: cfg.wire?.durations ?? DEFAULT_WIRE_FORMAT.durations, }; } diff --git a/server/typescript/packages/sdk/src/index.ts b/server/typescript/packages/sdk/src/index.ts index 6a8373cd30..fdd0ea82cd 100644 --- a/server/typescript/packages/sdk/src/index.ts +++ b/server/typescript/packages/sdk/src/index.ts @@ -16,7 +16,7 @@ // shows `resolveCollection`, the documented single authority for locating a project. // Config -export { ConfigSchema, DEFAULT_CONFIG, loadConfig, setConfigWarningSink, splitUnknownTopLevelKeys, CONFIG_TOP_LEVEL_KEYS, saveConfig, AllowTokenEnum, physicalNamingFromConfig, wireFormatFromConfig } from "./config.js"; +export { ConfigSchema, DEFAULT_CONFIG, loadConfig, setConfigWarningSink, splitUnknownTopLevelKeys, CONFIG_TOP_LEVEL_KEYS, saveConfig, AllowTokenEnum, physicalNamingFromConfig, wireFormatFromConfig, enumDefaultsFromConfig } from "./config.js"; export type { Config } from "./config.js"; // Metadata dependencies (FR-023) — the `dependencies` config key's schema, diff --git a/server/typescript/packages/sdk/src/memory.ts b/server/typescript/packages/sdk/src/memory.ts index 8facb118ed..32155f6ab5 100644 --- a/server/typescript/packages/sdk/src/memory.ts +++ b/server/typescript/packages/sdk/src/memory.ts @@ -15,6 +15,7 @@ import { type MetaRoot, type PhysicalNaming, type WireFormat, + type EnumDefaults, } from "@metaobjectsdev/metadata"; import { FileSource } from "@metaobjectsdev/metadata/core"; // FR-043 — a node-only subpath (it reaches the filesystem), which is why it is imported @@ -114,6 +115,8 @@ export interface LoadMemoryOptions { physicalNaming?: PhysicalNaming; /** The project wire format; defaults to the collection's (`resolveCollection(...).wireFormat`) when `files` is not passed. */ wireFormat?: WireFormat; + /** The project's enum storage defaults; defaults to the collection's (`resolveCollection(...).enumDefaults`) when `files` is not passed. */ + enumDefaults?: EnumDefaults; } /** Default provider bundle threaded by {@link loadMemory} when no options @@ -169,7 +172,7 @@ export async function loadMemory( repoRoot: string, options?: LoadMemoryOptions, ): Promise { - const { result, importedPackages, importedNodes, libraries, physicalNaming, wireFormat } = + const { result, importedPackages, importedNodes, libraries, physicalNaming, wireFormat, enumDefaults } = await loadCollectionRaw(repoRoot, options, options?.strict === true); if (result.errors.length > 0) { @@ -190,6 +193,7 @@ export async function loadMemory( if (physicalNaming !== undefined) result.root.physicalNaming = physicalNaming; if (wireFormat !== undefined) result.root.wireFormat = wireFormat; + if (enumDefaults !== undefined) result.root.enumDefaults = enumDefaults; return result.root; } @@ -264,6 +268,7 @@ async function loadCollectionRaw( libraries: readonly string[] | undefined; physicalNaming: PhysicalNaming | undefined; wireFormat: WireFormat | undefined; + enumDefaults: EnumDefaults | undefined; }> { const extra = options?.providers ?? []; let providers: readonly MetaDataTypeProvider[]; @@ -292,6 +297,7 @@ async function loadCollectionRaw( let libraries: readonly string[] | undefined = options?.libraries; let physicalNaming: PhysicalNaming | undefined = options?.physicalNaming; let wireFormat: WireFormat | undefined = options?.wireFormat; + let enumDefaults: EnumDefaults | undefined = options?.enumDefaults; if (options?.files !== undefined) { paths = [...options.files]; fileIds = options.fileIds; @@ -312,6 +318,7 @@ async function loadCollectionRaw( libraries ??= collection.libraries; physicalNaming ??= collection.physicalNaming; wireFormat ??= collection.wireFormat; + enumDefaults ??= collection.enumDefaults; } const loader = new MetaDataLoader({ @@ -337,7 +344,7 @@ async function loadCollectionRaw( return id === undefined ? new FileSource(p) : new FileSource(p, { id }); }), ]); - return { result, registry, importedPackages, importedNodes, libraries, physicalNaming, wireFormat }; + return { result, registry, importedPackages, importedNodes, libraries, physicalNaming, wireFormat, enumDefaults }; } /** Every source file that contributed to a node, across the envelope variants that diff --git a/server/typescript/packages/sdk/test/enum-length-conformance.test.ts b/server/typescript/packages/sdk/test/enum-length-conformance.test.ts new file mode 100644 index 0000000000..0e4886a713 --- /dev/null +++ b/server/typescript/packages/sdk/test/enum-length-conformance.test.ts @@ -0,0 +1,81 @@ +// The cross-port enum-length corpus, run through TypeScript's own config reader and +// resolver: the `enums` block parses with the schema `.metaobjects/config.json` is read with, +// the loaded root carries it, and every enum's column length resolves through +// resolveEnumMaxLength, the function `meta migrate` and the generated bindings call. + +import { describe, expect, test } from "bun:test"; +import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { FileSource } from "@metaobjectsdev/metadata/core"; +import { MetaDataLoader, MetaModelError, isMetaRoot, resolveEnumMaxLength, type MetaObject, type MetaRoot } from "@metaobjectsdev/metadata"; +import { ConfigSchema, enumDefaultsFromConfig } from "../src/config.js"; +import { loadMemory } from "../src/memory.js"; + +const CORPUS = resolve(import.meta.dir, "../../../../../fixtures/enum-length-conformance"); + +interface Case { name: string; enums: unknown; lengths: Record } +interface Overflow { name: string; enums: unknown; field: string; member: string } +const corpus = JSON.parse(readFileSync(join(CORPUS, "cases.json"), "utf8")) as { + cases: Case[]; + overflow: Overflow[]; + invalid: { name: string; enums: unknown }[]; +}; + +const configFor = (enums: unknown): Record => + enums === null ? { schema_version: 1 } : { schema_version: 1, enums }; + +async function loadCorpusModel(enums: unknown): Promise { + const result = await new MetaDataLoader().load([new FileSource(join(CORPUS, "meta.enums.json"))]); + expect(result.errors).toEqual([]); + if (!isMetaRoot(result.root)) throw new Error("not a root"); + result.root.enumDefaults = enumDefaultsFromConfig(ConfigSchema.parse(configFor(enums))); + return result.root; +} + +const ticketField = (root: MetaRoot, name: string) => + (root.findObject("Ticket") as MetaObject).fields().find((f) => f.name === name)!; + +describe("enum-length conformance", () => { + for (const c of corpus.cases) { + test(`${c.name}: every enum's column length`, async () => { + const root = await loadCorpusModel(c.enums); + const actual: Record = {}; + for (const name of Object.keys(c.lengths)) actual[name] = resolveEnumMaxLength(ticketField(root, name)) ?? null; + expect(actual).toEqual(c.lengths); + }); + } + + for (const o of corpus.overflow) { + test(`${o.name}: refused, naming the member`, async () => { + const root = await loadCorpusModel(o.enums); + let caught: unknown; + try { resolveEnumMaxLength(ticketField(root, o.field)); } catch (e) { caught = e; } + expect(caught).toBeInstanceOf(MetaModelError); + expect((caught as MetaModelError).code).toBe("ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH"); + expect((caught as Error).message).toContain(o.member); + }); + } + + for (const c of corpus.invalid) { + test(`${c.name}: the config is refused when read`, () => { + expect(ConfigSchema.safeParse(configFor(c.enums)).success).toBe(false); + }); + } + + test("the project-aware load sets the root from .metaobjects/config.json", async () => { + const withDefault = corpus.cases.find((c) => c.enums !== null && Object.keys(c.enums as object).length > 0)!; + const dir = mkdtempSync(join(tmpdir(), "mo-enums-")); + try { + mkdirSync(join(dir, ".metaobjects")); + mkdirSync(join(dir, "metaobjects")); + writeFileSync(join(dir, ".metaobjects", "config.json"), JSON.stringify(configFor(withDefault.enums))); + copyFileSync(join(CORPUS, "meta.enums.json"), join(dir, "metaobjects", "meta.enums.json")); + const root = await loadMemory(dir); + expect(root.enumDefaults).toEqual(withDefault.enums as object); + expect(resolveEnumMaxLength(ticketField(root, "status")) ?? null).toBe(withDefault.lengths.status ?? null); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/server/typescript/packages/sdk/test/physical-naming-conformance.test.ts b/server/typescript/packages/sdk/test/physical-naming-conformance.test.ts index ad8281861f..c2afeec46f 100644 --- a/server/typescript/packages/sdk/test/physical-naming-conformance.test.ts +++ b/server/typescript/packages/sdk/test/physical-naming-conformance.test.ts @@ -11,8 +11,12 @@ import { FileSource } from "@metaobjectsdev/metadata/core"; import { MetaDataLoader, isMetaRoot, + isMetaObject, + refTarget, resolveColumnName, + resolveFlattenedColumnName, resolveTableName, + FIELD_ATTR_OBJECT_REF, type MetaRoot, } from "@metaobjectsdev/metadata"; import { ConfigSchema, physicalNamingFromConfig } from "../src/config.js"; @@ -49,9 +53,17 @@ function assertNames(root: MetaRoot, c: NamingCase): void { expect(tables).toEqual(c.tables); const columns: Record = {}; for (const key of Object.keys(c.columns)) { - const [objectName, fieldName] = key.split(".") as [string, string]; + const [objectName, fieldName, memberName] = key.split(".") as [string, string, string | undefined]; const field = root.findObject(objectName)!.fields().find((f) => f.name === fieldName)!; - columns[key] = resolveColumnName(field); + if (memberName === undefined) { + columns[key] = resolveColumnName(field); + continue; + } + // `..`: a member of the flattened value object the field embeds. + const target = refTarget(field, FIELD_ATTR_OBJECT_REF); + if (target === undefined || !isMetaObject(target)) throw new Error(`${key}: @objectRef does not resolve`); + const member = target.fields().find((f) => f.name === memberName)!; + columns[key] = resolveFlattenedColumnName(field, member); } expect(columns).toEqual(c.columns); } diff --git a/server/typescript/packages/sdk/test/schema-conventions-config.test.ts b/server/typescript/packages/sdk/test/schema-conventions-config.test.ts new file mode 100644 index 0000000000..2938f21f3e --- /dev/null +++ b/server/typescript/packages/sdk/test/schema-conventions-config.test.ts @@ -0,0 +1,80 @@ +// `migrate.conventions` and `migrate.checks` in .metaobjects/config.json: the schema +// conventions meta migrate names database-only objects with, and which validator CHECKs it +// emits. Both are migrate settings, so the resolved collection carries them and the loaded +// model does not; the CLI resolves them once and hands them to every expected-schema build. + +import { describe, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ConfigSchema, loadConfig } from "../src/config.js"; +import { resolveCollection } from "../src/collection.js"; +import { loadMemory } from "../src/memory.js"; + +const MODEL = { "metadata.root": { package: "w", children: [ + { "object.entity": { name: "Thing", children: [{ "field.string": { name: "id" } }] } }, +] } }; + +async function withProject(config: Record, fn: (dir: string) => Promise): Promise { + const dir = mkdtempSync(join(tmpdir(), "mo-conventions-")); + try { + mkdirSync(join(dir, ".metaobjects")); + mkdirSync(join(dir, "metaobjects")); + writeFileSync(join(dir, ".metaobjects", "config.json"), JSON.stringify({ schema_version: 1, ...config })); + writeFileSync(join(dir, "metaobjects", "meta.w.json"), JSON.stringify(MODEL)); + return await fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe("migrate.conventions", () => { + test("is a string in the config schema", () => { + expect(ConfigSchema.safeParse({ schema_version: 1, migrate: { conventions: "efcore" } }).success).toBe(true); + expect(ConfigSchema.safeParse({ schema_version: 1, migrate: { conventions: 3 } }).success).toBe(false); + expect(ConfigSchema.safeParse({ schema_version: 1, migrate: { conventions: "" } }).success).toBe(false); + }); + + test("the resolved collection carries it", async () => { + await withProject({ migrate: { conventions: "efcore" } }, async (dir) => { + expect((await resolveCollection(dir)).schemaConventions).toBe("efcore"); + }); + await withProject({}, async (dir) => { + expect((await resolveCollection(dir)).schemaConventions).toBeUndefined(); + }); + }); + + test("the loaded model does not: it is migrate's setting, not the model's", async () => { + await withProject({ migrate: { conventions: "efcore" } }, async (dir) => { + expect("schemaConventions" in (await loadMemory(dir))).toBe(false); + }); + }); +}); + +describe("migrate.checks", () => { + test("skipRedundant is carried by the collection, false when absent", async () => { + await withProject({ migrate: { checks: { skipRedundant: true } } }, async (dir) => { + expect((await resolveCollection(dir)).skipRedundantChecks).toBe(true); + }); + await withProject({ migrate: { checks: {} } }, async (dir) => { + expect((await resolveCollection(dir)).skipRedundantChecks).toBe(false); + }); + await withProject({}, async (dir) => { + expect((await resolveCollection(dir)).skipRedundantChecks).toBe(false); + }); + }); + + test("a bad checks block is refused, naming the key", async () => { + const refusal = (checks: unknown) => withProject({ migrate: { checks } }, async (dir) => { + try { + await loadConfig(join(dir, ".metaobjects")); + } catch (err) { + return (err as Error).message; + } + return "accepted"; + }); + expect(await refusal(true)).toMatch(/migrate\.checks: .*expected object/); + expect(await refusal({ skipRedundnat: true })).toMatch(/migrate\.checks: .*skipRedundnat/); + expect(await refusal({ skipRedundant: "yes" })).toMatch(/migrate\.checks\.skipRedundant: .*expected boolean/); + }); +}); diff --git a/server/typescript/packages/sdk/test/wire-conformance.test.ts b/server/typescript/packages/sdk/test/wire-conformance.test.ts index 366bdc7646..a25bf0f279 100644 --- a/server/typescript/packages/sdk/test/wire-conformance.test.ts +++ b/server/typescript/packages/sdk/test/wire-conformance.test.ts @@ -6,13 +6,13 @@ import { describe, expect, test } from "bun:test"; import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { DEFAULT_WIRE_FORMAT, wireFormatOf, type WireDecimals, type WireNullMembers } from "@metaobjectsdev/metadata"; +import { DEFAULT_WIRE_FORMAT, wireFormatOf, type WireDecimals, type WireDurations, type WireNullMembers } from "@metaobjectsdev/metadata"; import { ConfigSchema, wireFormatFromConfig } from "../src/config.js"; import { loadMemory } from "../src/memory.js"; const CORPUS = resolve(import.meta.dir, "../../../../../fixtures/wire-conformance/config-blocks.json"); -interface WireCase { name: string; wire: unknown; decimals: WireDecimals; nullMembers: WireNullMembers } +interface WireCase { name: string; wire: unknown; decimals: WireDecimals; nullMembers: WireNullMembers; durations: WireDurations } const corpus = JSON.parse(readFileSync(CORPUS, "utf8")) as { cases: WireCase[]; invalid: { name: string; wire: unknown }[]; @@ -29,9 +29,9 @@ const MODEL = { "metadata.root": { package: "w", children: [ describe("wire conformance (ADR-0019 Amendment 2)", () => { for (const c of corpus.cases) { - test(`${c.name}: resolves to decimals=${c.decimals} nullMembers=${c.nullMembers}`, () => { + test(`${c.name}: resolves to decimals=${c.decimals} nullMembers=${c.nullMembers} durations=${c.durations}`, () => { const wire = wireFormatFromConfig(ConfigSchema.parse(configFor(c.wire))); - expect(wire).toEqual({ decimals: c.decimals, nullMembers: c.nullMembers }); + expect(wire).toEqual({ decimals: c.decimals, nullMembers: c.nullMembers, durations: c.durations }); }); } @@ -50,7 +50,7 @@ describe("wire conformance (ADR-0019 Amendment 2)", () => { test("an unknown key and a non-object block are refused by name", () => { const unknown = ConfigSchema.safeParse(configFor({ dates: "iso" })); expect(unknown.error?.issues.map((i) => i.message)).toContain( - '"wire" has an unknown key "dates" (expected "decimals" and/or "nullMembers")'); + '"wire" has an unknown key "dates" (expected "decimals", "nullMembers" and/or "durations")'); const scalar = ConfigSchema.safeParse(configFor("string")); expect(scalar.error?.issues.map((i) => i.message)).toContain('"wire" must be an object'); }); @@ -64,8 +64,8 @@ describe("wire conformance (ADR-0019 Amendment 2)", () => { writeFileSync(join(dir, ".metaobjects", "config.json"), JSON.stringify(configFor(portable.wire))); writeFileSync(join(dir, "metaobjects", "meta.w.json"), JSON.stringify(MODEL)); const root = await loadMemory(dir); - expect(root.wireFormat).toEqual({ decimals: "string", nullMembers: "omit" }); - expect(wireFormatOf(root.findObject("Thing")!)).toEqual({ decimals: "string", nullMembers: "omit" }); + expect(root.wireFormat).toEqual({ decimals: "string", nullMembers: "omit", durations: "iso" }); + expect(wireFormatOf(root.findObject("Thing")!)).toEqual({ decimals: "string", nullMembers: "omit", durations: "iso" }); } finally { rmSync(dir, { recursive: true, force: true }); } diff --git a/spec/decisions/ADR-0019-runtime-return-type-contract.md b/spec/decisions/ADR-0019-runtime-return-type-contract.md index 5a843dd511..83e5d74b7c 100644 --- a/spec/decisions/ADR-0019-runtime-return-type-contract.md +++ b/spec/decisions/ADR-0019-runtime-return-type-contract.md @@ -194,3 +194,14 @@ spelling (`"12.50"`, not `"12.5"`). A project that wants 1.1.1's output in every `JSON.parse` turns a number into a double before any user code sees it (Amendment 1, "Why a string"). That is a choice the project makes; it is no longer the default. - No vocabulary changes, so `metamodelVersion` does not move. The fix ships in 1.1.2 as a bug fix. + +**Addendum (2026-11-04): `wire.durations`.** `field.duration` (1.3) follows this rule rather than +answering it. `wire.durations` is `"native"` (default) or `"iso"`. `native` writes and reads what +the port's standard serializer does for its duration type: System.Text.Json's `TimeSpan` form in +C# (`"01:30:00"`), `Duration.toString()` / `Duration.parse` in Java and Kotlin as Spring Boot +configures Jackson, Pydantic v2's `timedelta` form in Python (ISO-8601 that folds 24 hours into a +day), and the canonical string in TypeScript, whose in-process type is a `string`. `iso` is the +canonical ISO-8601 form of `fixtures/duration-wire/` in every port, and the api-contract and +persistence corpora pin it, as they pin `decimals: "string"`. A filter operand is an ISO-8601 +duration in both modes, since it is query grammar, not the body serializer. The shared config +cases are in `fixtures/wire-conformance/config-blocks.json`. diff --git a/spec/decisions/ADR-0023-strict-metadata-provenance.md b/spec/decisions/ADR-0023-strict-metadata-provenance.md index 43c8172e3c..2f43b0bf2b 100644 --- a/spec/decisions/ADR-0023-strict-metadata-provenance.md +++ b/spec/decisions/ADR-0023-strict-metadata-provenance.md @@ -50,3 +50,46 @@ Downstream apps may register their own providers (extending the registry before - **Runtime codegen-read instrumentation** (record every attr a generator queries, fail undeclared). Rejected: strict-load + sealed-registry makes undeclared attrs unreadable by construction, so instrumentation is unnecessary. - **Seal the global singleton as-is** (JVM). Rejected: it would freeze the *polluted* vocabulary; the loader must default to the defined-provider set instead. - **Static attr-name-constant provenance scan.** Useful as a lint but fragile (misses dynamic reads); the structural approach is stronger. + +## Amendment 1 (2026-11-04): the 1.3 schema vocabulary, justified + +Section 3 asks for a written case, per attribute, that its value cannot be computed from the +metadata already there. The 1.3 release adds the vocabulary below. Each one states a fact about a +database or a rule that the model has no other way to know; each derived default stays as it was, +so a model that declares none of them emits what it always did. + +- **`@flattenedColumnPrefix`** (on a `field.object` with `@storage: flattened`). The default is + computed: the field's resolved column and `_`. What cannot be computed is an existing table that + used another prefix, or none at all, and a value object embedded twice on one table with a + different prefix each time. `@column` on the field cannot say it: `""` is not a column name, and + the prefix belongs to the embedding, not to the value object, which other tables embed too. +- **`@sequenceStart` and `@sequenceName`** (on an `@generation: increment` `identity.primary`). + The first value a sequence hands out and its physical name are facts of a database that already + exists: a low id range kept for seeded rows, a sequence that kept its name across a table + rename. Nothing in the model implies either. They are not `@dbColumnType`, which spells a + column's type and is own-only: these are options of the identity's sequence, compared and + altered in place by `meta migrate`, and they keep their meaning when the column type changes. + Postgres only; SQLite and D1 have no sequence, and migrate says so when it creates such a key. +- **`@constraintName`** (on a validator that generates a CHECK, and on a string-stored + `field.enum`). The derived name (`
___chk`) stays the default. The declared + one is a name an adopter chose: a database whose CHECKs were named by hand, or a program that + compares a violation against the constraint name. A chosen name cannot be derived. +- **`@condition`** (on the validators that generate a CHECK). A rule that holds only in some case + ("a refund needs a reason") is a guard the author states; no other metadata says when a rule + applies. It uses the existing `attr.expression` grammar, so no new expression language comes + with it. +- **`validator.exactlyOne`** (a subtype, per ADR-0037). "Exactly one of these fields is set" has + its own SQL form (`num_nonnulls(...) = 1`) and its own record-model check, so it is a thing with + its own behaviour, not a variant of `atLeastOne`. The first draft spelled it `atLeastOne` with + `@max: 1`, which overloaded `@max` (numeric everywhere else) and read against the validator's + name; that never shipped. It cannot be computed: nothing else in the model bounds how many of a + set of fields may be set. +- **`@maxLength` on `field.enum`**. The longest member could size the column, but then adding a + longer member would silently become a column type change, and the width an existing database + already uses (a `VARCHAR(32)` sized for growth) is a physical fact the member list does not + carry. The project-wide default (`enums.maxLength`) is configuration, not vocabulary. Absent + both, the column stays unbounded text, as before. +- **`field.duration`** (a subtype, per ADR-0037). A fixed-length span has its own native type in + four ports (`TimeSpan`, `java.time.Duration`, `timedelta`) and in Postgres (`interval`), its own + wire grammar and its own ordering. A `field.string` or a `field.long` holding a count cannot carry + the unit or the native type, so it is a subtype, not an attribute. diff --git a/spec/decisions/ADR-0058-one-physical-naming-setting.md b/spec/decisions/ADR-0058-one-physical-naming-setting.md index fdae02d7fc..822e674752 100644 --- a/spec/decisions/ADR-0058-one-physical-naming-setting.md +++ b/spec/decisions/ADR-0058-one-physical-naming-setting.md @@ -42,7 +42,9 @@ every port already reads for `sources`, `dependencies` and `libraries`: { "schema_version": 1, "naming": { "columns": "snake_case", "tables": "snake_case" } } ``` -- `columns`: `snake_case` | `literal` | `kebab-case`. Applies to a field with no `@column`. +- `columns`: `snake_case` | `literal` | `kebab-case` | `upperFirst`. Applies to a field with no + `@column`. (`upperFirst` was added after the decision: the first letter upper-cased, the rest verbatim. + It is a column rule only, like `kebab-case`.) - `tables`: `snake_case` | `literal`. Applies to a source with no physical-name attribute: its structural `name` is cased by the rule; with no name either, the owning object's name is cased by the rule and then pluralized by the frozen legacy pluralizer, under every rule. diff --git a/spec/metamodel/db.json b/spec/metamodel/db.json index 44c1048d73..4e087e72f0 100644 --- a/spec/metamodel/db.json +++ b/spec/metamodel/db.json @@ -21,6 +21,7 @@ "type": "field", "subType": "object", "children": [ + { "type": "attr", "subType": "string", "name": "flattenedColumnPrefix", "min": 0, "max": 1, "description": "On a field.object with @storage \"flattened\": the text put before each member's column, verbatim (\"\" allowed, so the members keep their own column names). Absent: this field's resolved column name and \"_\" (with @column \"bill\", the members are bill_). A fact about the owning table, so it lives on the embedding field, never on the value object. Refused on any other storage (ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE). Justified in ADR-0023 Amendment 1." }, { "type": "attr", "subType": "string", "name": "storage", "min": 0, "max": 1, "allowedValues": ["flattened", "jsonb", "subdocument"], "description": "Storage strategy for an object-typed field (set with @objectRef). \"flattened\" expands the nested value into prefixed columns on the parent table. \"jsonb\" stores the structured value in a single jsonb column (supports isArray=true for arrays of values). \"subdocument\" is a hint for document-store codegen targets and emits no Postgres column." } ] }, @@ -58,6 +59,28 @@ { "type": "attr", "subType": "string", "name": "using", "min": 0, "max": 1, "description": "Index access method (e.g. \"gin\", \"gist\", \"hash\"); default \"btree\" (not rendered). Pair with @expr for e.g. a GIN index over an array/jsonb expression. RDB-physical — contributed by the db provider." } ] }, + { + "type": "field", + "subType": "enum", + "children": [ + { "type": "attr", "subType": "string", "name": "constraintName", "min": 0, "max": 1, "description": "The database name of the CHECK constraint this enum's member list generates. Absent:
__chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." } + ] + }, + { + "type": "validator", + "subType": ["numeric", "length", "regex", "comparison", "requiredWhen", "presentIff", "atLeastOne", "exactlyOne"], + "children": [ + { "type": "attr", "subType": "string", "name": "constraintName", "min": 0, "max": 1, "description": "The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." } + ] + }, + { + "type": "identity", + "subType": "primary", + "children": [ + { "type": "attr", "subType": "long", "name": "sequenceStart", "min": 0, "max": 1, "description": "The first value an @generation \"increment\" key's sequence hands out (Postgres: GENERATED BY DEFAULT AS IDENTITY (START WITH n)). Absent: 1. Lets a model reproduce a table that keeps a low id range for seeded rows. Refused without @generation \"increment\" (ERR_SEQUENCE_WITHOUT_INCREMENT). RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." }, + { "type": "attr", "subType": "string", "name": "sequenceName", "min": 0, "max": 1, "description": "The physical name of an @generation \"increment\" key's sequence (Postgres: GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME ...)). Absent: the database's own default,
__seq on Postgres. Lets a model adopt a sequence that kept its name across a table rename. Refused without @generation \"increment\" (ERR_SEQUENCE_WITHOUT_INCREMENT). RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1." } + ] + }, { "type": "identity", "subType": "reference", diff --git a/spec/metamodel/field.json b/spec/metamodel/field.json index b054bda765..3b4a4ce9fc 100644 --- a/spec/metamodel/field.json +++ b/spec/metamodel/field.json @@ -151,6 +151,7 @@ "children": [ { "type": "attr", "subType": "string", "name": "values", "isArray": true, "min": 1, "max": 1, "description": "Member symbols of an enum-subtype field. Declaration order is significant; each is a legal identifier and its own stored string." }, { "type": "attr", "subType": "boolean", "name": "provided", "min": 0, "max": 1, "description": "FR-019: marks an abstract package-level field.enum as externally provided — codegen references the type (resolved via per-port codegen config) instead of materializing it. Default false. Not a field attr — it lives on the type declaration." }, + { "type": "attr", "subType": "int", "name": "maxLength", "min": 0, "max": 1, "description": "Column length of a string-stored enum (drives VARCHAR(n)); every member must fit. Overrides the project's enums.maxLength. Refused with @intValueMap, whose column is an integer. Justified in ADR-0023 Amendment 1." }, { "type": "attr", "subType": "intMap", "name": "intValueMap", "min": 0, "max": 1, "description": "Optional per-member int values ({member: int}) switching this enum field's DB persistence from string+CHECK to integer+CHECK. Keys must exactly match @values; values must be unique integers. The generated native type and wire format are unchanged in every language." } ] }, @@ -183,6 +184,14 @@ "children": [ { "type": "attr", "subType": "boolean", "name": "lenient", "min": 0, "max": 1, "description": "#234: opt this field out of strict well-formedness enforcement. When true, codegen binds a plain string (no URL/IP validator, no native URI/InetAddress type; field.inet uses a text column, not the native inet type) so a not-strictly-valid value round-trips unchanged. Default (absent/false) is strict: an absolute-scheme URI / an IPv4-or-IPv6 literal." } ] + }, + { + "type": "field", + "subType": "duration", + "dataType": "string", + "extendsBase": true, + "description": "A fixed-length span of time: days, hours, minutes and seconds (ADR-0036/0037 — a concept with a native type + behavior). Binds to TS string (TS has no native duration type); other ports bind to their native type (C# System.TimeSpan, Java and Kotlin java.time.Duration, Python datetime.timedelta). DB column is the Postgres-native interval type; SQLite, D1 and MySQL store a 64-bit integer count of microseconds, which orders and compares as the span does. The wire form is an ISO-8601 duration: every port writes [-]PT[nH][nM][n[.f]S] (hours never folded into days, as many fractional digits as its native type holds, zero is PT0S) and reads that form plus leading week and day counts (nW, exactly 7 days; nD, exactly 24 hours) and a fraction of any length, truncated toward zero. Years and months are refused: a calendar span is not a fixed length. Justified in ADR-0023 Amendment 1.", + "whenToUse": "A field holds an elapsed time, a timeout or a delay. Use it for a native Postgres interval column and a native duration type instead of a string or a number of seconds. A calendar span (\"every 3 months\") is not a duration: model it as an int and a unit." } ] } diff --git a/spec/metamodel/validator.json b/spec/metamodel/validator.json index 9520605bd7..4aba88dbd3 100644 --- a/spec/metamodel/validator.json +++ b/spec/metamodel/validator.json @@ -66,7 +66,8 @@ "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": "attr", "subType": "string", "name": "right", "min": 1, "max": 1, "description": "Name of the right-hand field of the owning entity." }, + { "type": "attr", "subType": "expression", "name": "condition", "min": 0, "max": 1, "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." } ] }, { @@ -78,7 +79,8 @@ "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": "attr", "subType": "string", "name": "equals", "min": 1, "max": 1, "description": "The gating value; when @when equals this, @field must be present." }, + { "type": "attr", "subType": "expression", "name": "condition", "min": 0, "max": 1, "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." } ] }, { @@ -90,7 +92,8 @@ "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": "attr", "subType": "string", "name": "equals", "min": 1, "max": 1, "description": "The gating value; @field is present exactly when @when equals this." }, + { "type": "attr", "subType": "expression", "name": "condition", "min": 0, "max": 1, "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." } ] }, { @@ -100,7 +103,19 @@ "whenToUse": "At least one of several fields must be present (email or phone). Declare it instead of hand-written OR-presence checks.", "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." } + { "type": "attr", "subType": "string", "name": "fields", "isArray": true, "min": 1, "max": 1, "description": "Names of the candidate fields; at least one must be present." }, + { "type": "attr", "subType": "expression", "name": "condition", "min": 0, "max": 1, "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." } + ] + }, + { + "type": "validator", + "subType": "exactlyOne", + "description": "Cardinality of presence: exactly one of the named fields (@fields) is present (NOT NULL), and the others are null. Entity-scoped; references fields by name (same @fields-by-name pattern as identity.*). Justified in ADR-0023 Amendment 1.", + "whenToUse": "A row belongs to exactly one of several alternatives (a store or a kiosk, never both, never neither). Declare it instead of hand-written count-of-non-nulls checks.", + "rules": "@fields names two or more fields of the owning entity (ERR_BAD_ATTR_VALUE): one field would just be a required field. Satisfied when exactly one of them is non-null.", + "children": [ + { "type": "attr", "subType": "string", "name": "fields", "isArray": true, "min": 1, "max": 1, "description": "Names of the alternative fields; exactly one must be present." }, + { "type": "attr", "subType": "expression", "name": "condition", "min": 0, "max": 1, "description": "The case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1." } ] } ] From 555c8a635c0b96ba988701e37cd5938f81b0746b Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 02:47:30 -0500 Subject: [PATCH 02/12] feat(migrate-ts): schema conventions, named CHECKs and key renames - migrate.conventions names the database objects no code addresses. efcore is a reference preset. - migrate.checks.skipRedundant leaves out CHECKs the column type already enforces. - Validators emit CHECKs: - with a declared @constraintName - with a @condition, as NOT (cond) OR rule - for exactlyOne, as num_nonnulls(...) = 1, or a sum of presence tests on SQLite and D1 - Identity sequences (@sequenceStart / @sequenceName) are emitted and read back. A failure to read them is reported instead of being swallowed. - A constraint or index whose only change is its name is renamed in place on Postgres. - A key that changes both its name and its kind is renamed first, then promoted or demoted, in the same migration (#285). - An FK whose target is not a unique key: - Postgres and D1 refuse it. - SQLite only warns, because it accepts the CREATE TABLE. `meta verify --db` reports the same. - An enum column whose members are longer than its length is refused by name. --- .../.metaobjects/config.json | 2 +- .../canonical/meta.fitness.json | 1 + .../canonical/schema.mysql.sql | 1 + .../canonical/schema.postgres.sql | 1 + .../canonical/schema.sqlite.sql | 1 + .../persistence-conformance/normalization.md | 1 + .../queries/roundtrip-all-types.yaml | 13 +- .../queries/update-delete-all-types.yaml | 3 + .../MetaObjects/Persistence/Db/DbConstants.cs | 8 + .../MetaObjects/Persistence/Db/DbProvider.cs | 25 + .../MetaObjects/Persistence/Db/DbSchema.cs | 39 ++ .../packages/cli/src/commands/gen.ts | 27 +- .../packages/cli/src/commands/migrate.ts | 141 +++++- .../packages/cli/src/commands/verify.ts | 126 ++++- .../packages/cli/src/lib/agent-schema.ts | 4 + .../typescript/packages/cli/src/lib/allow.ts | 7 + .../packages/cli/src/lib/catalog-listing.ts | 5 +- .../packages/cli/src/lib/codegen-drift.ts | 3 +- .../cli/src/lib/collection-load-options.ts | 6 +- .../cli/src/lib/enum-length-refusal.ts | 16 + .../packages/cli/src/lib/fk-index-advisory.ts | 4 + .../packages/cli/src/lib/fk-target-warning.ts | 13 + .../cli/src/lib/gen-migrate-advice.ts | 5 + .../cli/src/lib/load-metaobjects-config.ts | 21 + .../src/lib/referential-action-advisory.ts | 7 +- .../packages/cli/src/lib/schema-advisories.ts | 19 + .../cli/src/lib/schema-build-options.ts | 56 +++ .../integration/identity-sequence-pg.test.ts | 131 ++++++ .../integration/verify-codegen-wire.test.ts | 4 +- .../migrate-checks-skip-redundant.test.ts | 121 +++++ .../cli/test/migrate-enum-max-length.test.ts | 65 +++ .../test/migrate-fk-target-not-unique.test.ts | 136 ++++++ .../test/migrate-schema-conventions.test.ts | 152 ++++++ .../cli/test/unit/gen-migrate-advice.test.ts | 3 +- .../packages/cli/test/verify-replay.test.ts | 55 +++ .../cli/test/verify-schema-refusals.test.ts | 157 +++++++ .../migrate-ts/src/check-expression.ts | 132 ++++++ .../packages/migrate-ts/src/diff/index.ts | 242 +++++++++- .../packages/migrate-ts/src/diff/status.ts | 5 + .../packages/migrate-ts/src/drift/drift.ts | 16 +- .../packages/migrate-ts/src/emit/postgres.ts | 104 ++++- .../packages/migrate-ts/src/emit/sqlite.ts | 15 +- .../packages/migrate-ts/src/errors.ts | 118 +++++ .../migrate-ts/src/expected-schema.ts | 438 ++++++++++++++++-- .../packages/migrate-ts/src/expected.ts | 2 +- .../packages/migrate-ts/src/index.ts | 19 +- .../migrate-ts/src/introspect/postgres.ts | 116 +++-- .../migrate-ts/src/schema-conventions.ts | 145 ++++++ .../packages/migrate-ts/src/scope.ts | 32 +- .../migrate-ts/src/shadow-expected.ts | 24 +- .../packages/migrate-ts/src/snapshot/plan.ts | 10 +- .../migrate-ts/src/snapshot/serialize.ts | 19 +- .../packages/migrate-ts/src/sql-type.ts | 3 + .../packages/migrate-ts/src/types.ts | 97 +++- ...pected-schema-field-object-storage.test.ts | 25 + .../pg-constraint-backed-index-285.test.ts | 117 ++++- .../pg-cross-field-checks-roundtrip.test.ts | 103 ++++ .../integration/pg-duration-roundtrip.test.ts | 110 +++++ .../postgres-composite-fk-introspect.test.ts | 87 ++++ .../test/unit/check-redundant.test.ts | 92 ++++ .../unit/cross-field-check-extensions.test.ts | 249 ++++++++++ .../test/unit/diff-rename-constraints.test.ts | 115 +++++ .../test/unit/diff-unique-key-kind.test.ts | 106 +++++ .../test/unit/duration-column.test.ts | 59 +++ .../test/unit/enum-max-length.test.ts | 89 ++++ .../unit/expected-schema-conventions.test.ts | 171 +++++++ .../test/unit/fk-target-unique.test.ts | 266 +++++++++++ .../test/unit/identity-sequence.test.ts | 158 +++++++ .../test/unit/schema-conventions.test.ts | 109 +++++ 69 files changed, 4614 insertions(+), 158 deletions(-) create mode 100644 server/typescript/packages/cli/src/lib/enum-length-refusal.ts create mode 100644 server/typescript/packages/cli/src/lib/fk-target-warning.ts create mode 100644 server/typescript/packages/cli/src/lib/schema-advisories.ts create mode 100644 server/typescript/packages/cli/src/lib/schema-build-options.ts create mode 100644 server/typescript/packages/cli/test/integration/identity-sequence-pg.test.ts create mode 100644 server/typescript/packages/cli/test/migrate-checks-skip-redundant.test.ts create mode 100644 server/typescript/packages/cli/test/migrate-enum-max-length.test.ts create mode 100644 server/typescript/packages/cli/test/migrate-fk-target-not-unique.test.ts create mode 100644 server/typescript/packages/cli/test/migrate-schema-conventions.test.ts create mode 100644 server/typescript/packages/cli/test/verify-schema-refusals.test.ts create mode 100644 server/typescript/packages/migrate-ts/src/check-expression.ts create mode 100644 server/typescript/packages/migrate-ts/src/schema-conventions.ts create mode 100644 server/typescript/packages/migrate-ts/test/integration/pg-cross-field-checks-roundtrip.test.ts create mode 100644 server/typescript/packages/migrate-ts/test/integration/pg-duration-roundtrip.test.ts create mode 100644 server/typescript/packages/migrate-ts/test/integration/postgres-composite-fk-introspect.test.ts create mode 100644 server/typescript/packages/migrate-ts/test/unit/check-redundant.test.ts create mode 100644 server/typescript/packages/migrate-ts/test/unit/cross-field-check-extensions.test.ts create mode 100644 server/typescript/packages/migrate-ts/test/unit/diff-rename-constraints.test.ts create mode 100644 server/typescript/packages/migrate-ts/test/unit/diff-unique-key-kind.test.ts create mode 100644 server/typescript/packages/migrate-ts/test/unit/duration-column.test.ts create mode 100644 server/typescript/packages/migrate-ts/test/unit/enum-max-length.test.ts create mode 100644 server/typescript/packages/migrate-ts/test/unit/expected-schema-conventions.test.ts create mode 100644 server/typescript/packages/migrate-ts/test/unit/fk-target-unique.test.ts create mode 100644 server/typescript/packages/migrate-ts/test/unit/identity-sequence.test.ts create mode 100644 server/typescript/packages/migrate-ts/test/unit/schema-conventions.test.ts diff --git a/fixtures/persistence-conformance/.metaobjects/config.json b/fixtures/persistence-conformance/.metaobjects/config.json index d6d2b1359d..3a5a786b8c 100644 --- a/fixtures/persistence-conformance/.metaobjects/config.json +++ b/fixtures/persistence-conformance/.metaobjects/config.json @@ -1,5 +1,5 @@ { "schema_version": 1, "naming": { "columns": "literal" }, - "wire": { "decimals": "string", "nullMembers": "omit" } + "wire": { "decimals": "string", "nullMembers": "omit", "durations": "iso" } } diff --git a/fixtures/persistence-conformance/canonical/meta.fitness.json b/fixtures/persistence-conformance/canonical/meta.fitness.json index 12dbeb55ab..cd2aa0a35c 100644 --- a/fixtures/persistence-conformance/canonical/meta.fitness.json +++ b/fixtures/persistence-conformance/canonical/meta.fitness.json @@ -347,6 +347,7 @@ { "field.uri": { "name": "uriVal", "@required": true } }, { "field.inet": { "name": "inetVal", "@required": true } }, { "field.inet": { "name": "inet6Val", "@required": true } }, + { "field.duration": { "name": "durationVal" } }, { "field.object": { "name": "settings", "@objectRef": "Settings", "@storage": "jsonb" } }, { "field.object": { "name": "labels", "@objectRef": "Label", "@storage": "jsonb", "isArray": true } }, { "identity.primary": { "name": "id", "@fields": "id", "@generation": "uuid" } }, diff --git a/fixtures/persistence-conformance/canonical/schema.mysql.sql b/fixtures/persistence-conformance/canonical/schema.mysql.sql index 8d2a314303..3f2037f6fc 100644 --- a/fixtures/persistence-conformance/canonical/schema.mysql.sql +++ b/fixtures/persistence-conformance/canonical/schema.mysql.sql @@ -102,6 +102,7 @@ CREATE TABLE `all_types` ( `uriVal` VARCHAR(255) NOT NULL, `inetVal` VARCHAR(45) NOT NULL, `inet6Val` VARCHAR(45) NOT NULL, + `durationVal` BIGINT, `settings` JSON, `labels` JSON ); diff --git a/fixtures/persistence-conformance/canonical/schema.postgres.sql b/fixtures/persistence-conformance/canonical/schema.postgres.sql index 3631a660ad..a5a9bd9317 100644 --- a/fixtures/persistence-conformance/canonical/schema.postgres.sql +++ b/fixtures/persistence-conformance/canonical/schema.postgres.sql @@ -132,6 +132,7 @@ CREATE TABLE "all_types" ( "uriVal" TEXT NOT NULL, "inetVal" INET NOT NULL, "inet6Val" INET NOT NULL, + "durationVal" INTERVAL, "settings" JSONB, "labels" JSONB, CONSTRAINT "all_types_pkey" PRIMARY KEY ("id"), diff --git a/fixtures/persistence-conformance/canonical/schema.sqlite.sql b/fixtures/persistence-conformance/canonical/schema.sqlite.sql index c7cc934302..c61ed55174 100644 --- a/fixtures/persistence-conformance/canonical/schema.sqlite.sql +++ b/fixtures/persistence-conformance/canonical/schema.sqlite.sql @@ -133,6 +133,7 @@ CREATE TABLE "all_types" ( "uriVal" TEXT NOT NULL, "inetVal" TEXT NOT NULL, "inet6Val" TEXT NOT NULL, + "durationVal" INTEGER, "settings" TEXT, "labels" TEXT, CONSTRAINT "all_types_enumVal_chk" CHECK ("enumVal" IN ('LOW', 'MEDIUM', 'HIGH')), diff --git a/fixtures/persistence-conformance/normalization.md b/fixtures/persistence-conformance/normalization.md index ee9fadf4ee..e467925fb3 100644 --- a/fixtures/persistence-conformance/normalization.md +++ b/fixtures/persistence-conformance/normalization.md @@ -38,6 +38,7 @@ byte equality check. | `TIMESTAMP` | string `"YYYY-MM-DDTHH:MM:SS[.fff]"` | `"2026-05-25T10:30:00"` (no Z) | | `TIMESTAMPTZ` | string `"YYYY-MM-DDTHH:MM:SS[.fff]Z"` | `"2026-05-25T14:30:00Z"` (UTC always) | | `UUID` | string (lowercase canonical) | `"550e8400-e29b-41d4-a716-446655440000"` | +| `INTERVAL` (`field.duration`) | string (canonical ISO-8601 duration, [`fixtures/duration-wire`](../duration-wire/)) | `"PT24H0.5S"` | | `JSON`, `JSONB` | re-serialized with **sorted keys** | `{"a": 1, "b": 2}` not `{"b": 2, "a": 1}` | | `BYTEA` | base64 string | `"aGVsbG8="` | | `NULL` | JSON `null` | | diff --git a/fixtures/persistence-conformance/queries/roundtrip-all-types.yaml b/fixtures/persistence-conformance/queries/roundtrip-all-types.yaml index 93ff84047d..31ccf6024d 100644 --- a/fixtures/persistence-conformance/queries/roundtrip-all-types.yaml +++ b/fixtures/persistence-conformance/queries/roundtrip-all-types.yaml @@ -15,7 +15,7 @@ description: | string / int / long / double / float / decimal / boolean / date / time / timestamp / timestamp(tz) / currency / enum / uuid / - uri / inet (IPv4 + IPv6) / object(@objectRef, jsonb storage) + uri / inet (IPv4 + IPv6) / duration / object(@objectRef, jsonb storage) Tricky wire cases asserted here: - decimal: exact, canonical (no trailing zeros) → "123.456". @@ -26,6 +26,11 @@ description: | precision loss — a port routing these through a JS number / 32-bit int / double corrupts the low bits and fails. - uuid: written upper-case, reads back lowercase-canonical. + - duration: written in an accepted ISO-8601 form, read back canonical + (fixtures/duration-wire): a day folds into hours (`P1DT0.5S` -> `PT24H0.5S`) + and a negative span carries one leading sign (`-PT90S` -> `-PT1M30S`). + Postgres refuses a signed ISO duration as interval input, so this also + proves each port binds the value in a form Postgres reads. - id (uuid PK): NOT supplied on insert; gen_random_uuid() fills it, the runtime returns it, and the read-back is by that generated PK — so a server-generated PK is exercised on the write path too. @@ -77,6 +82,7 @@ queries: uriVal: "https://example.com/a/b?q=1" inetVal: "192.168.1.1" inet6Val: "2001:db8::1" + durationVal: "P1DT0.5S" settings: { theme: "dark", notify: true, retries: 3 } # array-of-VO stored as JSONB (field.object @isArray @storage:jsonb). Element # order is preserved; each element's keys are sorted on read (like settings). @@ -102,6 +108,7 @@ queries: uriVal: "https://example.com/a/b?q=1" inetVal: "192.168.1.1" inet6Val: "2001:db8::1" + durationVal: "PT24H0.5S" settings: { notify: true, retries: 3, theme: "dark" } labels: - { key: "alpha", weight: 10 } @@ -129,6 +136,7 @@ queries: uriVal: "urn:isbn:0451450523" inetVal: "10.0.0.5" inet6Val: "fe80::1" + durationVal: "-PT90S" settings: { theme: "light", notify: false, retries: 0 } # empty array-of-VO: an empty JSON array must round-trip as [] (distinct from null). labels: [] @@ -151,6 +159,7 @@ queries: uriVal: "urn:isbn:0451450523" inetVal: "10.0.0.5" inet6Val: "fe80::1" + durationVal: "-PT1M30S" settings: { notify: false, retries: 0, theme: "light" } labels: [] @@ -190,6 +199,7 @@ queries: # Authored in the long (uncompressed) form; Postgres returns the canonical # compressed form, so the expected read-back is `2001:db8::8a2e:370:7334`. inet6Val: "2001:0db8:0000:0000:0000:8a2e:0370:7334" + durationVal: "PT876000H" settings: { theme: "dark", notify: true, retries: 9 } labels: - { key: "solo", weight: 99 } @@ -212,6 +222,7 @@ queries: uriVal: "https://example.org/path#frag" inetVal: "255.255.255.255" inet6Val: "2001:db8::8a2e:370:7334" + durationVal: "PT876000H" settings: { notify: true, retries: 9, theme: "dark" } labels: - { key: "solo", weight: 99 } diff --git a/fixtures/persistence-conformance/queries/update-delete-all-types.yaml b/fixtures/persistence-conformance/queries/update-delete-all-types.yaml index 35edbcfe3b..7eff2f9059 100644 --- a/fixtures/persistence-conformance/queries/update-delete-all-types.yaml +++ b/fixtures/persistence-conformance/queries/update-delete-all-types.yaml @@ -80,6 +80,7 @@ queries: uriVal: "https://example.com/after?x=1" inetVal: "192.168.10.20" inet6Val: "2001:db8::dead:beef" + durationVal: "PT2700S" settings: { theme: "dark", notify: true, retries: 7 } expect: id: "22222222-2222-4222-8222-222222222222" @@ -101,6 +102,7 @@ queries: uriVal: "https://example.com/after?x=1" inetVal: "192.168.10.20" inet6Val: "2001:db8::dead:beef" + durationVal: "PT45M" settings: { notify: true, retries: 7, theme: "dark" } # labels (array-of-VO jsonb) is neither seeded nor updated here → reads back null. labels: null @@ -130,6 +132,7 @@ queries: uriVal: "https://example.com/after?x=1" inetVal: "192.168.10.20" inet6Val: "2001:db8::dead:beef" + durationVal: "PT45M" settings: { notify: true, retries: 7, theme: "dark" } # labels (array-of-VO jsonb) is neither seeded nor updated here → reads back null. labels: null diff --git a/server/csharp/MetaObjects/Persistence/Db/DbConstants.cs b/server/csharp/MetaObjects/Persistence/Db/DbConstants.cs index 28378aa31f..dc0f4ed6d7 100644 --- a/server/csharp/MetaObjects/Persistence/Db/DbConstants.cs +++ b/server/csharp/MetaObjects/Persistence/Db/DbConstants.cs @@ -17,6 +17,10 @@ public static class DbConstants { /// Custom DB column name override on a field (source-v2 attr @column). public const string FIELD_ATTR_COLUMN = "column"; + /// @flattenedColumnPrefix on a field.object @storage "flattened": the text before + /// each member's column, verbatim ("" allowed). Absent: the field's resolved column and + /// _ (). + public const string FIELD_ATTR_FLATTENED_COLUMN_PREFIX = "flattenedColumnPrefix"; /// When true, suppress the @filterable-without-index Loader warning (Project D drift check). public const string FIELD_ATTR_DB_INDEXED = "db.indexed"; @@ -45,6 +49,10 @@ public static class DbConstants public const string IDENTITY_ATTR_USING = "using"; /// identity.reference: physical FK constraint-name override. Absent => the auto-derived default. public const string IDENTITY_ATTR_CONSTRAINT_NAME = "constraintName"; + /// identity.primary (@generation "increment"): the sequence's first value. Absent → 1. + public const string IDENTITY_ATTR_SEQUENCE_START = "sequenceStart"; + /// identity.primary (@generation "increment"): the sequence's physical name. Absent → the database's default. + public const string IDENTITY_ATTR_SEQUENCE_NAME = "sequenceName"; /// identity.reference: referential action on parent delete — the explicit per-FK override (ADR-0047). public const string IDENTITY_ATTR_ON_DELETE = "onDelete"; /// identity.reference: referential action on key update — the explicit per-FK override (ADR-0047). diff --git a/server/csharp/MetaObjects/Persistence/Db/DbProvider.cs b/server/csharp/MetaObjects/Persistence/Db/DbProvider.cs index 7a1e48a169..300a7dc76c 100644 --- a/server/csharp/MetaObjects/Persistence/Db/DbProvider.cs +++ b/server/csharp/MetaObjects/Persistence/Db/DbProvider.cs @@ -52,6 +52,27 @@ public void RegisterTypes(TypeRegistry registry) FieldConstants.FIELD_SUBTYPE_TIMESTAMP, attributes: [DbSchema.LocalTimeSchema]); + // @constraintName: the database name of the CHECK a field.enum's member list, and a + // CHECK-generating validator, generates — db.json's field.enum and validator extends blocks. + registry.Extend( + MetaObjects.Shared.BaseTypes.TYPE_FIELD, + FieldConstants.FIELD_SUBTYPE_ENUM, + attributes: [DbSchema.EnumConstraintNameSchema]); + foreach (string subType in Core.Validator.ValidatorConstants.CHECK_VALIDATOR_SUBTYPES) + { + registry.Extend( + MetaObjects.Shared.BaseTypes.TYPE_VALIDATOR, + subType, + attributes: [DbSchema.ValidatorConstraintNameSchema]); + } + + // @flattenedColumnPrefix is field.object-ONLY (the flattened-member column prefix), mirroring + // db.json's field.object extends block. + registry.Extend( + MetaObjects.Shared.BaseTypes.TYPE_FIELD, + FieldConstants.FIELD_SUBTYPE_OBJECT, + attributes: [DbSchema.FlattenedColumnPrefixSchema]); + // Physical RDB index/constraint attrs on identity subtypes — DB-domain // concerns (index ordering / partial predicate / FK constraint naming), // NOT core identity. Mirror the TS db.json identity extends. @@ -62,6 +83,10 @@ public void RegisterTypes(TypeRegistry registry) MetaObjects.Shared.BaseTypes.TYPE_IDENTITY, IdentityConstants.IDENTITY_SUBTYPE_SECONDARY, attributes: [DbSchema.OrdersSchema, DbSchema.WhereSchema, DbSchema.ExprSchema, DbSchema.UsingSchema]); + registry.Extend( + MetaObjects.Shared.BaseTypes.TYPE_IDENTITY, + IdentityConstants.IDENTITY_SUBTYPE_PRIMARY, + attributes: [DbSchema.SequenceStartSchema, DbSchema.SequenceNameSchema]); registry.Extend( MetaObjects.Shared.BaseTypes.TYPE_IDENTITY, IdentityConstants.IDENTITY_SUBTYPE_REFERENCE, diff --git a/server/csharp/MetaObjects/Persistence/Db/DbSchema.cs b/server/csharp/MetaObjects/Persistence/Db/DbSchema.cs index 05b25e8174..26dd53d7e9 100644 --- a/server/csharp/MetaObjects/Persistence/Db/DbSchema.cs +++ b/server/csharp/MetaObjects/Persistence/Db/DbSchema.cs @@ -61,6 +61,17 @@ public static class DbSchema "(`timestamptz`). ADR-0036 Wave 2 — replaces the retired @dbColumnType: timestamp_with_tz " + "escape hatch."); + /// + /// @flattenedColumnPrefix — the text before each member's column of a field.object @storage + /// "flattened" (string, "" allowed). Registered ONLY on field.object by + /// . + /// + public static readonly AttrSchema FlattenedColumnPrefixSchema = new AttrSchema( + Name: DbConstants.FIELD_ATTR_FLATTENED_COLUMN_PREFIX, + ValueType: AttrConstants.ATTR_SUBTYPE_STRING, + Required: false, + Description: "On a field.object with @storage \"flattened\": the text put before each member's column, verbatim (\"\" allowed, so the members keep their own column names). Absent: this field's resolved column name and \"_\" (with @column \"bill\", the members are bill_). A fact about the owning table, so it lives on the embedding field, never on the value object. Refused on any other storage (ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE). Justified in ADR-0023 Amendment 1."); + // --- Physical RDB index/constraint attrs the db provider adds to identity.* --- /// @orders — per-key index sort direction (asc|desc), positional to @fields; on identity.secondary. @@ -93,6 +104,34 @@ public static class DbSchema Required: false, Description: "Index access method (e.g. \"gin\", \"gist\", \"hash\"); default \"btree\" (not rendered). Pair with @expr for e.g. a GIN index over an array/jsonb expression. RDB-physical — contributed by the db provider."); + /// @constraintName on a validator — the database name of the CHECK it generates. + public static readonly AttrSchema ValidatorConstraintNameSchema = new AttrSchema( + Name: Core.Validator.ValidatorConstants.VALIDATOR_ATTR_CONSTRAINT_NAME, + ValueType: AttrConstants.ATTR_SUBTYPE_STRING, + Required: false, + Description: "The database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived
___chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1."); + + /// @constraintName on a field.enum — the database name of its membership CHECK. + public static readonly AttrSchema EnumConstraintNameSchema = new AttrSchema( + Name: Core.Validator.ValidatorConstants.VALIDATOR_ATTR_CONSTRAINT_NAME, + ValueType: AttrConstants.ATTR_SUBTYPE_STRING, + Required: false, + Description: "The database name of the CHECK constraint this enum's member list generates. Absent:
__chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1."); + + /// @sequenceStart — the first value of an increment key's sequence; on identity.primary. + public static readonly AttrSchema SequenceStartSchema = new AttrSchema( + Name: DbConstants.IDENTITY_ATTR_SEQUENCE_START, + ValueType: AttrConstants.ATTR_SUBTYPE_LONG, + Required: false, + Description: "The first value an @generation \"increment\" key's sequence hands out (Postgres: GENERATED BY DEFAULT AS IDENTITY (START WITH n)). Absent: 1. Lets a model reproduce a table that keeps a low id range for seeded rows. Refused without @generation \"increment\" (ERR_SEQUENCE_WITHOUT_INCREMENT). RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1."); + + /// @sequenceName — the physical name of an increment key's sequence; on identity.primary. + public static readonly AttrSchema SequenceNameSchema = new AttrSchema( + Name: DbConstants.IDENTITY_ATTR_SEQUENCE_NAME, + ValueType: AttrConstants.ATTR_SUBTYPE_STRING, + Required: false, + Description: "The physical name of an @generation \"increment\" key's sequence (Postgres: GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME ...)). Absent: the database's own default,
__seq on Postgres. Lets a model adopt a sequence that kept its name across a table rename. Refused without @generation \"increment\" (ERR_SEQUENCE_WITHOUT_INCREMENT). RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1."); + /// @constraintName — physical FK constraint-name override; on identity.reference. public static readonly AttrSchema ConstraintNameSchema = new AttrSchema( Name: DbConstants.IDENTITY_ATTR_CONSTRAINT_NAME, diff --git a/server/typescript/packages/cli/src/commands/gen.ts b/server/typescript/packages/cli/src/commands/gen.ts index bdba49c353..2e5bb29020 100644 --- a/server/typescript/packages/cli/src/commands/gen.ts +++ b/server/typescript/packages/cli/src/commands/gen.ts @@ -34,6 +34,8 @@ import { describeError } from "../lib/error-text.js"; import { findRuntimeBoundaryCrossings, runtimeBoundaryWarnings } from "../lib/runtime-boundary-advisory.js"; import { hasLegacyDocsBlock, legacyDocsConfigMessage, NO_CONFIG_MESSAGE } from "../lib/docs-removed.js"; import { buildAgentSchemaInput } from "../lib/agent-schema.js"; +import { schemaBuildOptions, schemaBuildOptionsOrWarn, type SchemaBuildOptions } from "../lib/schema-build-options.js"; +import { isUnknownSchemaConventionsError } from "@metaobjectsdev/migrate-ts"; import { existsSync } from "node:fs"; /** @@ -214,6 +216,19 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat : []; for (const w of unknownAttrs) { log.warn(`warning: ${w}`); warnings.push(w); } + // The schema settings `meta migrate` builds with, for the Prisma schema, the schema page and + // the migrate advice. A conventions preset that does not resolve is a config error here as in + // `meta migrate` and `meta verify`: generating with the default names instead would write a + // Prisma schema whose keys the database does not have. + let schemaOptions: SchemaBuildOptions; + try { + schemaOptions = schemaBuildOptions(genCollection, forgeConfig); + } catch (err) { + if (!isUnknownSchemaConventionsError(err)) throw err; + log.error(`gen: ${err.message}`); + return 2; + } + // FR-023 §11.1 item 2 — refuse a positional that names ONLY objects imported // from a dependency and excluded from output by the default exclusion rule. // Distinct from the runner's existing "no entities matched" WARNING @@ -258,7 +273,10 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat metadata, forgeConfig.dialect, (m) => { const w = `[agent-docs] ${m}`; log.warn(w); warnings.push(w); }, + schemaOptions, ), + // The Prisma schema names its keys by the same conventions migrate uses. + schemaSettings: schemaOptions, // FR-043 §6 — the shipped-library selection. Always passed, including as `[]`: // that is what tells the post-selection audit "this project opted into none", // which is a different statement from a programmatic caller that never said. @@ -323,6 +341,7 @@ export async function genCommand(args: string[], cwd: string, fmt: OutputFormat const migrateDefaults = await resolveMigrateDefaults(projectRoot); const migrateAdvice = await genMigrateAdvice({ + schema: schemaOptions, metadata, dialect: forgeConfig.dialect, migrateDialect: migrateDefaults.dialect, @@ -501,6 +520,7 @@ async function listCatalogCommand( reportLoadError(log, "failed to load metadata", err); return 1; } + const schemaOptions = schemaBuildOptionsOrWarn(genCollection, forgeConfig, () => {}); opts = { project: { @@ -523,11 +543,14 @@ async function listCatalogCommand( scope: genCollection.inScope, sourceFiles: genCollection.ownFiles, sourceRoots: sourceRootsOf(genCollection), - agentSchema: () => buildAgentSchemaInput( + agentSchema: () => schemaOptions === undefined ? undefined : buildAgentSchemaInput( metadata, forgeConfig.dialect, - () => {}, + () => {}, + schemaOptions, ), + // The Prisma schema names its keys by the same conventions migrate uses. + ...(schemaOptions !== undefined && { schemaSettings: schemaOptions }), }, }; } else { diff --git a/server/typescript/packages/cli/src/commands/migrate.ts b/server/typescript/packages/cli/src/commands/migrate.ts index a5d8bc30da..9806304313 100644 --- a/server/typescript/packages/cli/src/commands/migrate.ts +++ b/server/typescript/packages/cli/src/commands/migrate.ts @@ -1,4 +1,5 @@ import { resolve as resolvePath } from "node:path"; +import { ENUM_LENGTH_HINT, isEnumLengthRefusal } from "../lib/enum-length-refusal.js"; import { mkdir } from "node:fs/promises"; import { existsSync } from "node:fs"; import { spawn } from "node:child_process"; @@ -12,15 +13,22 @@ import { emitStructured, narrate, toonEncode } from "../lib/format.js"; import { buildKyselyFromUrl, inferDialect, redactUrl } from "../lib/kysely.js"; import { describeExprCanonPath, shadowSession } from "../lib/shadow.js"; import { log } from "../lib/log.js"; +import { warnUnenforcedFkTargets } from "../lib/fk-target-warning.js"; import { loadMemory, resolveCollection, resolveConfigDir, type Collection } from "@metaobjectsdev/sdk"; -import { loadMemoryOptionsFrom, loadMetaobjectsConfig, RemovedNamingConfigError, resolveGenConfigDir } from "../lib/load-metaobjects-config.js"; +import { configNotLoadedWarning, loadMemoryOptionsFrom, loadMetaobjectsConfig, RemovedNamingConfigError, resolveGenConfigDir } from "../lib/load-metaobjects-config.js"; import { collectionLoadOptions } from "../lib/collection-load-options.js"; +import { schemaBuildOptions, type SchemaBuildOptions } from "../lib/schema-build-options.js"; import { exclusionNotes, importedOption, migrateScopeMismatch } from "../lib/migrate-scope.js"; import { isBlockedChangesError, isPrimaryKeyChangeError, isDeclaredRenameError, isDuplicateSqlNameError, + isInvalidCheckNameError, + isFkTargetNotUniqueError, + FkTargetNotUniqueError, + type FkTargetViolation, + isUnknownSchemaConventionsError, buildExpectedSchemaWithProvenance, scopeExpectedSchema, scopedDiffInputs, @@ -50,6 +58,7 @@ import { type AmbiguousResolution, type Change, type DataHazard, + type DiffResult, type Dialect, type D1Binding, type EmitResult, @@ -69,12 +78,13 @@ import { defaultWranglerRunner, type WranglerRunner, } from "../lib/wrangler.js"; -import { buildProjectionViews } from "@metaobjectsdev/codegen-ts"; +import { buildProjectionViews, type MetaobjectsGenConfig } from "@metaobjectsdev/codegen-ts"; import { tokensToAllowOptions, blockedEntriesFor, blockedHintLines, viewRecreateNote } from "../lib/allow.js"; import { reportLoadError } from "../lib/load-error.js"; import { decimalPrecisionWarnings } from "../lib/decimal-precision-advisory.js"; +import { schemaAdvisoryLines } from "../lib/schema-advisories.js"; import { scanForReferentialActionConflicts } from "../lib/referential-action-advisory.js"; -import type { MetaData } from "@metaobjectsdev/metadata"; +import type { MetaData, MetaRoot } from "@metaobjectsdev/metadata"; import { describeError } from "../lib/error-text.js"; /** @@ -288,10 +298,10 @@ function sqlPreview(sql: { up: string; down: string }): string { * child's. Advisory: it never changes the exit code, and a scan that throws is dropped * (the schema build itself reports anything wrong with the model). */ -function warnReferentialActionConflicts(metadata: MetaData, collection: Collection): void { +function warnReferentialActionConflicts(metadata: MetaData, collection: Collection, schema: SchemaBuildOptions): void { let findings; try { - findings = scanForReferentialActionConflicts(metadata, { skip: collection.imported }); + findings = scanForReferentialActionConflicts(metadata, { skip: collection.imported, schema }); } catch { return; } @@ -351,6 +361,16 @@ function mapOnAmbiguous(v: "abort" | "rename" | "drop-add"): AmbiguousResolution return v === "drop-add" ? "drop+add" : v; } +/** + * FKs whose target is not a unique key, on tables this run does not govern (imported, or + * outside migrate.scope). This project never emits them, so they are reported, not refused. + */ +function warnUngovernedFkTargets(violations: readonly FkTargetViolation[] | undefined): void { + if (violations === undefined || violations.length === 0) return; + log.warn(`migrate: ${new FkTargetNotUniqueError([...violations]).message}`); + log.warn(`migrate: the table(s) holding ${violations.length === 1 ? "that foreign key are" : "those foreign keys are"} not governed by this run (imported, or outside migrate.scope), so nothing is refused.`); +} + /** * The report-and-exit for an engine refusal whose message the engine already made * actionable — the sibling of `refuseScopeMismatch` above, which is the same decision @@ -363,13 +383,46 @@ function refuseEngineError(err: Error, hint: string, fmt: OutputFormat): number return 1; } +const SCHEMA_CONVENTIONS_HINT = + "set migrate.conventions in .metaobjects/config.json to one of the presets, or supply your own conventions from metaobjects.config.ts"; + +/** + * The project's schema settings (`migrate.conventions`, a conventions object from + * `metaobjects.config.ts`, `migrate.checks`), or exit 2 after naming the config error: a + * preset this version does not ship, or a conventions object missing a member. + */ +function resolveSchemaBuild( + collection: Collection, + genConfig: MetaobjectsGenConfig | undefined, + fmt: OutputFormat, +): SchemaBuildOptions | number { + try { + return schemaBuildOptions(collection, genConfig); + } catch (err) { + if (!isUnknownSchemaConventionsError(err)) throw err; + refuseEngineError(err, SCHEMA_CONVENTIONS_HINT, fmt); + return 2; + } +} + const PK_CHANGE_HINT = "align the primary key manually, or reconcile the metadata identity to match the live table"; const DUPLICATE_SQL_NAME_HINT = "rename one of the colliding declarations; an index's database name is its identity.secondary / index.lookup name, not prefixed with its table"; +const CHECK_NAME_HINT = + "give each constraint on a table its own @constraintName, at most 63 bytes"; +const FK_TARGET_NOT_UNIQUE_HINT = + "declare an identity.secondary on the target entity with exactly the referenced fields, or reference its primary key"; const DECLARED_RENAME_HINT = "fix or remove the --rename-table / --rename-column flag; a table's name is its name in the metadata (the new name when it is renamed too)"; +/** Print the diff's advice that is not drift (`schemaAdvisoryLines`), one warning each. */ +function warnAdvisories(advisories: DiffResult["advisories"] | undefined): string[] { + const lines = schemaAdvisoryLines(advisories); + for (const w of lines) log.warn(`migrate: ${w}`); + return lines; +} + /** * Warn about changes that apply to an empty table and fail on a populated one. Warn, not * refuse: the diff cannot see rows, and a new required column on an empty table is fine. @@ -736,13 +789,17 @@ export async function migrateCommand( // providers. Column naming is not read here: it is `naming` in // `.metaobjects/config.json`, set on the loaded root (ADR-0058). let postgresLoadOptions: ReturnType = {}; + let postgresGenConfig: MetaobjectsGenConfig | undefined; try { - postgresLoadOptions = loadMemoryOptionsFrom(await loadMetaobjectsConfig(genRoot)); + postgresGenConfig = await loadMetaobjectsConfig(genRoot); + postgresLoadOptions = loadMemoryOptionsFrom(postgresGenConfig); } catch (e) { if (e instanceof RemovedNamingConfigError) { log.error(e.message); return 2; } + const warning = configNotLoadedWarning(genRoot, e, "migrate"); + if (warning !== undefined) log.warn(warning); postgresLoadOptions = {}; } @@ -759,6 +816,9 @@ export async function migrateCommand( return 2; } + const schema = resolveSchemaBuild(collection, postgresGenConfig, fmt); + if (typeof schema === "number") return schema; + let metadata; try { metadata = await loadMemory(collection.configDir, { @@ -769,7 +829,7 @@ export async function migrateCommand( reportLoadError(log, "failed to load metadata", err); return 1; } - warnReferentialActionConflicts(metadata, collection); + warnReferentialActionConflicts(metadata, collection, schema); let kysely; try { @@ -806,6 +866,7 @@ export async function migrateCommand( // there is no separate view-migration emitter. const expectedViews = buildProjectionViews(metadata, { dialect: kysely.dialect }); const built = buildExpectedSchemaWithProvenance(metadata, { + ...schema, dialect: kysely.dialect, views: expectedViews, }); @@ -818,6 +879,8 @@ export async function migrateCommand( const scoped = scopeExpectedSchema(built, collection.inMigrateScope, importedOption(collection)); const expected = scoped.snapshot; logOutOfScope(scoped.outOfScope, scoped.importedOutOfScope ?? [], fmt); + warnUngovernedFkTargets(scoped.ungovernedFkTargetViolations); + warnUnenforcedFkTargets("migrate", scoped.unenforcedFkTargetViolations); let actual; try { actual = await introspect(kysely.db, kysely.dialect); @@ -916,7 +979,7 @@ export async function migrateCommand( } changeCounts = summarizeChanges(diffResult.changes); - hazardWarnings = warnDataHazards(diffResult.hazards); + hazardWarnings = [...warnDataHazards(diffResult.hazards), ...warnAdvisories(diffResult.advisories)]; // #313 — refuse to AUTHOR a drop for an object the committed snapshot never // contained. This path diffs metadata against introspection and never reads the @@ -1151,6 +1214,12 @@ export async function migrateCommand( if (err instanceof AlreadyEmittedError) return err.exitCode; // A metadata defect, not a tool failure: two declarations generate one database name. if (isDuplicateSqlNameError(err)) return refuseEngineError(err, DUPLICATE_SQL_NAME_HINT, fmt); + // A metadata defect too: a declared CHECK name that duplicates another constraint or is too long. + if (isInvalidCheckNameError(err)) return refuseEngineError(err, CHECK_NAME_HINT, fmt); + // A metadata defect too: a foreign key whose target columns are not a unique key there. + if (isFkTargetNotUniqueError(err)) return refuseEngineError(err, FK_TARGET_NOT_UNIQUE_HINT, fmt); + // A metadata defect too: an enum member longer than the project's enums.maxLength. + if (isEnumLengthRefusal(err)) return refuseEngineError(err, ENUM_LENGTH_HINT, fmt); // Unexpected error: emit structured error on stdout in the active format, then exit 1. const msg = describeError(err) ?? String(err); log.error(`migrate: unexpected error: ${msg}`); @@ -1167,20 +1236,28 @@ export async function migrateCommand( * * Throws what loading throws; a `RemovedNamingConfigError` from the config propagates unchanged. */ -async function loadBaselineMetadata(metaRoot: string, genRoot: string) { +async function loadBaselineMetadata( + metaRoot: string, + genRoot: string, +): Promise<{ metadata: MetaRoot; schema: SchemaBuildOptions }> { let baselineLoadOptions: ReturnType = {}; + let cfg: MetaobjectsGenConfig | undefined; try { - const cfg = await loadMetaobjectsConfig(genRoot); + cfg = await loadMetaobjectsConfig(genRoot); baselineLoadOptions = loadMemoryOptionsFrom(cfg); } catch (e) { if (e instanceof RemovedNamingConfigError) throw e; // config absent — no custom providers, no libraries, default snake_case + const warning = configNotLoadedWarning(genRoot, e, "migrate"); + if (warning !== undefined) log.warn(warning); } const collection = await resolveCollection(metaRoot); - return await loadMemory(collection.configDir, { + const schema = schemaBuildOptions(collection, cfg); + const metadata = await loadMemory(collection.configDir, { ...collectionLoadOptions(collection), ...baselineLoadOptions, }); + return { metadata, schema }; } /** @@ -1201,8 +1278,8 @@ async function withGeneratedSpelling( ): Promise<{ snapshot: SchemaSnapshot; path?: ExprCanonPath; shadowUnavailable?: string }> { let expected: SchemaSnapshot; try { - const metadata = await loadBaselineMetadata(metaRoot, genRoot); - expected = baselineFromMetadata(metadata, dialect, buildProjectionViews(metadata, { dialect })); + const { metadata, schema } = await loadBaselineMetadata(metaRoot, genRoot); + expected = baselineFromMetadata(metadata, dialect, buildProjectionViews(metadata, { dialect }), schema); } catch { return { snapshot }; } @@ -1313,18 +1390,23 @@ export async function runBaseline( // would delete the entry, and removing the scope later would then propose // CREATE TABLE for a table that exists. let metadata; + let schema; try { - metadata = await loadBaselineMetadata(metaRoot, genRoot); + ({ metadata, schema } = await loadBaselineMetadata(metaRoot, genRoot)); } catch (err) { if (err instanceof RemovedNamingConfigError) { log.error(err.message); return 2; } + if (isUnknownSchemaConventionsError(err)) { + refuseEngineError(err, SCHEMA_CONVENTIONS_HINT, fmt); + return 2; + } reportLoadError(log, "migrate baseline: failed to load metadata", err); return 1; } const baselineViews = buildProjectionViews(metadata, { dialect: config.dialect }); - snapshot = baselineFromMetadata(metadata, config.dialect, baselineViews); + snapshot = baselineFromMetadata(metadata, config.dialect, baselineViews, schema); } if (config.dryRun) { @@ -1463,15 +1545,18 @@ export async function runOfflineGenerate( // mirroring the DB path (and `meta gen`) so offline generate resolves // config-registered custom subtypes too (#157). let offlineLoadOptions: ReturnType = {}; + let offlineGenConfig: MetaobjectsGenConfig | undefined; try { - const cfg = await loadMetaobjectsConfig(genRoot); - offlineLoadOptions = loadMemoryOptionsFrom(cfg); + offlineGenConfig = await loadMetaobjectsConfig(genRoot); + offlineLoadOptions = loadMemoryOptionsFrom(offlineGenConfig); } catch (e) { if (e instanceof RemovedNamingConfigError) { log.error(e.message); return 2; } // config absent — no custom providers, no libraries, default snake_case + const warning = configNotLoadedWarning(genRoot, e, "migrate"); + if (warning !== undefined) log.warn(warning); } let metadata; @@ -1486,7 +1571,9 @@ export async function runOfflineGenerate( reportLoadError(log, "migrate: failed to load metadata", err); return 1; } - warnReferentialActionConflicts(metadata, collection); + const schema = resolveSchemaBuild(collection, offlineGenConfig, fmt); + if (typeof schema === "number") return schema; + warnReferentialActionConflicts(metadata, collection, schema); warnDecimalPrecision(metadata, collection, config.dialect); const offlineDialect = config.dialect; @@ -1494,6 +1581,7 @@ export async function runOfflineGenerate( const scopeRc = refuseScopeMismatch( collection, () => buildExpectedSchemaWithProvenance(metadata, { + ...schema, dialect: offlineDialect, views: offlineViews, }).provenance, @@ -1534,6 +1622,7 @@ export async function runOfflineGenerate( let plan; try { plan = await planOffline({ + ...schema, metadata, dialect: config.dialect, snapshot, @@ -1594,7 +1683,7 @@ export async function runOfflineGenerate( finishMigrate(fmt, [`migrate: no changes`, ...narratedNotes(offlineNotes)], migrateResultToData(offlineResult({}))); return 0; } - const offlineWarnings = warnDataHazards(diffResult.hazards); + const offlineWarnings = [...warnDataHazards(diffResult.hazards), ...warnAdvisories(diffResult.advisories)]; const emitResult = emit(diffResult.changes, { dialect: config.dialect, @@ -1761,13 +1850,17 @@ async function runD1Migrate( // 3. Load metadata. Best-effort config read for consumer providers; falls // back to default core+forge bundle if metaobjects.config.ts is absent. let d1LoadOptions: ReturnType = {}; + let d1GenConfig: MetaobjectsGenConfig | undefined; try { - d1LoadOptions = loadMemoryOptionsFrom(await loadMetaobjectsConfig(genRoot)); + d1GenConfig = await loadMetaobjectsConfig(genRoot); + d1LoadOptions = loadMemoryOptionsFrom(d1GenConfig); } catch (e) { if (e instanceof RemovedNamingConfigError) { log.error(e.message); return 2; } + const warning = configNotLoadedWarning(genRoot, e, "migrate"); + if (warning !== undefined) log.warn(warning); d1LoadOptions = {}; } @@ -1792,18 +1885,22 @@ async function runD1Migrate( reportLoadError(log, "migrate: failed to load metadata", err); return 1; } - warnReferentialActionConflicts(metadata, collection); + const schema = resolveSchemaBuild(collection, d1GenConfig, fmt); + if (typeof schema === "number") return schema; + warnReferentialActionConflicts(metadata, collection, schema); warnDecimalPrecision(metadata, collection, "d1"); // 4. Build expected schema + introspect actual. const expectedViews = buildProjectionViews(metadata, { dialect: "d1" }); - const built = buildExpectedSchemaWithProvenance(metadata, { dialect: "d1", views: expectedViews }); + const built = buildExpectedSchemaWithProvenance(metadata, { ...schema, dialect: "d1", views: expectedViews }); const scopeRc = refuseScopeMismatch(collection, () => built.provenance, fmt); if (scopeRc !== undefined) return scopeRc; // Per-command scope — both-sided, exactly as on the Kysely path above. const scoped = scopeExpectedSchema(built, collection.inMigrateScope, importedOption(collection)); const expected = scoped.snapshot; logOutOfScope(scoped.outOfScope, scoped.importedOutOfScope ?? [], fmt); + warnUngovernedFkTargets(scoped.ungovernedFkTargetViolations); + warnUnenforcedFkTargets("migrate", scoped.unenforcedFkTargetViolations); let actual; try { actual = await introspectD1({ diff --git a/server/typescript/packages/cli/src/commands/verify.ts b/server/typescript/packages/cli/src/commands/verify.ts index 4eac6c7432..6bd15b3e9f 100644 --- a/server/typescript/packages/cli/src/commands/verify.ts +++ b/server/typescript/packages/cli/src/commands/verify.ts @@ -7,11 +7,13 @@ // silently break a prompt" guarantee, enforced at the last fixed point before // the text ships. Required-slot misses are warnings (don't fail the build). +import { ENUM_LENGTH_HINT, isEnumLengthRefusal } from "../lib/enum-length-refusal.js"; import { existsSync } from "node:fs"; import { join, relative, resolve as resolvePath } from "node:path"; import { parseVerifyArgs, type MigrateFlags } from "../lib/args.js"; import { log } from "../lib/log.js"; import { requirementLedgerRows, type RequirementLedgerRow } from "../lib/requirement-ledger.js"; +import { warnUnenforcedFkTargets } from "../lib/fk-target-warning.js"; import { emitStructured, type OutputFormat } from "../lib/format.js"; import { antiPatternRows, missingBaseUrlRows, removedPropRows, ranSection, skippedSection, warnCapped, @@ -34,10 +36,12 @@ import { FileProvider, templateReferenceLine } from "../lib/file-provider.js"; import { derivePayloadFieldTree } from "../lib/payload-field-tree.js"; import { loadMemoryOptionsFrom, loadMetaobjectsConfig, RemovedNamingConfigError, resolveGenCollection, resolveGenConfigDir } from "../lib/load-metaobjects-config.js"; import { collectionLoadOptions } from "../lib/collection-load-options.js"; +import { schemaBuildOptions, type SchemaBuildOptions } from "../lib/schema-build-options.js"; import { computeCodegenDrift } from "../lib/codegen-drift.js"; import { hasLegacyDocsBlock, legacyDocsConfigMessage, REMOVED_VERIFY_DOCS } from "../lib/docs-removed.js"; import { buildAgentSchemaInput } from "../lib/agent-schema.js"; import { sourceRootsOf } from "./gen.js"; +import { schemaAdvisoryLines } from "../lib/schema-advisories.js"; import { checkRequirements, summariseRequirements, scanRequirements, type Diagnostic, } from "../lib/requirement-check.js"; @@ -92,6 +96,9 @@ import { postgresExprCanonProbe, type ShadowSchemaSource, withoutFlywayHistory, + isFkTargetNotUniqueError, + isInvalidCheckNameError, + isUnknownSchemaConventionsError, } from "@metaobjectsdev/migrate-ts"; import { DEFAULT_METAOBJECTS_DIR, @@ -121,8 +128,10 @@ import { physicalNamingOf, WIRE_DECIMALS, WIRE_NULL_MEMBERS, + WIRE_DURATIONS, WIRE_KEY_DECIMALS, WIRE_KEY_NULL_MEMBERS, + WIRE_KEY_DURATIONS, type WireFormat, } from "@metaobjectsdev/metadata"; import { @@ -168,6 +177,13 @@ function attrAsStringArray(attr: unknown): string[] { return []; } +/** An enum member longer than the project's enums.maxLength: a metadata defect, reported by name. */ +function refuseEnumLength(err: Error): number { + log.error(`verify: ${err.message}`); + log.error(`verify: ${ENUM_LENGTH_HINT}`); + return 1; +} + export async function verifyCommand( args: string[], cwd: string, @@ -395,6 +411,19 @@ export async function verifyCommand( return 1; } + // The project's schema settings, resolved once for every expected-schema build and drift + // computation below. A `migrate.conventions` preset this version does not ship is a config + // error on every verify run, not only one that builds the expected schema (a typo'd preset + // otherwise passes `meta verify` and changes nothing until `meta migrate`). + let schema: SchemaBuildOptions; + try { + schema = schemaBuildOptions(collection, forgeConfig); + } catch (err) { + if (!isUnknownSchemaConventionsError(err)) throw err; + log.error(`verify: ${err.message}`); + return 2; + } + // The schema gate governs exactly the objects `meta migrate` governs — ONE // declaration (`migrate.scope`), not a second key: a drift gate that fails on // tables migrate deliberately does not own is incoherent. Undefined ⇒ everything @@ -708,11 +737,19 @@ export async function verifyCommand( // passes no `governed` and gets the comparison exactly as it was. let governed: GovernedScope | undefined; if (schemaScope !== undefined) { - const built = buildExpectedSchemaWithProvenance(root, { - dialect, - views: buildProjectionViews(root, { dialect }), - }); - governed = scopeExpectedSchema(built, schemaScope, importedOption(collection)); + try { + const built = buildExpectedSchemaWithProvenance(root, { + ...schema, + dialect, + views: buildProjectionViews(root, { dialect }), + }); + governed = scopeExpectedSchema(built, schemaScope, importedOption(collection)); + } catch (err) { + if (isEnumLengthRefusal(err)) return refuseEnumLength(err); + if (isInvalidCheckNameError(err)) { log.error(`verify: ${err.message}`); return 1; } + if (isFkTargetNotUniqueError(err)) return refuseFkTarget(err); + throw err; + } } // `verifyReplay` calls `applyPending` itself. That is NOT a second replay: the @@ -1072,6 +1109,7 @@ export async function verifyCommand( unindexedFks = scanForUnindexedForeignKeys(root, { dialect: scanDialect, skip: collection.imported, + schema, }); } } catch { @@ -1084,7 +1122,7 @@ export async function verifyCommand( // cascade) silently has no effect. Same discipline as its siblings. let actionConflicts: ReferentialActionConflictFinding[] = []; try { - actionConflicts = scanForReferentialActionConflicts(root, { skip: collection.imported }); + actionConflicts = scanForReferentialActionConflicts(root, { skip: collection.imported, schema }); } catch { // advisory only — a model the schema builder refuses is migrate's to report. } @@ -1308,13 +1346,23 @@ export async function verifyCommand( // work at the top of `verifyCommand`, because `migrate.scope` governs only the // schema gate: a stale pattern must not fail a `--templates` run that never // consults it. - const scopeMismatch = migrateScopeMismatch(collection, () => { - const dialect: Dialect = usingD1 ? "d1" : (flags.dialect ?? inferDialect(flags.db as string)); - return buildExpectedSchemaWithProvenance(root, { - dialect, - views: buildProjectionViews(root, { dialect }), - }).provenance; - }); + let scopeMismatch: string | undefined; + try { + scopeMismatch = migrateScopeMismatch(collection, () => { + const dialect: Dialect = usingD1 ? "d1" : (flags.dialect ?? inferDialect(flags.db as string)); + return buildExpectedSchemaWithProvenance(root, { + ...schema, + dialect, + views: buildProjectionViews(root, { dialect }), + }).provenance; + }); + } catch (err) { + // Building the expected schema to check the scope raises the same metadata refusals + // the comparison below would; report them by name, not as an uncaught error. + if (isEnumLengthRefusal(err)) return refuseEnumLength(err); + if (isInvalidCheckNameError(err)) { log.error(`verify: ${err.message}`); return 1; } + throw err; + } if (scopeMismatch !== undefined) { log.error(`verify: ${scopeMismatch}`); return 2; @@ -1389,6 +1437,7 @@ export async function verifyCommand( actual = withoutFlywayHistory(actual); } driftResult = await computeDriftFromActual(actual, kysely.dialect, root, { + ...schema, allow, views: expectedViews, // The same question `meta migrate` asks, so the report names the change it plans. @@ -1402,11 +1451,20 @@ export async function verifyCommand( ...importedOption(collection), }); } catch (err) { + // Metadata refusals raised while building the expected side, not introspection failures. + if (isFkTargetNotUniqueError(err)) return refuseFkTarget(err); + if (isInvalidCheckNameError(err)) { + log.error(`verify: ${err.message}`); + return 1; + } + if (isEnumLengthRefusal(err)) return refuseEnumLength(err); schemaRunError = `failed to introspect ${kysely.displayUrl}: ${describeError(err)}`; log.error(`verify: ${schemaRunError}`); return 1; } + warnUnenforcedFkTargets("verify", driftResult.unenforcedFkTargetViolations); + if (driftResult.exprCanonPath !== undefined) { say(describeExprCanonPath(driftResult.exprCanonPath, driftResult.shadowUnavailable)); } @@ -1494,6 +1552,7 @@ export async function verifyCommand( let driftResult; try { driftResult = await computeDriftFromActual(actual, "d1", root, { + ...schema, allow, views: expectedViews, ...(schemaScope !== undefined ? { inScope: schemaScope } : {}), @@ -1631,6 +1690,8 @@ export async function verifyCommand( say(note); } + for (const line of schemaAdvisoryLines(driftResult.advisories)) log.warn(`verify: ${line}`); + const changes = driftResult.changes; schemaDrift = { changes: changes.map(toSchemaDriftRow), findings: ledgerDrift }; if (changes.length === 0 && ledgerDrift.length === 0) { @@ -1704,6 +1765,16 @@ export async function verifyCommand( return 2; } const config = forgeConfig; + // The schema page describes the schema `meta gen` would, so it takes this package's settings. + // A conventions preset that does not resolve fails here as it does in `meta gen`. + let codegenSchema: SchemaBuildOptions; + try { + codegenSchema = schemaBuildOptions(genCollection, config); + } catch (err) { + if (!isUnknownSchemaConventionsError(err)) throw err; + log.error(`verify --codegen: ${err.message}`); + return 2; + } let result; try { result = await computeCodegenDrift(config, codegenRoot, genConfigDir, genCollection.inScope, genCollection.ownFiles, { @@ -1712,7 +1783,9 @@ export async function verifyCommand( codegenRoot, config.dialect, (m) => log.warn(`verify --codegen: [agent-docs] ${m}`), + codegenSchema, ), + schemaSettings: codegenSchema, }); } catch (err) { log.error(`verify --codegen: regeneration failed: ${describeError(err)}`); @@ -1734,7 +1807,8 @@ export async function verifyCommand( const probe = await computeCodegenDrift(config, codegenRoot, genConfigDir, genCollection.inScope, genCollection.ownFiles, { sourceRoots: sourceRootsOf(genCollection), - agentSchema: () => buildAgentSchemaInput(codegenRoot, config.dialect, () => {}), + agentSchema: () => buildAgentSchemaInput(codegenRoot, config.dialect, () => {}, codegenSchema), + schemaSettings: codegenSchema, }); return probe.error === undefined && probe.clean; } finally { @@ -1889,6 +1963,7 @@ const DRIFT_PRESENTATION: Record { for (const decimals of WIRE_DECIMALS) { for (const nullMembers of WIRE_NULL_MEMBERS) { - if (decimals === used.decimals && nullMembers === used.nullMembers) continue; - const wire: WireFormat = { decimals, nullMembers }; - if (await reproduces(wire)) return wire; + for (const durations of WIRE_DURATIONS) { + if (decimals === used.decimals && nullMembers === used.nullMembers && durations === used.durations) continue; + const wire: WireFormat = { decimals, nullMembers, durations }; + if (await reproduces(wire)) return wire; + } } } return undefined; @@ -2001,7 +2080,7 @@ export function wireDiffersMessage(committed: WireFormat, used: WireFormat): str return ( `The committed output was generated with ${wireBlock(committed)}; this run used ${wireBlock(used)} ` + "(.metaobjects/config.json; each key defaults to native). Do not regenerate: that changes the JSON " + - "your API writes for decimals or for a value object's null members. Declare " + + "your API writes for decimals, durations or a value object's null members. Declare " + `${wireBlock(committed)} in .metaobjects/config.json, and 'meta gen' and 'meta verify --codegen' both read it.` ); } @@ -2209,3 +2288,10 @@ function buildVerifyPayload(input: { ], }; } + +/** An FK whose target is not a unique key, refused by name on every `--db` path. */ +function refuseFkTarget(err: Error): number { + log.error(`verify: ${err.message}`); + log.error("verify: declare an identity.secondary on the target entity with exactly the referenced fields, or reference its primary key"); + return 1; +} diff --git a/server/typescript/packages/cli/src/lib/agent-schema.ts b/server/typescript/packages/cli/src/lib/agent-schema.ts index 0da0e3bff2..125ca53f57 100644 --- a/server/typescript/packages/cli/src/lib/agent-schema.ts +++ b/server/typescript/packages/cli/src/lib/agent-schema.ts @@ -14,6 +14,7 @@ import type { AgentSchemaInput, Dialect, SchemaColumnLike } from "@metaobjectsde import { buildExpectedSchemaWithProvenance, columnTypeSql, qualifiedDbName } from "@metaobjectsdev/migrate-ts"; import type { MetaRoot } from "@metaobjectsdev/metadata"; import { describeError } from "./error-text.js"; +import type { SchemaBuildOptions } from "./schema-build-options.js"; /** * The schema for `agent/schema.md`, or undefined, after saying why through `warn`, when the @@ -24,6 +25,8 @@ export function buildAgentSchemaInput( root: MetaRoot, configured: Dialect | undefined, warn: (message: string) => void, + /** The project's schema settings, so the page describes the schema `meta migrate` builds. */ + schema: SchemaBuildOptions, ): AgentSchemaInput | undefined { // THE RUNNER'S OWN GUARD DECIDES THIS, not a default applied here. `DEFAULT_DIALECT` is // inert: `meta gen` refuses a model that emits database code with no dialect declared, so a @@ -45,6 +48,7 @@ export function buildAgentSchemaInput( const dialect = configured ?? DEFAULT_DIALECT; try { const built = buildExpectedSchemaWithProvenance(root, { + ...schema, dialect, // Views come from codegen-ts (migrate-ts never generates view DDL), exactly as // `verify --db` threads them. diff --git a/server/typescript/packages/cli/src/lib/allow.ts b/server/typescript/packages/cli/src/lib/allow.ts index a6db944138..7726f26eab 100644 --- a/server/typescript/packages/cli/src/lib/allow.ts +++ b/server/typescript/packages/cli/src/lib/allow.ts @@ -67,6 +67,8 @@ export function describeChange(c: Change): string { case "create-table": return c.table.name; case "drop-table": return c.table; case "rename-table": return `${c.from} → ${c.to}`; + case "rename-constraints": + return `${c.table} ${[...(c.constraintRenames ?? []), ...(c.indexRenames ?? [])].map((r) => `${r.from} → ${r.to}`).join(", ")}`; case "add-column": return `${c.table}.${c.column.name}`; case "drop-column": return c.readBy === undefined || c.readBy.length === 0 @@ -84,6 +86,11 @@ export function describeChange(c: Change): string { case "change-column-default": return `${c.table}.${c.column}`; case "change-column-identity": return `${c.table}.${c.column} (identity ${c.from ?? "none"} → ${c.to ?? "none"})`; case "change-comment": return `${c.column !== undefined ? `${c.table}.${c.column}` : c.table} (comment)`; + case "alter-sequence": return `${c.table}.${c.column} (sequence ${c.sequence}: ` + + [ + ...(c.toStart !== undefined ? [`start ${c.fromStart} → ${c.toStart}`] : []), + ...(c.toName !== undefined ? [`rename → ${c.toName}`] : []), + ].join(", ") + ")"; case "add-index": return `${c.table} idx ${c.index.name}`; case "drop-index": return `${c.table} idx ${c.index}`; case "add-fk": return `${c.table} fk ${c.fk.name}`; diff --git a/server/typescript/packages/cli/src/lib/catalog-listing.ts b/server/typescript/packages/cli/src/lib/catalog-listing.ts index 24d1689680..705328bf51 100644 --- a/server/typescript/packages/cli/src/lib/catalog-listing.ts +++ b/server/typescript/packages/cli/src/lib/catalog-listing.ts @@ -210,6 +210,8 @@ export interface CatalogListingOpts { sourceRoots?: readonly string[]; /** The physical schema `agent/schema.md` describes, as `meta gen` supplies it. */ agentSchema?: RunGenOpts["agentSchema"]; + /** The project's schema conventions and CHECK settings, as `meta gen` supplies them (Prisma). */ + schemaSettings?: RunGenOpts["schemaSettings"]; }; } @@ -226,7 +228,7 @@ async function probeOne( project: CatalogProject, probe: NonNullable, ): Promise<{ count: number; needsConfig?: string[] } | { error: string }> { - const { metadata, scope, sourceFiles, sourceRoots, agentSchema } = probe; + const { metadata, scope, sourceFiles, sourceRoots, agentSchema, schemaSettings } = probe; const count = async (config: MetaobjectsGenConfig): Promise => { const result = await runGen({ config: { ...config, generators: [entry.factory()] }, @@ -237,6 +239,7 @@ async function probeOne( ...(sourceFiles !== undefined ? { sourceFiles } : {}), ...(sourceRoots !== undefined ? { sourceRoots } : {}), ...(agentSchema !== undefined ? { agentSchema } : {}), + ...(schemaSettings !== undefined ? { schemaSettings } : {}), }); return result.files.length; }; diff --git a/server/typescript/packages/cli/src/lib/codegen-drift.ts b/server/typescript/packages/cli/src/lib/codegen-drift.ts index 334e02f512..c7d063e70e 100644 --- a/server/typescript/packages/cli/src/lib/codegen-drift.ts +++ b/server/typescript/packages/cli/src/lib/codegen-drift.ts @@ -187,7 +187,7 @@ export async function computeCodegenDrift( projectRoot: string, scope?: (fqn: string) => boolean, sourceFiles?: readonly string[], - run?: Pick, + run?: Pick, ): Promise { const root = isAbsolute(projectRoot) ? projectRoot : resolve(projectRoot); @@ -278,6 +278,7 @@ export async function computeCodegenDrift( ...(sourceFiles !== undefined ? { sourceFiles } : {}), ...(run?.sourceRoots !== undefined ? { sourceRoots: run.sourceRoots } : {}), ...(run?.agentSchema !== undefined ? { agentSchema: run.agentSchema } : {}), + ...(run?.schemaSettings !== undefined ? { schemaSettings: run.schemaSettings } : {}), }); // Where gen discards hand edits instead of keeping them (handEdits: "overwrite"). diff --git a/server/typescript/packages/cli/src/lib/collection-load-options.ts b/server/typescript/packages/cli/src/lib/collection-load-options.ts index c86d74433e..69f6173ff3 100644 --- a/server/typescript/packages/cli/src/lib/collection-load-options.ts +++ b/server/typescript/packages/cli/src/lib/collection-load-options.ts @@ -12,6 +12,7 @@ // `ERR_DEPENDENCY_PACKAGE_NOT_OWNED` refusal, so a node declared into a // dependency's package is silently excluded from that command's output instead // of being refused by name. +import { DEFAULT_ENUM_DEFAULTS } from "@metaobjectsdev/metadata"; import type { Collection, LoadMemoryOptions } from "@metaobjectsdev/sdk"; /** @@ -26,7 +27,7 @@ import type { Collection, LoadMemoryOptions } from "@metaobjectsdev/sdk"; */ export function collectionLoadOptions( collection: Collection, -): Required> { +): Required> { return { files: collection.files, fileIds: collection.fileIds, @@ -40,5 +41,8 @@ export function collectionLoadOptions( libraries: collection.libraries, physicalNaming: collection.physicalNaming, wireFormat: collection.wireFormat, + enumDefaults: collection.enumDefaults ?? DEFAULT_ENUM_DEFAULTS, + // The collection's migrate settings (`migrate.conventions`, `migrate.checks`) are not load + // options: they reach the expected-schema builds through `schemaBuildOptions`. }; } diff --git a/server/typescript/packages/cli/src/lib/enum-length-refusal.ts b/server/typescript/packages/cli/src/lib/enum-length-refusal.ts new file mode 100644 index 0000000000..9f20d669ef --- /dev/null +++ b/server/typescript/packages/cli/src/lib/enum-length-refusal.ts @@ -0,0 +1,16 @@ +/** + * A string-stored enum member longer than the project's `enums.maxLength`: core's + * resolveEnumMaxLength refuses it while the expected schema is built. A metadata (or config) + * defect, so `meta migrate` and `meta verify` report it by name rather than as an + * unexpected error. Matched by name and code, not `instanceof`: two physical copies of + * @metaobjectsdev/metadata in one process give the class two identities. + */ +export const ENUM_LENGTH_CODE = "ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH"; + +export const ENUM_LENGTH_HINT = + "give the enum field its own @maxLength that fits its longest member, raise enums.maxLength in .metaobjects/config.json, or shorten the member"; + +export function isEnumLengthRefusal(err: unknown): err is Error { + return err instanceof Error && err.name === "MetaModelError" + && (err as { code?: unknown }).code === ENUM_LENGTH_CODE; +} diff --git a/server/typescript/packages/cli/src/lib/fk-index-advisory.ts b/server/typescript/packages/cli/src/lib/fk-index-advisory.ts index 4a3d94ff86..4a375ca589 100644 --- a/server/typescript/packages/cli/src/lib/fk-index-advisory.ts +++ b/server/typescript/packages/cli/src/lib/fk-index-advisory.ts @@ -30,6 +30,7 @@ import { type Dialect, type TableDescriptor, } from "@metaobjectsdev/migrate-ts"; +import type { SchemaBuildOptions } from "./schema-build-options.js"; export interface UnindexedFkFinding { /** The metadata file declaring the entity ("" when the loader recorded none). */ @@ -43,6 +44,8 @@ export interface UnindexedFkOptions { dialect: Dialect; /** Skip an entity by FQN — imported (dependency) metadata is not the adopter's to index. */ skip?: (fqn: string) => boolean; + /** The project's schema settings: the conventions decide which FK indexes the schema already has. */ + schema: SchemaBuildOptions; } const INDEX_LOOKUP = `${TYPE_INDEX}${TYPE_SUBTYPE_SEPARATOR}${INDEX_SUBTYPE_LOOKUP}`; @@ -63,6 +66,7 @@ function pascal(s: string): string { export function scanForUnindexedForeignKeys(root: MetaData, opts: UnindexedFkOptions): UnindexedFkFinding[] { const { snapshot, provenance } = buildExpectedSchemaWithProvenance(root, { + ...opts.schema, dialect: opts.dialect, }); // ADR-0058: the same column rule the expected schema just used, from the loaded root. diff --git a/server/typescript/packages/cli/src/lib/fk-target-warning.ts b/server/typescript/packages/cli/src/lib/fk-target-warning.ts new file mode 100644 index 0000000000..058397b236 --- /dev/null +++ b/server/typescript/packages/cli/src/lib/fk-target-warning.ts @@ -0,0 +1,13 @@ +import { FkTargetNotUniqueError, type FkTargetViolation } from "@metaobjectsdev/migrate-ts"; +import { log } from "./log.js"; + +/** + * FKs whose target is not a unique key, on a SQLite database that does not enforce the target's + * uniqueness when the table is created: reported, not refused. `prefix` is the command name that + * leads each line (`migrate`, `verify`). + */ +export function warnUnenforcedFkTargets(prefix: string, violations: readonly FkTargetViolation[] | undefined): void { + if (violations === undefined || violations.length === 0) return; + log.warn(`${prefix}: ${new FkTargetNotUniqueError([...violations]).message}`); + log.warn(`${prefix}: SQLite creates the table anyway and checks foreign keys only with PRAGMA foreign_keys on, so nothing is refused; a write with it on fails with "foreign key mismatch".`); +} diff --git a/server/typescript/packages/cli/src/lib/gen-migrate-advice.ts b/server/typescript/packages/cli/src/lib/gen-migrate-advice.ts index 14827acd58..068e9cd1db 100644 --- a/server/typescript/packages/cli/src/lib/gen-migrate-advice.ts +++ b/server/typescript/packages/cli/src/lib/gen-migrate-advice.ts @@ -30,6 +30,7 @@ import { type Change, type D1Binding, } from "@metaobjectsdev/migrate-ts"; +import type { SchemaBuildOptions } from "./schema-build-options.js"; /** The FQN separator; an object's short name is the segment after the last one. */ const FQN_SEPARATOR = "::"; @@ -52,6 +53,8 @@ export interface GenMigrateAdviceInput { migrateFormat: "default" | "flyway"; /** Paths (any form) of the files this run wrote, merged or removed. */ changedFiles: readonly string[]; + /** The project's schema settings, so the schema compared is the one `meta migrate` builds. */ + schema: SchemaBuildOptions; } /** The next-step line, or undefined when this run cannot have changed the schema. */ @@ -65,6 +68,7 @@ export async function genMigrateAdvice(input: GenMigrateAdviceInput): Promise; try { provenance = buildExpectedSchemaWithProvenance(input.metadata, { + ...input.schema, dialect: "postgres", }).provenance; } catch { diff --git a/server/typescript/packages/cli/src/lib/load-metaobjects-config.ts b/server/typescript/packages/cli/src/lib/load-metaobjects-config.ts index 606dc55fc7..1053a219d8 100644 --- a/server/typescript/packages/cli/src/lib/load-metaobjects-config.ts +++ b/server/typescript/packages/cli/src/lib/load-metaobjects-config.ts @@ -52,6 +52,13 @@ const CLI_PKG_PATHS: Record = { dist: "node_modules/@metaobjectsdev/metadata/dist/index.js", src: "node_modules/@metaobjectsdev/metadata/src/index.ts", }, + // A config that supplies its own schema conventions starts from a preset + // (`efcoreConventions`) — aliased like the other codegen specifiers so the project + // needn't declare migrate-ts directly, and a global CLI still resolves it. + "@metaobjectsdev/migrate-ts": { + dist: "node_modules/@metaobjectsdev/migrate-ts/dist/index.js", + src: "node_modules/@metaobjectsdev/migrate-ts/src/index.ts", + }, "@metaobjectsdev/codegen-ts/generators": { dist: "node_modules/@metaobjectsdev/codegen-ts/dist/generators/index.js", src: "node_modules/@metaobjectsdev/codegen-ts/src/generators/index.ts", @@ -203,6 +210,18 @@ function rewriteImportSpecifiers(source: string, aliasMap: Record | undefined, ): { providers?: readonly MetaDataTypeProvider[] } { + // `schemaConventions` is not a load option: it reaches the expected-schema builds through + // `schemaBuildOptions` (schema-build-options.ts), never through the loaded model. return { ...(cfg?.providers !== undefined ? { providers: cfg.providers } : {}), }; diff --git a/server/typescript/packages/cli/src/lib/referential-action-advisory.ts b/server/typescript/packages/cli/src/lib/referential-action-advisory.ts index b93c9245cf..71f54366ee 100644 --- a/server/typescript/packages/cli/src/lib/referential-action-advisory.ts +++ b/server/typescript/packages/cli/src/lib/referential-action-advisory.ts @@ -20,6 +20,7 @@ import { describeReferentialActionConflict, type ReferentialActionConflict, } from "@metaobjectsdev/migrate-ts"; +import type { SchemaBuildOptions } from "./schema-build-options.js"; export interface ReferentialActionConflictFinding { /** The metadata file declaring the FK-owning entity ("" when the loader recorded none). */ @@ -32,14 +33,16 @@ export interface ReferentialActionConflictFinding { export interface ReferentialActionConflictOptions { /** Skip an entity by FQN — imported (dependency) metadata is not the adopter's to fix. */ skip?: (fqn: string) => boolean; + /** The project's schema settings: the build that reports the conflicts is the one migrate runs. */ + schema: SchemaBuildOptions; } export function scanForReferentialActionConflicts( root: MetaData, - opts: ReferentialActionConflictOptions = {}, + opts: ReferentialActionConflictOptions, ): ReferentialActionConflictFinding[] { const conflicts: ReferentialActionConflict[] = []; - buildExpectedSchema(root, { onReferentialActionConflict: (c) => conflicts.push(c) }); + buildExpectedSchema(root, { ...opts.schema, onReferentialActionConflict: (c) => conflicts.push(c) }); const out: ReferentialActionConflictFinding[] = []; const seen = new Set(); diff --git a/server/typescript/packages/cli/src/lib/schema-advisories.ts b/server/typescript/packages/cli/src/lib/schema-advisories.ts new file mode 100644 index 0000000000..e30c7f985a --- /dev/null +++ b/server/typescript/packages/cli/src/lib/schema-advisories.ts @@ -0,0 +1,19 @@ +// Advice from the schema diff that is not drift (migrate-ts `SchemaAdvisory`): `meta migrate` +// and `meta verify` print the same sentence for each. +import type { SchemaAdvisory } from "@metaobjectsdev/migrate-ts"; + +/** One sentence per advisory, each naming the column and what to do about it. */ +export function schemaAdvisoryLines(advisories: readonly SchemaAdvisory[] | undefined): string[] { + return (advisories ?? []).map((a) => { + const t = a.schema !== undefined ? `${a.schema}.${a.table}` : a.table; + switch (a.kind) { + case "interval-as-string": + return `${t}.${a.column} is a Postgres interval the metadata does not declare as a field.duration; ` + + `it is compared as text, so it is not drift. Model it as field.duration for the native type, ` + + `span ordering and range filters.`; + case "sequence-not-applied": + return `${t}.${a.column}: SQLite and D1 have no sequence, so the primary key's @sequenceStart / ` + + `@sequenceName is not applied and the key counts from 1.`; + } + }); +} diff --git a/server/typescript/packages/cli/src/lib/schema-build-options.ts b/server/typescript/packages/cli/src/lib/schema-build-options.ts new file mode 100644 index 0000000000..8067c6e57b --- /dev/null +++ b/server/typescript/packages/cli/src/lib/schema-build-options.ts @@ -0,0 +1,56 @@ +// server/typescript/packages/cli/src/lib/schema-build-options.ts +// +// The project settings every expected-schema build takes, resolved in ONE place. +// +// The schema conventions (`migrate.conventions` in `.metaobjects/config.json`, or a +// conventions object from `metaobjects.config.ts`) and `migrate.checks.skipRedundant` are +// migrate settings, not facts about the model, so the loaded model does not carry them: +// each command resolves them here and passes the result to every build and drift +// computation. The helpers that build an expected schema take it as a REQUIRED +// parameter, so a call site that forgets it is a compile error rather than a silent +// default — the way #333 happened to `libraries` (see `loadMemoryOptionsFrom`). +import type { MetaobjectsGenConfig } from "@metaobjectsdev/codegen-ts"; +import { isUnknownSchemaConventionsError, resolveSchemaConventions, type SchemaConventions } from "@metaobjectsdev/migrate-ts"; +import type { Collection } from "@metaobjectsdev/sdk"; + +/** What an expected-schema build needs from the project, every member resolved. */ +export interface SchemaBuildOptions { + conventions: SchemaConventions; + skipRedundantChecks: boolean; +} + +/** + * The project's schema settings. A conventions object from `metaobjects.config.ts` wins over + * the `migrate.conventions` preset name. Throws `UnknownSchemaConventionsError` for a preset + * this version does not ship and `InvalidSchemaConventionsError` for an object missing a + * member (`isUnknownSchemaConventionsError` recognises both). + */ +export function schemaBuildOptions( + collection: Pick, + tsConfig: Pick | undefined, +): SchemaBuildOptions { + return { + conventions: resolveSchemaConventions(tsConfig?.schemaConventions ?? collection.schemaConventions), + skipRedundantChecks: collection.skipRedundantChecks, + }; +} + +/** + * {@link schemaBuildOptions} for a command that only ADVISES from the schema (`meta gen`'s + * schema page and next-step line): conventions that do not resolve are warned about and the + * result is undefined, so the caller skips what needed them. `meta migrate` and `meta verify` + * refuse the same config with exit 2. + */ +export function schemaBuildOptionsOrWarn( + collection: Pick, + tsConfig: Pick | undefined, + warn: (message: string) => void, +): SchemaBuildOptions | undefined { + try { + return schemaBuildOptions(collection, tsConfig); + } catch (err) { + if (!isUnknownSchemaConventionsError(err)) throw err; + warn(`${err.message}; the schema-derived output (agent/schema.md, the migrate advice) is skipped`); + return undefined; + } +} diff --git a/server/typescript/packages/cli/test/integration/identity-sequence-pg.test.ts b/server/typescript/packages/cli/test/integration/identity-sequence-pg.test.ts new file mode 100644 index 0000000000..d587ae7326 --- /dev/null +++ b/server/typescript/packages/cli/test/integration/identity-sequence-pg.test.ts @@ -0,0 +1,131 @@ +/** + * identity.primary @sequenceStart / @sequenceName through the CLI against a real Postgres, on + * the shadow path `meta verify --db` and `meta migrate --from-db` take by default (env-gated on + * MIGRATE_TS_PG_URL; a throwaway database per test). + * + * - A model that declares NO sequence sees no change from one: a live sequence left behind by + * a table rename, with a non-default start, verifies clean and plans nothing, and a fresh + * CREATE stays plain `GENERATED BY DEFAULT AS IDENTITY`. + * - A declared start and name that differ from the live sequence are altered in place, and the + * counter really moves: the next insert takes the declared start. + */ +import { describe, test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import pg from "pg"; +import { run } from "../../src/index.js"; + +const PG_URL = process.env.MIGRATE_TS_PG_URL; + +function model(pk: Record): string { + return JSON.stringify({ "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Ticket", children: [ + { "source.rdb": { "@table": "Ticket" } }, + { "field.int": { name: "id", "@column": "Id" } }, + { "field.string": { name: "label", "@column": "Label" } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment", ...pk } }, + ] } }, + ] } }); +} + +async function withDatabase(fn: (url: string, client: pg.Client) => Promise): Promise { + const admin = new pg.Client({ connectionString: PG_URL }); + await admin.connect(); + const db = `mt_seq_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; + await admin.query(`CREATE DATABASE ${db}`); + const url = new URL(PG_URL!); + url.pathname = `/${db}`; + const client = new pg.Client({ connectionString: url.toString() }); + await client.connect(); + try { + await fn(url.toString(), client); + } finally { + await client.end(); + await admin.query(`DROP DATABASE IF EXISTS ${db} WITH (FORCE)`); + await admin.end(); + } +} + +function project(pk: Record): string { + const root = mkdtempSync(join(tmpdir(), "identity-sequence-pg-")); + mkdirSync(join(root, "metaobjects"), { recursive: true }); + mkdirSync(join(root, ".metaobjects", "migrations"), { recursive: true }); + writeFileSync(join(root, "metaobjects", "meta.acme.json"), model(pk), "utf8"); + writeFileSync(join(root, ".metaobjects", "config.json"), JSON.stringify({ schema_version: 1, migrate: { dialect: "postgres" } }), "utf8"); + return root; +} + +async function captured(argv: string[]): Promise<{ exit: number; out: string }> { + const lines: string[] = []; + const keep = (...a: unknown[]): void => { lines.push(a.map(String).join(" ")); }; + const orig = [console.log, console.error, console.warn] as const; + console.log = keep; console.error = keep; console.warn = keep; + try { + return { exit: await run(argv), out: lines.join("\n") }; + } finally { + [console.log, console.error, console.warn] = orig; + } +} + +describe("identity.primary sequence on a real Postgres (env-gated)", () => { + if (PG_URL === undefined || PG_URL.length === 0) { + test.skip("skipped — MIGRATE_TS_PG_URL not set", () => {}); + return; + } + + test("a model that declares no sequence: a renamed table's live sequence verifies clean and plans nothing", async () => { + await withDatabase(async (url, client) => { + await client.query(`CREATE TABLE "OldTicket" ("Id" integer GENERATED BY DEFAULT AS IDENTITY (START WITH 1000) PRIMARY KEY, "Label" text)`); + await client.query(`ALTER TABLE "OldTicket" RENAME TO "Ticket"`); + // The primary key's name is compared too, so it follows the table; the sequence keeps its old name. + await client.query(`ALTER TABLE "Ticket" RENAME CONSTRAINT "OldTicket_pkey" TO "Ticket_pkey"`); + const root = project({}); + try { + const verify = await captured(["verify", "--cwd", root, "--db", url]); + expect(verify.out).not.toContain("alter-sequence"); + expect(verify.exit).toBe(0); + const plan = await captured(["migrate", "--cwd", root, "--db", url, "--from-db", "--dry-run"]); + expect(plan.out).not.toContain("SEQUENCE"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + }); + + test("a model that declares no sequence: a fresh CREATE is plain IDENTITY", async () => { + await withDatabase(async (url) => { + const root = project({}); + try { + const plan = await captured(["migrate", "--cwd", root, "--db", url, "--from-db", "--dry-run"]); + expect(plan.out).toContain("GENERATED BY DEFAULT AS IDENTITY"); + expect(plan.out).not.toContain("SEQUENCE NAME"); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + }); + + test("a declared start and name that differ are altered in place, and the next insert takes the start", async () => { + await withDatabase(async (url, client) => { + await client.query(`CREATE TABLE "Ticket" ("Id" integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, "Label" text)`); + await client.query(`INSERT INTO "Ticket" ("Label") VALUES ('seeded')`); + const root = project({ "@sequenceStart": 1000000000, "@sequenceName": "Legacy_Id_seq" }); + try { + const drift = await captured(["verify", "--cwd", root, "--db", url]); + expect(drift.exit).toBe(1); + expect(drift.out).toContain("alter-sequence"); + const applied = await captured(["migrate", "--cwd", root, "--db", url, "--from-db", "--slug", "seq", "--apply"]); + expect(applied.exit).toBe(0); + const { rows } = await client.query(`INSERT INTO "Ticket" ("Label") VALUES ('next') RETURNING "Id"`); + expect(Number(rows[0].Id)).toBe(1000000000); + const seq = await client.query(`SELECT pg_get_serial_sequence('"Ticket"', 'Id') AS s`); + expect(seq.rows[0].s).toBe('public."Legacy_Id_seq"'); + const clean = await captured(["verify", "--cwd", root, "--db", url]); + expect(clean.exit).toBe(0); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + }); +}); diff --git a/server/typescript/packages/cli/test/integration/verify-codegen-wire.test.ts b/server/typescript/packages/cli/test/integration/verify-codegen-wire.test.ts index 40ca34b00c..166e7f88b6 100644 --- a/server/typescript/packages/cli/test/integration/verify-codegen-wire.test.ts +++ b/server/typescript/packages/cli/test/integration/verify-codegen-wire.test.ts @@ -84,8 +84,8 @@ describe("meta verify --codegen — the wire setting", () => { out = []; err = []; expect(await run(["verify", "--cwd", root, "--codegen"])).toBe(1); expect(all()).toContain( - 'The committed output was generated with "wire": { "decimals": "string", "nullMembers": "omit" }; ' + - 'this run used "wire": { "decimals": "native", "nullMembers": "native" }', + 'The committed output was generated with "wire": { "decimals": "string", "nullMembers": "omit", "durations": "native" }; ' + + 'this run used "wire": { "decimals": "native", "nullMembers": "native", "durations": "native" }', ); expect(all()).toContain("Do not regenerate"); expect(all()).not.toContain("Run 'meta gen' to regenerate"); diff --git a/server/typescript/packages/cli/test/migrate-checks-skip-redundant.test.ts b/server/typescript/packages/cli/test/migrate-checks-skip-redundant.test.ts new file mode 100644 index 0000000000..12c0789ab3 --- /dev/null +++ b/server/typescript/packages/cli/test/migrate-checks-skip-redundant.test.ts @@ -0,0 +1,121 @@ +/** + * `migrate.checks.skipRedundant` in .metaobjects/config.json reaches `meta migrate` and + * `meta verify --db`: a `validator.length` minimum of 0 is left out of the CHECK (on SQLite + * the maximum stays, since SQLite does not enforce VARCHAR(n)). It is not a schema + * convention: `migrate.conventions: "efcore"` alone emits the CHECK whole. A malformed + * `checks` block is refused as a config error. + */ +import { describe, test, expect, afterAll, beforeEach, afterEach, spyOn } from "bun:test"; +import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { migrateCommand } from "../src/commands/migrate.js"; +import { verifyCommand } from "../src/commands/verify.js"; +import { log } from "../src/lib/log.js"; + +const dirs: string[] = []; +afterAll(async () => { for (const d of dirs) await rm(d, { recursive: true, force: true }); }); + +const MODEL = JSON.stringify({ + "metadata.root": { + package: "acme::platform", + children: [ + { "object.entity": { name: "Ticket", children: [ + { "source.rdb": { name: "src", "@table": "tickets" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "code", "@maxLength": 3, children: [ + { "validator.length": { name: "len", "@min": 0, "@max": 3 } }, + ] } }, + { "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } }, + ] } }, + ], + }, +}); + +async function writeConfig(root: string, migrate: Record): Promise { + await writeFile(join(root, ".metaobjects", "config.json"), + JSON.stringify({ schema_version: 1, migrate: { dialect: "sqlite", ...migrate } }), "utf8"); +} + +async function project(migrate: Record): Promise { + const root = await mkdtemp(join(tmpdir(), "checks-skip-redundant-")); + dirs.push(root); + await mkdir(join(root, "metaobjects"), { recursive: true }); + await writeFile(join(root, "metaobjects", "meta.platform.json"), MODEL, "utf8"); + await mkdir(join(root, ".metaobjects", "migrations"), { recursive: true }); + await writeConfig(root, migrate); + return root; +} + +let out: string[] = []; +let spies: ReturnType[] = []; +beforeEach(() => { + out = []; + const push = (m: unknown) => { out.push(String(m)); }; + spies = [ + spyOn(log, "info").mockImplementation(push), + spyOn(log, "error").mockImplementation(push), + spyOn(log, "warn").mockImplementation(push), + spyOn(console, "log").mockImplementation(push), + spyOn(console, "error").mockImplementation(push), + ]; +}); +afterEach(() => { for (const s of spies) s.mockRestore(); }); + +const db = (root: string) => `file:${join(root, "t.db")}`; +const dryRun = (root: string) => + migrateCommand(["--from-db", "--db", db(root), "--dialect", "sqlite", "--dry-run"], root); + +const WHOLE = 'CHECK (length("code") >= 0 AND length("code") <= 3)'; +const SKIPPED = 'CHECK (length("code") <= 3)'; + +describe("migrate.checks.skipRedundant", () => { + test("meta migrate leaves the redundant part of the CHECK out", async () => { + const root = await project({ checks: { skipRedundant: true } }); + + expect(await dryRun(root)).toBe(0); + const text = out.join("\n"); + expect(text).toContain(SKIPPED); + expect(text).not.toContain(WHOLE); + }); + + test("absent, and under the efcore conventions alone, the CHECK is emitted whole", async () => { + for (const migrate of [{}, { conventions: "efcore" }, { checks: { skipRedundant: false } }]) { + out = []; + const root = await project(migrate); + expect(await dryRun(root)).toBe(0); + expect(out.join("\n")).toContain(WHOLE); + } + }); + + test("meta verify --db compares against the same setting", async () => { + const root = await project({ checks: { skipRedundant: true } }); + expect(await migrateCommand(["--from-db", "--db", db(root), "--dialect", "sqlite", "--slug", "init", "--apply"], root)).toBe(0); + + out = []; + expect(await verifyCommand(["--db", db(root), "--dialect", "sqlite"], root)).toBe(0); + + // The database was built without the redundant part; a project that wants it reports drift. + await writeConfig(root, {}); + out = []; + expect(await verifyCommand(["--db", db(root), "--dialect", "sqlite"], root)).toBe(1); + expect(out.join("\n")).toContain("tickets_code_length_chk"); + }); + + test("a malformed checks block is refused, naming the key", async () => { + for (const [checks, says] of [ + [true, "migrate.checks"], + [{ skipRedundnat: true }, "skipRedundnat"], + [{ skipRedundant: "yes" }, "migrate.checks.skipRedundant"], + ] as const) { + out = []; + const root = await project({ checks }); + const migrateCode = await dryRun(root); + expect(migrateCode).not.toBe(0); + expect(out.join("\n")).toContain(says); + out = []; + expect(await verifyCommand([], root)).not.toBe(0); + expect(out.join("\n")).toContain(says); + } + }); +}); diff --git a/server/typescript/packages/cli/test/migrate-enum-max-length.test.ts b/server/typescript/packages/cli/test/migrate-enum-max-length.test.ts new file mode 100644 index 0000000000..590d7afea2 --- /dev/null +++ b/server/typescript/packages/cli/test/migrate-enum-max-length.test.ts @@ -0,0 +1,65 @@ +/** + * `enums.maxLength` in .metaobjects/config.json reaches `meta migrate` through the + * project-aware load: an enum with no @maxLength gets VARCHAR(n), and a member longer than + * the default is refused by name — a metadata defect, not an "unexpected error". + */ +import { describe, test, expect, afterAll, beforeEach, afterEach, spyOn } from "bun:test"; +import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { migrateCommand } from "../src/commands/migrate.js"; +import { log } from "../src/lib/log.js"; + +const dirs: string[] = []; +afterAll(async () => { for (const d of dirs) await rm(d, { recursive: true, force: true }); }); + +function model(values: string[]): string { + return JSON.stringify({ "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Ticket", children: [ + { "source.rdb": { "@table": "tickets" } }, + { "field.long": { name: "id" } }, + { "field.enum": { name: "status", "@values": values } }, + { "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } }, + ] } }, + ] } }); +} + +async function project(values: string[], maxLength: number): Promise { + const root = await mkdtemp(join(tmpdir(), "enum-max-length-")); + dirs.push(root); + await mkdir(join(root, "metaobjects"), { recursive: true }); + await writeFile(join(root, "metaobjects", "meta.acme.json"), model(values), "utf8"); + await mkdir(join(root, ".metaobjects", "migrations"), { recursive: true }); + await writeFile(join(root, ".metaobjects", "config.json"), + JSON.stringify({ schema_version: 1, enums: { maxLength }, migrate: { dialect: "postgres" } }), "utf8"); + return root; +} + +let out: string[] = []; +let spies: ReturnType[] = []; +beforeEach(() => { + out = []; + spies = (["info", "error", "warn"] as const).map((k) => + spyOn(log, k).mockImplementation((m: string) => { out.push(String(m)); })); +}); +afterEach(() => { for (const s of spies) s.mockRestore(); }); + +const dryRun = (root: string) => + migrateCommand(["--from-db", "--db", `file:${join(root, "t.db")}`, "--dialect", "sqlite", "--dry-run"], root); + +describe("meta migrate with enums.maxLength", () => { + test("an enum with no @maxLength gets the project's length", async () => { + const root = await project(["OPEN", "CLOSED"], 24); + expect(await dryRun(root)).toBe(0); + expect(out.join("\n")).toMatch(/"status" (VARCHAR|varchar)\(24\)/); + }); + + test("a member longer than the default is refused by name, not as an unexpected error", async () => { + const root = await project(["OPEN", "IN_PROGRESS"], 10); + const code = await dryRun(root); + const text = out.join("\n"); + expect(code).toBe(1); + expect(text).toContain('field.enum "Ticket.status" has the member "IN_PROGRESS"'); + expect(text).not.toContain("unexpected error"); + }); +}); diff --git a/server/typescript/packages/cli/test/migrate-fk-target-not-unique.test.ts b/server/typescript/packages/cli/test/migrate-fk-target-not-unique.test.ts new file mode 100644 index 0000000000..15fe6b68ab --- /dev/null +++ b/server/typescript/packages/cli/test/migrate-fk-target-not-unique.test.ts @@ -0,0 +1,136 @@ +/** + * An FK whose target columns are not a unique key is a metadata defect, not a tool + * failure. On Postgres `meta migrate` refuses it with the engine's message and a hint, the + * way it refuses two declarations that generate one database name, never as an "unexpected + * error". On SQLite the DDL is accepted, so it is warned and the run goes on. + */ +import { describe, test, expect, afterAll, beforeEach, afterEach, spyOn } from "bun:test"; +import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import pg from "pg"; +import { migrateCommand } from "../src/commands/migrate.js"; +import { log } from "../src/lib/log.js"; + +const dirs: string[] = []; +afterAll(async () => { for (const d of dirs) await rm(d, { recursive: true, force: true }); }); + +const MODEL = JSON.stringify({ + "metadata.root": { + package: "acme::platform", + children: [ + { + "object.entity": { + name: "Person", + children: [ + { "source.rdb": { name: "src", "@table": "people" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "code" } }, + { "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } }, + ], + }, + }, + { + "object.entity": { + name: "Visit", + children: [ + { "source.rdb": { name: "src", "@table": "visits" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "personCode" } }, + { "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } }, + { "identity.reference": { name: "ref_person", "@fields": ["personCode"], "@references": "Person.code" } }, + ], + }, + }, + ], + }, +}); + +async function project(migrate: Record = { dialect: "sqlite" }): Promise { + const root = await mkdtemp(join(tmpdir(), "fk-target-unique-")); + dirs.push(root); + await mkdir(join(root, "metaobjects"), { recursive: true }); + await writeFile(join(root, "metaobjects", "meta.platform.json"), MODEL, "utf8"); + await mkdir(join(root, ".metaobjects", "migrations"), { recursive: true }); + await writeFile(join(root, ".metaobjects", "config.json"), JSON.stringify({ schema_version: 1, migrate }), "utf8"); + return root; +} + +let out: string[] = []; +let infoSpy: ReturnType; +let errSpy: ReturnType; +let warnSpy: ReturnType; +beforeEach(() => { + out = []; + infoSpy = spyOn(log, "info").mockImplementation((m: string) => { out.push(String(m)); }); + errSpy = spyOn(log, "error").mockImplementation((m: string) => { out.push(String(m)); }); + warnSpy = spyOn(log, "warn").mockImplementation((m: string) => { out.push(String(m)); }); +}); +afterEach(() => { infoSpy.mockRestore(); errSpy.mockRestore(); warnSpy.mockRestore(); }); + +describe("meta migrate on SQLite: an FK to a non-unique target is warned, not refused", () => { + // SQLite accepts the CREATE TABLE and checks the FK only on a write with PRAGMA + // foreign_keys on, so a refusal here would stop an estate that works (CR-9). + test("the reference and the fix are named, and the migration is still planned", async () => { + const root = await project(); + const code = await migrateCommand(["--from-db", "--db", `file:${join(root, "t.db")}`, "--dialect", "sqlite", "--dry-run"], root); + + const text = out.join("\n"); + expect(code).toBe(0); + expect(text).toContain('reference "ref_person" targets [code] on "Person"'); + expect(text).toContain("identity.secondary"); + expect(text).toContain("PRAGMA foreign_keys"); + expect(text).not.toContain("unexpected error"); + expect(text).toContain('CREATE TABLE "visits"'); + }); +}); + +const PG_URL = process.env.MIGRATE_TS_PG_URL; + +async function withPgDatabase(fn: (url: string) => Promise): Promise { + const admin = new pg.Client({ connectionString: PG_URL }); + await admin.connect(); + const db = `mt_fkt_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; + await admin.query(`CREATE DATABASE ${db}`); + const url = new URL(PG_URL!); + url.pathname = `/${db}`; + try { + await fn(url.toString()); + } finally { + await admin.query(`DROP DATABASE IF EXISTS ${db} WITH (FORCE)`); + await admin.end(); + } +} + +describe("meta migrate on Postgres refuses an FK to a non-unique target (env-gated)", () => { + if (PG_URL === undefined || PG_URL.length === 0) { + test.skip("skipped — MIGRATE_TS_PG_URL not set", () => {}); + return; + } + + test("exits 1 with the reference named and the fix, not as an unexpected error", async () => { + await withPgDatabase(async (url) => { + const root = await project({ dialect: "postgres" }); + const code = await migrateCommand(["--from-db", "--db", url, "--dialect", "postgres", "--dry-run"], root); + + const text = out.join("\n"); + expect(code).toBe(1); + expect(text).toContain('reference "ref_person" targets [code] on "Person"'); + expect(text).toContain("identity.secondary"); + expect(text).not.toContain("unexpected error"); + expect(text).not.toContain("CREATE TABLE"); + }); + }); + + test("a holder outside migrate.scope is not this run's to fix: warned, and the run goes on", async () => { + await withPgDatabase(async (url) => { + const root = await project({ dialect: "postgres", scope: ["acme::platform::Person"] }); + const code = await migrateCommand(["--from-db", "--db", url, "--dialect", "postgres", "--dry-run"], root); + + const text = out.join("\n"); + expect(code).toBe(0); + expect(text).toContain('reference "ref_person" targets [code] on "Person"'); + expect(text).toContain("not governed by this run"); + }); + }); +}); diff --git a/server/typescript/packages/cli/test/migrate-schema-conventions.test.ts b/server/typescript/packages/cli/test/migrate-schema-conventions.test.ts new file mode 100644 index 0000000000..fb36b12ddb --- /dev/null +++ b/server/typescript/packages/cli/test/migrate-schema-conventions.test.ts @@ -0,0 +1,152 @@ +/** + * `migrate.conventions` (or a conventions object from metaobjects.config.ts) reaches + * `meta migrate` and `meta verify --db`: an `efcore` project gets EF Core's database-only + * names (here, the index EF puts on every foreign key), and a preset name the tool does not + * know is refused by name, as a config error. + */ +import { describe, test, expect, afterAll, beforeEach, afterEach, spyOn } from "bun:test"; +import { mkdtemp, rm, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { migrateCommand } from "../src/commands/migrate.js"; +import { verifyCommand } from "../src/commands/verify.js"; +import { genCommand } from "../src/commands/gen.js"; +import { log } from "../src/lib/log.js"; + +const dirs: string[] = []; +afterAll(async () => { for (const d of dirs) await rm(d, { recursive: true, force: true }); }); + +const MODEL = JSON.stringify({ + "metadata.root": { + package: "acme::platform", + children: [ + { "object.entity": { name: "Order", children: [ + { "source.rdb": { name: "src", "@table": "Orders" } }, + { "field.long": { name: "id", "@column": "Id" } }, + { "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } }, + ] } }, + { "object.entity": { name: "OrderLine", children: [ + { "source.rdb": { name: "src", "@table": "OrderLines" } }, + { "field.long": { name: "id", "@column": "Id" } }, + { "field.long": { name: "orderId", "@column": "OrderId" } }, + { "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } }, + { "identity.reference": { name: "order", "@fields": ["orderId"], "@references": "Order" } }, + ] } }, + ], + }, +}); + +async function project(conventions: string | undefined): Promise { + const root = await mkdtemp(join(tmpdir(), "schema-conventions-")); + dirs.push(root); + await mkdir(join(root, "metaobjects"), { recursive: true }); + await writeFile(join(root, "metaobjects", "meta.platform.json"), MODEL, "utf8"); + await mkdir(join(root, ".metaobjects", "migrations"), { recursive: true }); + const migrate = conventions === undefined ? { dialect: "sqlite" } : { dialect: "sqlite", conventions }; + await writeFile(join(root, ".metaobjects", "config.json"), JSON.stringify({ schema_version: 1, migrate }), "utf8"); + return root; +} + +let out: string[] = []; +let infoSpy: ReturnType; +let errSpy: ReturnType; +let warnSpy: ReturnType; +beforeEach(() => { + out = []; + infoSpy = spyOn(log, "info").mockImplementation((m: string) => { out.push(String(m)); }); + errSpy = spyOn(log, "error").mockImplementation((m: string) => { out.push(String(m)); }); + warnSpy = spyOn(log, "warn").mockImplementation((m: string) => { out.push(String(m)); }); +}); +afterEach(() => { infoSpy.mockRestore(); errSpy.mockRestore(); warnSpy.mockRestore(); }); + +const dryRun = (root: string) => + migrateCommand(["--from-db", "--db", `file:${join(root, "t.db")}`, "--dialect", "sqlite", "--dry-run"], root); + +describe("meta migrate with migrate.conventions", () => { + test("efcore: the foreign key gets EF Core's IX_ index", async () => { + const root = await project("efcore"); + + expect(await dryRun(root)).toBe(0); + expect(out.join("\n")).toContain('CREATE INDEX "IX_OrderLines_OrderId" ON "OrderLines" ("OrderId");'); + }); + + test("no conventions set: no FK index, as before", async () => { + const root = await project(undefined); + + expect(await dryRun(root)).toBe(0); + expect(out.join("\n")).not.toContain("IX_OrderLines_OrderId"); + }); + + test("an unknown preset is refused by name, not as an unexpected error", async () => { + const root = await project("hibernate"); + + const code = await dryRun(root); + const text = out.join("\n"); + expect(code).toBe(2); + expect(text).toContain('migrate.conventions "hibernate" is not a schema-conventions preset: expected one of default, efcore'); + expect(text).not.toContain("unexpected error"); + }); + + test("meta gen refuses an unknown preset too, rather than generating with the default names", async () => { + const root = await project("hibernate"); + await writeFile(join(root, "metaobjects.config.ts"), "export default { generators: [] };\n", "utf8"); + const code = await genCommand([], root); + expect(code).toBe(2); + expect(out.join("\n")).toContain('migrate.conventions "hibernate" is not a schema-conventions preset'); + }); + + test("a conventions object from metaobjects.config.ts applies, importing the preset from migrate-ts", async () => { + const root = await project(undefined); + await writeFile(join(root, "metaobjects.config.ts"), [ + 'import { efcoreConventions } from "@metaobjectsdev/migrate-ts";', + "export default { generators: [], schemaConventions: { ...efcoreConventions, id: \"acme\" } };", + "", + ].join("\n"), "utf8"); + + expect(await dryRun(root)).toBe(0); + expect(out.join("\n")).toContain('CREATE INDEX "IX_OrderLines_OrderId"'); + }); + + test("a metaobjects.config.ts that exists but does not load is warned about, not silently ignored", async () => { + const root = await project(undefined); + await writeFile(join(root, "metaobjects.config.ts"), 'throw new Error("config exploded");\n', "utf8"); + + expect(await dryRun(root)).toBe(0); + const text = out.join("\n"); + expect(text).toContain("metaobjects.config.ts exists but did not load"); + expect(text).toContain("config exploded"); + expect(text).toContain("schemaConventions"); + }); + + test("meta verify --db builds with the same conventions, from config.json or metaobjects.config.ts", async () => { + const consoleSpies = [ + spyOn(console, "log").mockImplementation((m: unknown) => { out.push(String(m)); }), + spyOn(console, "error").mockImplementation((m: unknown) => { out.push(String(m)); }), + ]; + try { + const db = (root: string) => `file:${join(root, "t.db")}`; + const verify = (root: string) => verifyCommand(["--db", db(root), "--dialect", "sqlite"], root); + + const preset = await project("efcore"); + expect(await migrateCommand(["--from-db", "--db", db(preset), "--dialect", "sqlite", "--slug", "init", "--apply"], preset)).toBe(0); + expect(await verify(preset)).toBe(0); + + const own = await project(undefined); + await writeFile(join(own, "metaobjects.config.ts"), [ + 'import { efcoreConventions } from "@metaobjectsdev/migrate-ts";', + "export default { generators: [], schemaConventions: { ...efcoreConventions, id: \"acme\" } };", + "", + ].join("\n"), "utf8"); + expect(await migrateCommand(["--from-db", "--db", db(own), "--dialect", "sqlite", "--slug", "init", "--apply"], own)).toBe(0); + expect(await verify(own)).toBe(0); + + // Without the conventions, the EF Core FK index is an object the metadata does not describe. + await rm(join(own, "metaobjects.config.ts")); + out = []; + expect(await verify(own)).toBe(1); + expect(out.join("\n")).toContain("IX_OrderLines_OrderId"); + } finally { + for (const s of consoleSpies) s.mockRestore(); + } + }); +}); diff --git a/server/typescript/packages/cli/test/unit/gen-migrate-advice.test.ts b/server/typescript/packages/cli/test/unit/gen-migrate-advice.test.ts index 6bd127dee0..375b4be8af 100644 --- a/server/typescript/packages/cli/test/unit/gen-migrate-advice.test.ts +++ b/server/typescript/packages/cli/test/unit/gen-migrate-advice.test.ts @@ -5,7 +5,7 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { MetaDataLoader, InMemoryStringSource, type MetaRoot } from "@metaobjectsdev/metadata"; -import { buildExpectedSchema, snapshotPath, writeSnapshot } from "@metaobjectsdev/migrate-ts"; +import { buildExpectedSchema, defaultConventions, snapshotPath, writeSnapshot } from "@metaobjectsdev/migrate-ts"; import { genMigrateAdvice, type GenMigrateAdviceInput } from "../../src/lib/gen-migrate-advice.js"; const dirs: string[] = []; @@ -49,6 +49,7 @@ function input(over: Partial & Pick { expect(await verifyCommand(["--replay-snapshot"], root)).toBe(1); // tier 2: must not pass vacuously }); }); + +describe("verify --replay-snapshot under migrate.scope", () => { + // The scoped branch rebuilds the expected schema to decide what it governs, and that + // build refuses an FK into a non-unique target on Postgres. The refusal must come out + // by name, exit 1, as on every other --db path, not as an unexpected error. + test("an FK to a non-unique target on Postgres is refused by name", async () => { + const root = await project(); + const pgCfg = () => + ({ dialect: "postgres", outDir: MIGRATIONS, onAmbiguous: "abort", + allow: [], slug: "init", dryRun: false } as never); + await writeFile(join(root, "metaobjects", "meta.platform.json"), NO_TABLES, "utf8"); + await writeFile( + join(root, ".metaobjects", "config.json"), + JSON.stringify({ schema_version: 1, migrate: { dialect: "postgres", outDir: MIGRATIONS } }), + "utf8", + ); + expect(await runBaseline(pgCfg(), root)).toBe(0); + await writeFile(join(root, "metaobjects", "meta.platform.json"), JSON.stringify({ "metadata.root": { + package: "acme::platform", + children: [ + { "object.entity": { name: "Person", children: [ + { "source.rdb": { name: "src", "@table": "people" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "code" } }, + { "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } }, + ] } }, + { "object.entity": { name: "Visit", children: [ + { "source.rdb": { name: "src", "@table": "visits" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "personCode" } }, + { "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } }, + { "identity.reference": { name: "ref_person", "@fields": ["personCode"], "@references": "Person.code" } }, + ] } }, + ], + } }), "utf8"); + await writeFile( + join(root, ".metaobjects", "config.json"), + JSON.stringify({ schema_version: 1, migrate: { dialect: "postgres", outDir: MIGRATIONS, scope: ["acme::platform::*"] } }), + "utf8", + ); + + const lines: string[] = []; + const orig = [console.log, console.error] as const; + console.log = (...a: unknown[]) => { lines.push(a.map(String).join(" ")); }; + console.error = (...a: unknown[]) => { lines.push(a.map(String).join(" ")); }; + let code: number; + try { + code = await verifyCommand(["--replay-snapshot", "--skip-schema"], root); + } finally { + [console.log, console.error] = orig; + } + expect(code).toBe(1); + expect(lines.join("\n")).toContain('reference "ref_person" targets [code] on "Person"'); + }, 60_000); +}); diff --git a/server/typescript/packages/cli/test/verify-schema-refusals.test.ts b/server/typescript/packages/cli/test/verify-schema-refusals.test.ts new file mode 100644 index 0000000000..aa75a48ea1 --- /dev/null +++ b/server/typescript/packages/cli/test/verify-schema-refusals.test.ts @@ -0,0 +1,157 @@ +/** + * `meta verify` reports the two metadata/config refusals the expected-schema build can raise + * as what they are, the way `meta migrate` does: an FK whose target is not a unique key + * (exit 1 on Postgres, the reference named; a warning on SQLite, whose DDL accepts it), and an unknown `migrate.conventions` preset (exit 2, the + * presets named). Neither is "failed to introspect", and a typo'd preset is refused even + * on a run with no --db. + */ +import { describe, test, expect, afterAll } from "bun:test"; +import { mkdtemp, mkdir, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import pg from "pg"; +import { verifyCommand } from "../src/commands/verify.js"; + +const dirs: string[] = []; +afterAll(async () => { for (const d of dirs) await rm(d, { recursive: true, force: true }); }); + +function model(references: string): string { + return JSON.stringify({ "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Person", children: [ + { "source.rdb": { "@table": "people" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "code" } }, + { "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } }, + ] } }, + { "object.entity": { name: "Visit", children: [ + { "source.rdb": { "@table": "visits" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "personCode" } }, + { "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } }, + { "identity.reference": { name: "ref_person", "@fields": ["personCode"], "@references": references } }, + ] } }, + ] } }); +} + +async function project(references: string, migrate: Record = {}): Promise { + const root = await mkdtemp(join(tmpdir(), "verify-schema-refusals-")); + dirs.push(root); + await mkdir(join(root, "metaobjects"), { recursive: true }); + await writeFile(join(root, "metaobjects", "meta.acme.json"), model(references), "utf8"); + await mkdir(join(root, ".metaobjects"), { recursive: true }); + await writeFile(join(root, ".metaobjects", "config.json"), JSON.stringify({ schema_version: 1, migrate }), "utf8"); + return root; +} + +async function capture(fn: () => Promise): Promise<{ code: number; out: string }> { + const lines: string[] = []; + const log = console.log; + const err = console.error; + console.log = (...a: unknown[]) => { lines.push(a.map(String).join(" ")); }; + console.error = (...a: unknown[]) => { lines.push(a.map(String).join(" ")); }; + try { + const code = await fn(); + return { code, out: lines.join("\n") }; + } finally { + console.log = log; + console.error = err; + } +} + +describe("meta verify — schema refusals", () => { + test("--db on SQLite: an FK to a non-unique target is warned by name, not refused and not an introspection failure", async () => { + const root = await project("Person.code"); + const { out } = await capture(() => verifyCommand(["--db", `file:${join(root, "t.db")}`], root)); + + expect(out).toContain('reference "ref_person" targets [code] on "Person"'); + expect(out).toContain("PRAGMA foreign_keys"); + expect(out).not.toContain("failed to introspect"); + }); + + test("--db on Postgres: an FK to a non-unique target is refused by name (env-gated)", async () => { + const pgUrl = process.env.MIGRATE_TS_PG_URL; + if (pgUrl === undefined || pgUrl.length === 0) return; + const admin = new pg.Client({ connectionString: pgUrl }); + await admin.connect(); + const db = `mt_vfk_${Date.now()}_${Math.floor(Math.random() * 1e6)}`; + await admin.query(`CREATE DATABASE ${db}`); + try { + const url = new URL(pgUrl); + url.pathname = `/${db}`; + const root = await project("Person.code", { dialect: "postgres" }); + const { code, out } = await capture(() => verifyCommand(["--db", url.toString()], root)); + + expect(code).toBe(1); + expect(out).toContain('reference "ref_person" targets [code] on "Person"'); + expect(out).not.toContain("failed to introspect"); + } finally { + await admin.query(`DROP DATABASE IF EXISTS ${db} WITH (FORCE)`); + await admin.end(); + } + }); + + test("an unknown migrate.conventions preset is refused with exit 2, with or without --db", async () => { + const root = await project("Person", { conventions: "hibernate" }); + + const plain = await capture(() => verifyCommand([], root)); + expect(plain.code).toBe(2); + expect(plain.out).toContain('migrate.conventions "hibernate" is not a schema-conventions preset'); + + const withDb = await capture(() => verifyCommand(["--db", `file:${join(root, "t.db")}`], root)); + expect(withDb.code).toBe(2); + expect(withDb.out).not.toContain("failed to introspect"); + }); + + test("--db: an enum member longer than enums.maxLength is refused by name, not as an introspection failure", async () => { + const root = await mkdtemp(join(tmpdir(), "verify-schema-refusals-")); + dirs.push(root); + await mkdir(join(root, "metaobjects"), { recursive: true }); + await writeFile(join(root, "metaobjects", "meta.acme.json"), JSON.stringify({ "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Ticket", children: [ + { "source.rdb": { "@table": "tickets" } }, + { "field.long": { name: "id" } }, + { "field.enum": { name: "status", "@values": ["OPEN", "IN_PROGRESS"] } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, + ] } }, + ] } }), "utf8"); + await mkdir(join(root, ".metaobjects"), { recursive: true }); + await writeFile(join(root, ".metaobjects", "config.json"), JSON.stringify({ schema_version: 1, enums: { maxLength: 10 } }), "utf8"); + + const { code, out } = await capture(() => verifyCommand(["--db", `file:${join(root, "t.db")}`], root)); + expect(code).toBe(1); + expect(out).toContain('field.enum "Ticket.status" has the member "IN_PROGRESS"'); + expect(out).not.toContain("failed to introspect"); + + // The same refusal under migrate.scope, whose scope check builds the expected schema first. + await writeFile(join(root, ".metaobjects", "config.json"), + JSON.stringify({ schema_version: 1, enums: { maxLength: 10 }, migrate: { scope: ["acme::*"] } }), "utf8"); + const scoped = await capture(() => verifyCommand(["--db", `file:${join(root, "t.db")}`], root)); + expect(scoped.code).toBe(1); + expect(scoped.out).toContain('field.enum "Ticket.status" has the member "IN_PROGRESS"'); + }); + + test("--db: a declared CHECK name two validators share is refused by name, not as an introspection failure", async () => { + const root = await mkdtemp(join(tmpdir(), "verify-schema-refusals-")); + dirs.push(root); + await mkdir(join(root, "metaobjects"), { recursive: true }); + await writeFile(join(root, "metaobjects", "meta.acme.json"), JSON.stringify({ "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Slot", children: [ + { "source.rdb": { "@table": "slots" } }, + { "field.long": { name: "id" } }, + { "field.long": { name: "a" } }, + { "field.long": { name: "b" } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, + { "validator.atLeastOne": { name: "one", "@fields": ["a", "b"], "@constraintName": "CK_Slot" } }, + { "validator.comparison": { name: "cmp", "@left": "a", "@op": "lt", "@right": "b", "@constraintName": "CK_Slot" } }, + ] } }, + ] } }), "utf8"); + await mkdir(join(root, ".metaobjects"), { recursive: true }); + await writeFile(join(root, ".metaobjects", "config.json"), JSON.stringify({ schema_version: 1 }), "utf8"); + + const { code, out } = await capture(() => verifyCommand(["--db", `file:${join(root, "t.db")}`], root)); + expect(code).toBe(1); + expect(out).toContain("ERR_CHECK_NAME"); + expect(out).toContain('CHECK "CK_Slot" on table "slots"'); + expect(out).not.toContain("failed to introspect"); + }); +}); diff --git a/server/typescript/packages/migrate-ts/src/check-expression.ts b/server/typescript/packages/migrate-ts/src/check-expression.ts new file mode 100644 index 0000000000..a1a9bc3a47 --- /dev/null +++ b/server/typescript/packages/migrate-ts/src/check-expression.ts @@ -0,0 +1,132 @@ +// check-expression.ts — render an attr.expression tree (a cross-field validator's @condition) +// as SQL for a CHECK constraint, and negate one so `NOT (condition) OR (rule)` reads the way it +// would be written by hand: `"a" <> "b" OR …`, not `NOT ("a" = "b") OR …`. +// +// The negation is exact in SQL's three-valued logic: a flipped comparison is NULL exactly when +// the original is, isNull/isNotNull swap, and/or follow De Morgan. A field resolves to its +// physical column through the caller's lookup; a field the lookup does not know refuses the +// render (the loader refuses that model first, ERR_BAD_ATTR_VALUE). + +import { + EXPR_FN_COALESCE, + EXPR_OP_IS_NOT_NULL, + EXPR_OP_NOT, + FILTER_COMPOSE_AND, + FILTER_COMPOSE_OR, + FILTER_OP_EQ, + FILTER_OP_GT, + FILTER_OP_GTE, + FILTER_OP_IS_NULL, + FILTER_OP_LT, + FILTER_OP_LTE, + FILTER_OP_NE, + type ExprNode, +} from "@metaobjectsdev/metadata"; +import { isSqliteFamily, type Dialect } from "./types.js"; + +const SQL_OP: Readonly> = { + [FILTER_OP_EQ]: "=", [FILTER_OP_NE]: "<>", [FILTER_OP_GT]: ">", + [FILTER_OP_GTE]: ">=", [FILTER_OP_LT]: "<", [FILTER_OP_LTE]: "<=", +}; + +const NEGATED_OP: Readonly> = { + [FILTER_OP_EQ]: FILTER_OP_NE, [FILTER_OP_NE]: FILTER_OP_EQ, + [FILTER_OP_GT]: FILTER_OP_LTE, [FILTER_OP_LTE]: FILTER_OP_GT, + [FILTER_OP_LT]: FILTER_OP_GTE, [FILTER_OP_GTE]: FILTER_OP_LT, + [FILTER_OP_IS_NULL]: EXPR_OP_IS_NOT_NULL, [EXPR_OP_IS_NOT_NULL]: FILTER_OP_IS_NULL, +}; + +type Node = Record; + +/** The logical negation of `node`, pushed down to its comparisons. */ +export function negateExpression(node: ExprNode): ExprNode { + const n = node as Node; + const op = n["op"]; + if (op === EXPR_OP_NOT) return n["arg"] as ExprNode; + if (op === FILTER_COMPOSE_AND || op === FILTER_COMPOSE_OR) { + return { + op: op === FILTER_COMPOSE_AND ? FILTER_COMPOSE_OR : FILTER_COMPOSE_AND, + args: (n["args"] as ExprNode[]).map(negateExpression), + } as ExprNode; + } + if (typeof op === "string" && NEGATED_OP[op] !== undefined) { + return { ...n, op: NEGATED_OP[op] } as ExprNode; + } + return { op: EXPR_OP_NOT, arg: node } as ExprNode; +} + +/** + * How a literal compared with a field is stored in that field's column, when that is not the + * literal itself: an int-backed enum (`@intValueMap`) stores a member as its integer, and a + * duration is an interval or a microsecond integer. Answers the + * SQL for the stored value, or undefined to render the literal as written. + */ +export type StoredLiteral = (field: string, value: unknown) => string | undefined; + +/** A node SQL can use as a comparison operand without parentheses. */ +function isAtom(x: unknown): boolean { + const n = x as Node; + return typeof n["field"] === "string" || "value" in n || n["fn"] !== undefined; +} + +/** + * `node` as SQL. `column(field)` is the quoted physical column for a field name, or undefined + * when the entity has no such field. A compound operand of a comparison or a null test is + * parenthesised: `IS` binds looser than `=`, and `AND` / `OR` looser than both. + */ +export function renderExpressionSql( + node: ExprNode, column: (field: string) => string | undefined, dialect: Dialect | undefined, + stored?: StoredLiteral, +): string { + const n = node as Node; + if (typeof n["field"] === "string") { + const col = column(n["field"]); + if (col === undefined) throw new Error(`@condition names "${n["field"]}", which is not a field of the entity`); + return col; + } + if ("value" in n) return renderLiteral(n["value"], dialect); + const op = n["op"]; + const sub = (x: unknown): string => renderExpressionSql(x as ExprNode, column, dialect, stored); + const operand = (x: unknown, other: unknown): string => { + const field = storedFieldOf(other); + if (stored !== undefined && "value" in (x as Node) && field !== undefined) { + const sql = stored(field, (x as Node)["value"]); + if (sql !== undefined) return sql; + } + return isAtom(x) ? sub(x) : `(${sub(x)})`; + }; + if (typeof op === "string" && SQL_OP[op] !== undefined) { + return `${operand(n["left"], n["right"])} ${SQL_OP[op]} ${operand(n["right"], n["left"])}`; + } + if (op === FILTER_OP_IS_NULL) return `${operand(n["arg"], undefined)} IS NULL`; + if (op === EXPR_OP_IS_NOT_NULL) return `${operand(n["arg"], undefined)} IS NOT NULL`; + if (op === EXPR_OP_NOT) return `NOT (${sub(n["arg"])})`; + if (op === FILTER_COMPOSE_AND || op === FILTER_COMPOSE_OR) { + return (n["args"] as unknown[]).map((a) => `(${sub(a)})`).join(op === FILTER_COMPOSE_AND ? " AND " : " OR "); + } + if (n["fn"] === EXPR_FN_COALESCE) return `COALESCE(${(n["args"] as unknown[]).map(sub).join(", ")})`; + throw new Error(`@condition: an expression node migrate cannot render: ${JSON.stringify(node)}`); +} + +/** + * The field whose column form a literal compared with `node` takes: the field itself, or for a + * `coalesce` the first field among its arguments (a coalesce of fields has their column type). + */ +function storedFieldOf(node: unknown): string | undefined { + const n = (node ?? {}) as Node; + if (typeof n["field"] === "string") return n["field"]; + if (n["fn"] === EXPR_FN_COALESCE && Array.isArray(n["args"])) { + for (const a of n["args"] as unknown[]) { + const f = storedFieldOf(a); + if (f !== undefined) return f; + } + } + return undefined; +} + +function renderLiteral(v: unknown, dialect: Dialect | undefined): string { + if (v === null) return "NULL"; + if (typeof v === "boolean") return isSqliteFamily(dialect) ? (v ? "1" : "0") : (v ? "TRUE" : "FALSE"); + if (typeof v === "number") return String(v); + return `'${String(v).replace(/'/g, "''")}'`; +} diff --git a/server/typescript/packages/migrate-ts/src/diff/index.ts b/server/typescript/packages/migrate-ts/src/diff/index.ts index ef9640c4b8..d90771fa3f 100644 --- a/server/typescript/packages/migrate-ts/src/diff/index.ts +++ b/server/typescript/packages/migrate-ts/src/diff/index.ts @@ -4,7 +4,9 @@ import type { DependentRelation, Change, ChangeStatus, DiffResult, AllowOptions, AmbiguousCallback, Dialect, CheckDescriptor, ColumnDefault, NameChange, DeclaredRename, ExprCanonPath, + SchemaAdvisory, } from "../types.js"; +import { PG_IDENTIFIER_MAX_BYTES } from "../types.js"; import type { SqlType } from "../sql-type.js"; import { sqlTypeEquals } from "../sql-type.js"; import { applyStatus } from "./status.js"; @@ -281,12 +283,48 @@ export async function diff( const { renamed, predecessors } = diffRenamedTables(changes, expectedTables, actualTables, args.dialect, canon); // Pass 2: tables in both → compare columns/indexes/FKs + const demotedKeys: { table: TableDescriptor; columns: readonly string[] }[] = []; for (const [id, expectedTable] of expectedTables) { const actualTable = actualTables.get(id); if (!actualTable) continue; // An FK onto a renamed table follows the rename in the engine — compare it as such. - diffTablePair(expectedTable, withRenamedRefTables(actualTable, renamed), changes, args.dialect, canon); + if (args.dialect !== "postgres") { + diffTablePair(expectedTable, withRenamedRefTables(actualTable, renamed), changes, args.dialect, canon); + continue; + } + // Postgres: a constraint or index whose only change is its name is renamed in place, and so + // is a primary key. A unique key that changed only between index and constraint is promoted + // in place, or demoted by drop and re-create. + const tableChanges: Change[] = []; + diffTablePair(expectedTable, withRenamedRefTables(actualTable, renamed), tableChanges, args.dialect, canon); + const { constraintRenames, indexRenames, rest } = pairNameOnlyChanges(tableChanges, textColumnsOf(actualTable), canon); + const pk = primaryKeyRename(expectedTable, actualTable, actualTable.name); + if (pk !== undefined) constraintRenames.push(pk); + // A renamed key is compared for its kind under the name it is renamed to: a live UNIQUE + // constraint renamed onto a key the conventions want as a plain index is renamed AND + // demoted, in one migration. The rename runs first, so the demotion uses the new name. + const { promotions, demotions } = uniqueKindChanges( + expectedTable, withRenamedIndexes(actualTable, [...constraintRenames, ...indexRenames]), canon); + if (constraintRenames.length > 0 || indexRenames.length > 0 || promotions.length > 0) { + changes.push({ + kind: "rename-constraints", table: expectedTable.name, ...schemaSpread(expectedTable.schema), + ...(constraintRenames.length > 0 ? { constraintRenames } : {}), + ...(indexRenames.length > 0 ? { indexRenames } : {}), + ...(promotions.length > 0 ? { uniquePromotions: promotions } : {}), + status: ALLOWED, + }); + } + changes.push(...rest); + for (const d of demotions) { + const sx = schemaSpread(expectedTable.schema); + changes.push({ kind: "drop-index", table: expectedTable.name, ...sx, index: d.live.name, restore: d.live, status: ALLOWED }); + changes.push({ kind: "add-index", table: expectedTable.name, ...sx, index: d.want, status: ALLOWED }); + demotedKeys.push({ table: expectedTable, columns: d.live.columns }); + } } + // A demoted UNIQUE constraint is dropped, and Postgres refuses that while a foreign key + // relies on it, so each such FK is dropped first and added back once the index exists. + if (demotedKeys.length > 0) refitForeignKeysOnto(demotedKeys, actualTables, expectedTables, changes); // Pass 2b: views. Identity is (schema, name). How "changed" is decided is // DIALECT-DEPENDENT — see diffViews. @@ -364,6 +402,10 @@ export async function diff( changes, blocked: changes.filter((c) => c.status.state === "blocked"), hazards: changes.flatMap((c) => dataHazardOf(c) ?? []), + advisories: [ + ...intervalAsStringAdvisories(expectedTables, (id) => actualTables.get(id) ?? predecessors.get(id)), + ...(args.dialect === "sqlite" || args.dialect === "d1" ? sequenceNotAppliedAdvisories(changes) : []), + ], ...(args.dialect === "postgres" ? { exprCanonPath } : {}), ...(shadowAnswer !== undefined && "unavailable" in shadowAnswer ? { shadowUnavailable: shadowAnswer.unavailable } : {}), }; @@ -422,7 +464,11 @@ function diffRenamedTables( const carried = withRenamedRefTables({ ...actual, name: r.to }, renamed); const tableChanges: Change[] = []; diffTablePair(expected, carried, tableChanges, dialect, canon); - changes.push(...(dialect === "postgres" ? foldNameOnlyChanges(r, tableChanges, textColumnsOf(actual), canon) : tableChanges)); + if (dialect !== "postgres") { changes.push(...tableChanges); continue; } + changes.push(...foldNameOnlyChanges(r, tableChanges, textColumnsOf(actual), canon)); + // `ALTER TABLE … RENAME` keeps the old primary-key name. + const pk = primaryKeyRename(expected, actual, r.from); + if (pk !== undefined) (r.constraintRenames ??= []).push(pk); } return { renamed, predecessors }; } @@ -546,6 +592,21 @@ function foldNameOnlyChanges( textColumns: ReadonlySet, canon: ExprCanon, ): Change[] { + const { constraintRenames, indexRenames, rest } = pairNameOnlyChanges(tableChanges, textColumns, canon); + if (constraintRenames.length > 0) r.constraintRenames = constraintRenames; + if (indexRenames.length > 0) r.indexRenames = indexRenames; + return rest; +} + +/** + * Pair each drop of a constraint or index with an add of the SAME definition under another + * name. The pairs are name changes; `rest` is every other change, in order. + */ +function pairNameOnlyChanges( + tableChanges: Change[], + textColumns: ReadonlySet, + canon: ExprCanon, +): { constraintRenames: NameChange[]; indexRenames: NameChange[]; rest: Change[] } { const constraintRenames: NameChange[] = []; const indexRenames: NameChange[] = []; const folded = new Set(); @@ -573,10 +634,7 @@ function foldNameOnlyChanges( } } } - if (folded.size === 0) return tableChanges; - if (constraintRenames.length > 0) r.constraintRenames = constraintRenames; - if (indexRenames.length > 0) r.indexRenames = indexRenames; - return tableChanges.filter((c) => !folded.has(c)); + return { constraintRenames, indexRenames, rest: folded.size === 0 ? tableChanges : tableChanges.filter((c) => !folded.has(c)) }; } function idOfChange(c: Change): string | undefined { @@ -705,6 +763,90 @@ async function carryThroughColumnRenames( } } +/** Postgres keeps the first 63 bytes of an identifier, so a longer name is compared as it is stored. */ +function pgStoredName(name: string): string { + if (Buffer.byteLength(name, "utf8") <= PG_IDENTIFIER_MAX_BYTES) return name; + let out = ""; + for (const ch of name) { + if (Buffer.byteLength(out + ch, "utf8") > PG_IDENTIFIER_MAX_BYTES) break; + out += ch; + } + return out; +} + +/** + * The rename a live primary key needs to carry the name the schema conventions give it, or + * undefined when the names agree or either side has no key (pg-mem reports none). An absent + * name is `
_pkey`; `liveTable` is the table's name as the database knows it. + */ +function primaryKeyRename(expected: TableDescriptor, actual: TableDescriptor, liveTable: string): NameChange | undefined { + if (expected.primaryKey.length === 0 || actual.primaryKey.length === 0) return undefined; + const from = pgStoredName(actual.primaryKeyName ?? `${liveTable}_pkey`); + const to = pgStoredName(expected.primaryKeyName ?? `${expected.name}_pkey`); + return from === to ? undefined : { from, to }; +} + +/** The live table with each renamed index (or the index backing a renamed constraint) under its new name. */ +function withRenamedIndexes(actual: TableDescriptor, renames: readonly NameChange[]): TableDescriptor { + if (renames.length === 0) return actual; + const to = new Map(renames.map((r) => [r.from, r.to])); + return { ...actual, indexes: actual.indexes.map((i) => (to.has(i.name) ? { ...i, name: to.get(i.name)! } : i)) }; +} + +/** + * Unique keys whose definition and name match but which differ only in being a plain index or + * a UNIQUE constraint. `promotions` are the expected constraints a live index becomes; + * `demotions` pair a live constraint with the plain index the conventions ask for (`plainIndex`); + * under conventions with no preference a live constraint stays. + */ +function uniqueKindChanges( + expected: TableDescriptor, + actual: TableDescriptor, + canon: ExprCanon, +): { promotions: IndexDescriptor[]; demotions: { live: IndexDescriptor; want: IndexDescriptor }[] } { + const promotions: IndexDescriptor[] = []; + const demotions: { live: IndexDescriptor; want: IndexDescriptor }[] = []; + const live = new Map(actual.indexes.map((i) => [i.name, i])); + for (const want of expected.indexes) { + const a = live.get(want.name); + if (a === undefined || !want.unique || !a.unique || !indexEquals(want, a, canon)) continue; + // Only a plain index and a UNIQUE constraint trade places; a PK or EXCLUDE index is not a unique key. + if ((want.constraint ?? "unique") !== "unique" || (a.constraint ?? "unique") !== "unique") continue; + if (want.constraint === "unique" && a.constraint === undefined) promotions.push(want); + else if (want.plainIndex === true && a.constraint === "unique") demotions.push({ live: a, want }); + } + return { promotions, demotions }; +} + +/** + * Drop and re-add every live foreign key that targets a demoted key's columns, unless the + * diff already drops it. The re-added FK is the expected one when the model still declares + * it, else the live one. + */ +function refitForeignKeysOnto( + keys: readonly { table: TableDescriptor; columns: readonly string[] }[], + actualTables: ReadonlyMap, + expectedTables: ReadonlyMap, + changes: Change[], +): void { + const dropped = new Set(changes.flatMap((c) => + c.kind === "drop-fk" ? [`${tableIdentity({ name: c.table, ...schemaSpread(c.schema) })}\u0000${c.fk}`] : [])); + for (const [id, t] of actualTables) { + for (const fk of t.foreignKeys) { + // An FK names its target table in the referencing table's schema. + const target = keys.find((k) => fk.refTable === k.table.name && t.schema === k.table.schema + && fk.refColumns.length === k.columns.length && fk.refColumns.every((c, i) => c === k.columns[i])); + if (target === undefined || dropped.has(`${id}\u0000${fk.name}`)) continue; + const sx = schemaSpread(t.schema); + const want = expectedTables.get(id)?.foreignKeys.find((f) => f.name === fk.name) ?? fk; + changes.push({ kind: "drop-fk", table: t.name, ...sx, fk: fk.name, restore: fk, status: ALLOWED }); + if (!changes.some((c) => c.kind === "add-fk" && idOfChange(c) === id && c.fk.name === want.name)) { + changes.push({ kind: "add-fk", table: t.name, ...sx, fk: want, status: ALLOWED }); + } + } + } +} + /** * #258 — throw {@link PrimaryKeyChangeError} when a table's live PRIMARY KEY differs from * the metadata identity. Live PK column names are first mapped through any detected @@ -760,10 +902,71 @@ function canonTypeForDialect(t: SqlType, dialect: Dialect | undefined): SqlType return t; } +/** + * Before field.duration, introspection read a Postgres `interval` column as `text`, and a model + * could only describe one as a string. So a live interval column the model declares as text is + * read as text, exactly as it was, and does not turn into drift (an `interval-as-string` + * advisory says so). An expected interval compares as one, and any other expected type is a + * real type change from the interval. + */ +function asPreDurationType(actual: SqlType, expected: SqlType): SqlType { + // Only where the model expects text (a field.string): a live interval modelled as anything + // else is a real type change, reported as interval → that type. + if (actual.kind === "interval" && expected.kind === "text") return { kind: "text" }; + if (actual.kind === "array" && expected.kind === "array") { + const element = asPreDurationType(actual.element, expected.element); + return element === actual.element ? actual : { kind: "array", element }; + } + return actual; +} + +/** Every column a live `interval` (or array of them) the model does not declare as a duration: + * `asPreDurationType` reads it as text so it is not drift, and this says so. */ +function intervalAsStringAdvisories( + expectedTables: ReadonlyMap, liveOf: (id: string) => TableDescriptor | undefined, +): SchemaAdvisory[] { + const out: SchemaAdvisory[] = []; + for (const [id, expected] of expectedTables) { + const live = liveOf(id); + if (live === undefined) continue; + const liveCols = new Map(live.columns.map((c) => [c.name, c])); + for (const ec of expected.columns) { + const lc = liveCols.get(ec.name); + if (lc === undefined || asPreDurationType(lc.sqlType, ec.sqlType) === lc.sqlType) continue; + out.push({ kind: "interval-as-string", table: expected.name, ...schemaSpread(expected.schema), column: ec.name }); + } + } + return out; +} + +/** A column this diff creates on SQLite or D1 with a declared sequence start or name, which + * that database cannot hold (it has no sequence). */ +function sequenceNotAppliedAdvisories(changes: readonly Change[]): SchemaAdvisory[] { + const out: SchemaAdvisory[] = []; + const at = (table: string, schema: string | undefined, column: ColumnDescriptor): void => { + if (column.sequence !== undefined) out.push({ kind: "sequence-not-applied", table, ...schemaSpread(schema), column: column.name }); + }; + for (const c of changes) { + if (c.kind === "create-table") for (const col of c.table.columns) at(c.table.name, c.table.schema, col); + if (c.kind === "add-column") at(c.table, c.schema, c.column); + } + return out; +} + function sqlTypeEqualsForDialect(a: SqlType, b: SqlType, dialect: Dialect | undefined): boolean { return sqlTypeEquals(canonTypeForDialect(a, dialect), canonTypeForDialect(b, dialect)); } +/** + * The name Postgres gives an identity or serial column's sequence when none is declared: + * `
__seq`. Undefined past the 63-byte identifier limit, where Postgres + * truncates by its own rule; such a name is then never compared offline. + */ +function defaultSequenceName(table: string, column: string): string | undefined { + const name = `${table}_${column}_seq`; + return Buffer.byteLength(name, "utf8") <= PG_IDENTIFIER_MAX_BYTES ? name : undefined; +} + function diffTableColumns( expected: TableDescriptor, actual: TableDescriptor, @@ -777,11 +980,13 @@ function diffTableColumns( const actualCols = new Map(actual.columns.map((c) => [c.name, c])); for (const [name, ec] of expectedCols) { - const ac = actualCols.get(name); - if (!ac) { + const live = actualCols.get(name); + if (!live) { changes.push({ kind: "add-column", table, ...sx, column: ec, status: ALLOWED }); continue; } + const preDuration = asPreDurationType(live.sqlType, ec.sqlType); + const ac = preDuration === live.sqlType ? live : { ...live, sqlType: preDuration }; // Compare type, nullable, default — emit per-aspect change. Type equality is // dialect-aware: SQLite/D1 cannot physically represent a VARCHAR length or a // native json type, so those distinctions must not read as drift (see @@ -846,6 +1051,26 @@ function diffTableColumns( }; changes.push(change); } + // The declared start and name of an increment key's sequence (Postgres only; SQLite has + // none). Only what the model DECLARES is compared, so an undeclared one never produces a + // change. The other side is the live sequence when introspected (`liveSequence`), else — + // a committed snapshot — what the previous model declared, with Postgres's defaults (start + // 1, `
__seq`) for what it left undeclared. + if (dialect === "postgres" && ec.identity === "increment" && ac.identity === "increment" && ec.sequence !== undefined) { + const live = ac.liveSequence; + const actualName = live?.name ?? ac.sequence?.name ?? defaultSequenceName(table, name); + const actualStart = live?.start ?? ac.sequence?.start ?? 1; + const startDiffers = ec.sequence.start !== undefined && ec.sequence.start !== actualStart; + const nameDiffers = ec.sequence.name !== undefined && actualName !== undefined && ec.sequence.name !== actualName; + if ((startDiffers || nameDiffers) && actualName !== undefined) { + changes.push({ + kind: "alter-sequence", table, ...sx, column: name, sequence: actualName, status: ALLOWED, + ...(live?.schema !== undefined ? { sequenceSchema: live.schema } : {}), + ...(startDiffers ? { fromStart: actualStart, toStart: ec.sequence.start! } : {}), + ...(nameDiffers ? { toName: ec.sequence.name! } : {}), + }); + } + } if (canon.catalog === true) { // Both sides read `increment` off a GENERATED identity (attidentity) or a legacy serial's // nextval default; a serial losing its sequence is the default change above, not this. @@ -855,6 +1080,7 @@ function diffTableColumns( changes.push({ kind: "change-column-identity", table, ...sx, column: name, status: ALLOWED, ...(from !== undefined ? { from } : {}), ...(to !== undefined ? { to } : {}), + ...(to !== undefined && ec.sequence !== undefined ? { sequence: ec.sequence } : {}), }); } if (ec.description !== ac.description) { diff --git a/server/typescript/packages/migrate-ts/src/diff/status.ts b/server/typescript/packages/migrate-ts/src/diff/status.ts index a777fc390f..d6aff0974d 100644 --- a/server/typescript/packages/migrate-ts/src/diff/status.ts +++ b/server/typescript/packages/migrate-ts/src/diff/status.ts @@ -180,6 +180,10 @@ function blockFor( + `the column starts failing). Declare @generation: increment on the identity to keep it, or pass ` + `--allow drop-identity-default if removing it is intentional`); + // Altering a sequence's start or name keeps every value it has handed out. + case "alter-sequence": + return null; + case "change-comment": if (c.from === undefined || c.to !== undefined) return null; return gate(allow, "dropComment", `${c.column !== undefined ? `column "${c.table}"."${c.column}"` : `table "${c.table}"`} ` @@ -189,6 +193,7 @@ function blockFor( // Always-allowed kinds case "create-table": case "rename-table": + case "rename-constraints": case "add-column": case "rename-column": case "add-index": diff --git a/server/typescript/packages/migrate-ts/src/drift/drift.ts b/server/typescript/packages/migrate-ts/src/drift/drift.ts index 6c76415350..2462473011 100644 --- a/server/typescript/packages/migrate-ts/src/drift/drift.ts +++ b/server/typescript/packages/migrate-ts/src/drift/drift.ts @@ -15,7 +15,7 @@ import type { Kysely } from "kysely"; import type { MetaRoot } from "@metaobjectsdev/metadata"; -import { buildExpectedSchemaWithProvenance } from "../expected-schema.js"; +import { buildExpectedSchemaWithProvenance, type SchemaBuildSettings } from "../expected-schema.js"; import { introspect } from "../introspect/index.js"; import { diff } from "../diff/index.js"; import { collectUnmanagedNames } from "../unmanaged.js"; @@ -27,8 +27,11 @@ import type { ViewReplaceProbe } from "../view-replace.js"; import { postgresExprCanonProbe } from "../expr-canon-probe.js"; import type { ExprCanonProbe } from "../expr-canon.js"; import type { ShadowSchemaSource } from "../shadow-expected.js"; +import type { FkTargetViolation } from "../errors.js"; -export interface ComputeDriftOptions { +/** `conventions` and `skipRedundantChecks` are the project's schema settings, passed to the + * expected-schema build (see `BuildExpectedSchemaOptions`); absent = migrate's defaults. */ +export interface ComputeDriftOptions extends SchemaBuildSettings { /** * Destructive-change permissions. Mirrors `diff`'s `allow` — a drift that is * "allowed" still appears in `changes` (it's still drift), so the gate fails @@ -111,6 +114,12 @@ export interface DriftResult extends DiffResult { * come to disagree with the one this comparison actually made. */ importedOutOfScope: readonly string[] | undefined; + /** + * FKs whose target is not a unique key, on a dialect that does not refuse them at + * CREATE TABLE (SQLite and D1). Not drift and not a refusal: the caller reports them, + * as `meta migrate` does. + */ + unenforcedFkTargetViolations?: readonly FkTargetViolation[]; } /** @@ -136,6 +145,8 @@ export async function computeDriftFromActual( buildExpectedSchemaWithProvenance(metadata, { dialect, ...(opts?.views !== undefined ? { views: opts.views } : {}), + conventions: opts?.conventions, + skipRedundantChecks: opts?.skipRedundantChecks, }), opts?.inScope, opts?.imported !== undefined ? { imported: opts.imported } : undefined, @@ -163,6 +174,7 @@ export async function computeDriftFromActual( outOfScope: scoped.outOfScope, declaredSchemas: scoped.declaredSchemas, importedOutOfScope: scoped.importedOutOfScope, + ...(scoped.unenforcedFkTargetViolations !== undefined ? { unenforcedFkTargetViolations: scoped.unenforcedFkTargetViolations } : {}), }; } diff --git a/server/typescript/packages/migrate-ts/src/emit/postgres.ts b/server/typescript/packages/migrate-ts/src/emit/postgres.ts index 4f367f9344..46d75b6786 100644 --- a/server/typescript/packages/migrate-ts/src/emit/postgres.ts +++ b/server/typescript/packages/migrate-ts/src/emit/postgres.ts @@ -1,3 +1,4 @@ +import { canBeUniqueConstraint } from "../schema-conventions.js"; import type { Change, EmitResult, ColumnDescriptor, IndexDescriptor, FkDescriptor, TableDescriptor, ViewDescriptor, ColumnDefault, FkAction, NameChange, DataHazard, @@ -37,12 +38,16 @@ import { dataHazardOf } from "../hazards.js"; const STAGE_ORDER: Record = { "drop-view": 0, "rename-table": 0.5, + // After a table rename (its new name is the one this change addresses), before every drop/add. + "rename-constraints": 0.6, "drop-fk": 1, "drop-check": 1, "create-table": 1, "drop-index": 1.5, "add-column": 2, "drop-column": 2, "change-column-type": 2, "change-column-nullable": 2, "change-column-default": 2, "change-column-identity": 2, + // After column changes (the identity exists), before anything that could read the name. + "alter-sequence": 2.5, "rename-column": 3, "change-comment": 3.5, "add-index": 4, @@ -230,6 +235,7 @@ function renderUp(c: Change, recreated: ReadonlySet = new Set()): string // so a no-op down would still record the rollback as done. case "drop-table": return `DROP TABLE IF EXISTS ${quoteQualified(c.table, c.schema)};`; case "rename-table": return renderRenameTable(c, "up"); + case "rename-constraints": return renderRenameConstraints(c, "up"); case "add-column": { const base = `ALTER TABLE ${quoteQualified(c.table, c.schema)} ADD COLUMN ${renderColumn(c.column)};`; if (!c.column.description) return base; @@ -246,7 +252,8 @@ function renderUp(c: Change, recreated: ReadonlySet = new Set()): string return c.to !== undefined ? `ALTER TABLE ${quoteQualified(c.table, c.schema)} ALTER COLUMN ${quote(c.column)} SET DEFAULT ${renderDefault(c.to)};` : `ALTER TABLE ${quoteQualified(c.table, c.schema)} ALTER COLUMN ${quote(c.column)} DROP DEFAULT;`; - case "change-column-identity": return renderIdentity(c.table, c.schema, c.column, c.to); + case "change-column-identity": return renderIdentity(c.table, c.schema, c.column, c.to, c.sequence); + case "alter-sequence": return renderAlterSequence(c.sequenceSchema ?? c.schema, c.sequence, c.toStart, c.toName); case "change-comment": return renderComment(c.table, c.schema, c.column, c.to); case "add-index": return renderCreateIndex(c.table, c.schema, c.index); // #285: an index OWNED by a UNIQUE / PRIMARY KEY / EXCLUDE constraint cannot be @@ -339,6 +346,34 @@ function renderRenameTable(c: Extract, dir: "u return (up ? lines : [...lines].reverse()).join("\n\n"); } +/** Constraints and indexes renamed in place on a table that keeps its name; the down renames them back. */ +function renderRenameConstraints(c: Extract, dir: "up" | "down"): string { + const up = dir === "up"; + const table = quoteQualified(c.table, c.schema); + const flip = (r: NameChange): NameChange => (up ? r : { from: r.to, to: r.from }); + // A promotion's down drops the constraint, which drops its index, then re-creates the + // plain index. Postgres refuses that drop while a foreign key relies on the key; the FK + // must be dropped first. + const promotions = (c.uniquePromotions ?? []).map((ix) => up + ? `ALTER TABLE ${table} ADD CONSTRAINT ${quote(ix.name)} UNIQUE USING INDEX ${quote(ix.name)};` + : [`ALTER TABLE ${table} DROP CONSTRAINT ${quote(ix.name)};`, + renderCreateIndex(c.table, c.schema, withoutConstraint(ix))].join("\n")); + const lines = [ + ...(c.constraintRenames ?? []).map(flip).map((r) => + `ALTER TABLE ${table} RENAME CONSTRAINT ${quote(r.from)} TO ${quote(r.to)};`), + ...(c.indexRenames ?? []).map(flip).map((r) => + `ALTER INDEX ${quoteIndexQualified(r.from, c.schema)} RENAME TO ${quote(r.to)};`), + ...promotions, + ]; + return (up ? lines : [...lines].reverse()).join("\n\n"); +} + +/** The index with no owning constraint: what a promotion started from. */ +function withoutConstraint(ix: IndexDescriptor): IndexDescriptor { + const { constraint: _owned, ...plain } = ix; + return plain; +} + function renderDown(c: Change, recreated: ReadonlySet = new Set()): string { switch (c.kind) { case "create-table": return `DROP TABLE ${quoteQualified(c.table.name, c.table.schema)};`; @@ -360,6 +395,7 @@ function renderDown(c: Change, recreated: ReadonlySet = new Set()): stri return parts.join("\n"); } case "rename-table": return renderRenameTable(c, "down"); + case "rename-constraints": return renderRenameConstraints(c, "down"); case "add-column": return `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP COLUMN ${quote(c.column.name)};`; case "drop-column": return c.restore @@ -376,8 +412,19 @@ function renderDown(c: Change, recreated: ReadonlySet = new Set()): stri ? `ALTER TABLE ${quoteQualified(c.table, c.schema)} ALTER COLUMN ${quote(c.column)} SET DEFAULT ${renderDefault(c.from)};` : `ALTER TABLE ${quoteQualified(c.table, c.schema)} ALTER COLUMN ${quote(c.column)} DROP DEFAULT;`; case "change-column-identity": return renderIdentity(c.table, c.schema, c.column, c.from); + // The down addresses the sequence by the name the up gave it, and runs in reverse order: + // rename back first, then restore the start. + case "alter-sequence": + // The counter is not moved back: values it has handed out stay handed out. + return [ + ...(c.toName !== undefined ? [`ALTER SEQUENCE ${quoteQualified(c.toName, c.sequenceSchema ?? c.schema)} RENAME TO ${quote(c.sequence)};`] : []), + ...(c.fromStart !== undefined ? [`ALTER SEQUENCE ${quoteQualified(c.sequence, c.sequenceSchema ?? c.schema)} START WITH ${c.fromStart};`] : []), + ].join("\n"); case "change-comment": return renderComment(c.table, c.schema, c.column, c.from); - case "add-index": return `DROP INDEX ${quoteIndexQualified(c.index.name, c.schema)};`; + case "add-index": + return isUniqueConstraint(c.index) + ? `ALTER TABLE ${quoteQualified(c.table, c.schema)} DROP CONSTRAINT ${quote(c.index.name)};` + : `DROP INDEX ${quoteIndexQualified(c.index.name, c.schema)};`; case "drop-index": // #285: the up dropped a CONSTRAINT, so the down must add one back — recreating a // bare unique index would leave the database in a different shape than it started @@ -432,7 +479,7 @@ function renderDown(c: Change, recreated: ReadonlySet = new Set()): stri function renderCreateTable(t: TableDescriptor): string { const colDefs = t.columns.map((c) => ` ${renderColumn(c)}`); if (t.primaryKey.length > 0) { - colDefs.push(` CONSTRAINT ${quote(t.name + "_pkey")} PRIMARY KEY (${t.primaryKey.map(quote).join(", ")})`); + colDefs.push(` CONSTRAINT ${quote(t.primaryKeyName ?? t.name + "_pkey")} PRIMARY KEY (${t.primaryKey.map(quote).join(", ")})`); } for (const chk of t.checks ?? []) { colDefs.push(` CONSTRAINT ${quote(chk.name)} CHECK (${chk.expression})`); @@ -455,9 +502,36 @@ function renderTableComments(t: TableDescriptor): string[] { return out; } -function renderIdentity(table: string, schema: string | undefined, column: string, to: "increment" | undefined): string { +/** + * Start first, under the live name; then the rename. `START WITH` alone only records the + * restart point, so the counter is moved up to the new start — never down, which could hand + * out an id already in use — by `setval(..., false)` when it has not reached it. + */ +function renderAlterSequence(schema: string | undefined, sequence: string, toStart: number | undefined, toName: string | undefined): string { + const seq = quoteQualified(sequence, schema); + const regclass = `'${seq.replace(/'/g, "''")}'`; + return [ + ...(toStart !== undefined + ? [ + `ALTER SEQUENCE ${seq} START WITH ${toStart};`, + `SELECT setval(${regclass}, ${toStart}, false) FROM ${seq} WHERE last_value < ${toStart};`, + ] + : []), + ...(toName !== undefined ? [`ALTER SEQUENCE ${seq} RENAME TO ${quote(toName)};`] : []), + ].join("\n"); +} + +function renderIdentity( + table: string, + schema: string | undefined, + column: string, + to: "increment" | undefined, + sequence?: { name?: string; start?: number }, +): string { const alter = `ALTER TABLE ${quoteQualified(table, schema)} ALTER COLUMN ${quote(column)}`; - return to === "increment" ? `${alter} ADD GENERATED BY DEFAULT AS IDENTITY;` : `${alter} DROP IDENTITY IF EXISTS;`; + return to === "increment" + ? `${alter} ADD GENERATED BY DEFAULT AS IDENTITY${identityOptions(sequence !== undefined ? { sequence } : {})};` + : `${alter} DROP IDENTITY IF EXISTS;`; } function renderComment(table: string, schema: string | undefined, column: string | undefined, to: string | undefined): string { @@ -502,7 +576,7 @@ function hazardComment(h: DataHazard): string { function renderColumn(c: ColumnDescriptor): string { let s = `${quote(c.name)} ${pgType(c.sqlType)}`; - if (c.identity === "increment") s += " GENERATED BY DEFAULT AS IDENTITY"; + if (c.identity === "increment") s += " GENERATED BY DEFAULT AS IDENTITY" + identityOptions(c); if (c.identity === "uuid") s += " DEFAULT gen_random_uuid()"; s += c.nullable ? "" : " NOT NULL"; if (c.default !== undefined && c.identity === undefined) { @@ -514,6 +588,15 @@ function renderColumn(c: ColumnDescriptor): string { return s; } +/** ` (SEQUENCE NAME "x" START WITH n)` for what the model declares, else nothing. */ +function identityOptions(c: Pick): string { + const opts = [ + ...(c.sequence?.name !== undefined ? [`SEQUENCE NAME ${quote(c.sequence.name)}`] : []), + ...(c.sequence?.start !== undefined ? [`START WITH ${c.sequence.start}`] : []), + ]; + return opts.length === 0 ? "" : ` (${opts.join(" ")})`; +} + export function pgType(t: SqlType): string { switch (t.kind) { case "text": return t.maxLength !== undefined ? `VARCHAR(${t.maxLength})` : "TEXT"; @@ -533,6 +616,7 @@ export function pgType(t: SqlType): string { case "blob": return "BYTEA"; case "uuid": return "UUID"; case "inet": return "INET"; + case "interval": return "INTERVAL"; case "array": return `${pgType(t.element)}[]`; } } @@ -543,7 +627,15 @@ function renderDefault(d: ColumnDefault): string { return `'${pgEscape(d.value)}'`; } +/** A unique key the schema conventions create as a constraint (only a plain column key can be one). */ +function isUniqueConstraint(ix: IndexDescriptor): boolean { + return ix.constraint === "unique" && canBeUniqueConstraint(ix); +} + function renderCreateIndex(table: string, schema: string | undefined, ix: IndexDescriptor): string { + if (isUniqueConstraint(ix)) { + return `ALTER TABLE ${quoteQualified(table, schema)} ADD CONSTRAINT ${quote(ix.name)} UNIQUE (${ix.columns.map(quote).join(", ")});`; + } const u = ix.unique ? "UNIQUE " : ""; // Key list: a raw expression (functional/expression index) takes precedence over // the per-column list. The expression is emitted verbatim (already valid SQL). diff --git a/server/typescript/packages/migrate-ts/src/emit/sqlite.ts b/server/typescript/packages/migrate-ts/src/emit/sqlite.ts index 9689dafbf0..19ce6422dd 100644 --- a/server/typescript/packages/migrate-ts/src/emit/sqlite.ts +++ b/server/typescript/packages/migrate-ts/src/emit/sqlite.ts @@ -41,12 +41,13 @@ const STAGE_ORDER: Record = { // rename-table runs before every other table change (mirrors postgres): changes on a // renamed table — including its recreate-and-copy — are keyed by the NEW name. "rename-table": 0.5, + "rename-constraints": 0.6, "drop-fk": 1, "drop-check": 1, "create-table": 1, "drop-index": 1.5, "add-column": 2, "drop-column": 2, "change-column-type": 2, "change-column-nullable": 2, "change-column-default": 2, - "change-column-identity": 2, "change-comment": 2, + "change-column-identity": 2, "change-comment": 2, "alter-sequence": 2, "rename-column": 3, "add-index": 4, "add-fk": 5, @@ -151,6 +152,7 @@ export function changeTable(c: Change): string | undefined { case "create-table": return c.table.name; case "drop-table": return c.table; case "rename-table": return c.from; // applies to source table + case "rename-constraints": return c.table; case "add-column": case "drop-column": case "rename-column": @@ -390,6 +392,8 @@ function renderUpNative(c: Change): string { assertNoCarriedRenames(c); return `ALTER TABLE ${quote(c.from)} RENAME TO ${quote(c.to)};`; } + case "rename-constraints": + throw new Error("rename-constraints is postgres-only — the sqlite/d1 diff never produces it (sqlite cannot rename a constraint)"); case "add-column": return `ALTER TABLE ${quote(c.table)} ADD COLUMN ${renderColumnInline(c.column)};`; case "drop-column": return `ALTER TABLE ${quote(c.table)} DROP COLUMN ${quote(c.column)};`; case "rename-column": { @@ -411,7 +415,8 @@ function renderUpNative(c: Change): string { throw new Error(`renderUpNative: ${c.kind} should have been handled by recreate bundler`); case "change-column-identity": case "change-comment": - // Planned only on the Postgres shadow path; SQLite has neither GENERATED identity nor COMMENT. + case "alter-sequence": + // Planned only on Postgres; SQLite has no GENERATED identity, COMMENT or sequence. throw new Error(`renderUpNative: ${c.kind} is a Postgres-only change`); // SQLite has no schema namespacing for views and no CREATE OR REPLACE VIEW; // a replace is DROP + CREATE. The view body lives in ViewDescriptor.sql. @@ -465,6 +470,8 @@ function renderDownNative(c: Change): string { assertNoCarriedRenames(c); return `ALTER TABLE ${quote(c.to)} RENAME TO ${quote(c.from)};`; } + case "rename-constraints": + throw new Error("rename-constraints is postgres-only — the sqlite/d1 diff never produces it (sqlite cannot rename a constraint)"); case "add-column": return `ALTER TABLE ${quote(c.table)} DROP COLUMN ${quote(c.column.name)};`; case "drop-column": return renderDropColumnDown(c); case "rename-column": { @@ -486,7 +493,8 @@ function renderDownNative(c: Change): string { throw new Error(`renderDownNative: ${c.kind} should have been handled by recreate bundler`); case "change-column-identity": case "change-comment": - // Planned only on the Postgres shadow path; SQLite has neither GENERATED identity nor COMMENT. + case "alter-sequence": + // Planned only on Postgres; SQLite has no GENERATED identity, COMMENT or sequence. throw new Error(`renderDownNative: ${c.kind} is a Postgres-only change`); case "create-view": return `DROP VIEW IF EXISTS ${quote(c.view.name)};`; // The view as the DB held it (`restore`) is a valid restore payload: a view dropped @@ -584,6 +592,7 @@ export function sqliteType(t: SqlType, identity: ColumnDescriptor["identity"]): case "blob": return "BLOB"; case "uuid": return "TEXT"; case "inet": return "TEXT"; // SQLite has no inet type; store as text + case "interval": return "INTEGER"; // the duration's microseconds (orders as the span does) case "array": return "TEXT"; // SQLite has no array type; store as JSON text } } diff --git a/server/typescript/packages/migrate-ts/src/errors.ts b/server/typescript/packages/migrate-ts/src/errors.ts index 06d51a947a..f9cba66068 100644 --- a/server/typescript/packages/migrate-ts/src/errors.ts +++ b/server/typescript/packages/migrate-ts/src/errors.ts @@ -35,6 +35,47 @@ export class SetNullNotNullableError extends Error { } } +/** One identity.reference whose target fields are not a unique key on the table it references. */ +export interface FkTargetViolation { + /** The holder's table, qualified (`.
`): what `scopeExpectedSchema` governs by. */ + table: string; + entityName: string; + referenceName: string; + targetEntityName: string; + targetFields: string[]; + /** Set when the target is a TPH subtype: the entity whose table it is stored in. */ + storedIn?: string; +} + +/** + * Thrown by buildExpectedSchema() when one or more identity.references target fields the + * target table does not hold unique: not its primary key, not a plain identity.secondary, + * not a single @unique field. A database refuses such an FK at apply time, after earlier + * statements in the migration already ran; refusing here names every offending reference + * at once, so a model with several is fixed in one pass rather than one run per reference. + */ +export class FkTargetNotUniqueError extends Error { + override readonly name = "FkTargetNotUniqueError"; + readonly violations: FkTargetViolation[]; + + constructor(violations: FkTargetViolation[]) { + const lines = violations.map((v) => { + const fieldList = v.targetFields.join(", "); + const head = `Entity "${v.entityName}": reference "${v.referenceName}" targets [${fieldList}] on "${v.targetEntityName}", ` + + `which is not a unique key of its table (its primary key, an identity.secondary without @where/@expr, or a @unique field). `; + return v.storedIn !== undefined + ? head + `"${v.targetEntityName}" is stored in the table of "${v.storedIn}", and a key declared on a TPH subtype is not emitted: ` + + `declare it on "${v.storedIn}" (an identity.secondary with @fields [${fieldList}]).` + : head + `Fix: declare an identity.secondary on "${v.targetEntityName}" with @fields [${fieldList}], or reference its whole primary key.`; + }); + const head = violations.length === 1 + ? "A database refuses this foreign key." + : `A database refuses these ${violations.length} foreign keys.`; + super(`${head}\n${lines.map((l) => ` - ${l}`).join("\n")}`); + this.violations = violations; + } +} + const ALLOW_OPTION_BY_KIND: Partial> = { "drop-column": "dropColumn", "drop-table": "dropTable", @@ -75,6 +116,7 @@ function changeLocator(c: Change): string { case "change-column-nullable": case "change-column-default": case "change-column-identity": + case "alter-sequence": return `${c.table}.${"column" in c ? c.column : c.from}`; case "change-comment": return c.column !== undefined ? `${c.table}.${c.column}` : c.table; @@ -83,6 +125,8 @@ function changeLocator(c: Change): string { case "drop-table": case "rename-table": return c.kind === "drop-table" ? c.table : `${c.from}→${c.to}`; + case "rename-constraints": + return `${c.table}: ${[...(c.constraintRenames ?? []), ...(c.indexRenames ?? [])].map((r) => `${r.from}→${r.to}`).join(", ")}`; case "create-table": return c.table.name; case "add-index": @@ -243,6 +287,13 @@ export class DuplicateSqlNameError extends Error { "(e.g. `ownersByEmail`).", ); } + if (collisions.some((c) => c.owners.some((o) => o.includes("@sequenceName")))) { + fixes.push( + "A declared sequence name (identity.primary @sequenceName) is a relation in the same namespace: give each " + + "table's sequence its own name. An @sequenceName on an abstract base's identity is inherited by every " + + "entity that extends it, so declare it on each concrete entity instead.", + ); + } if (anyOther) { fixes.push("Rename one of the tables/views (entity source `@table`, projection `@kind view @view`)."); } @@ -282,6 +333,73 @@ export function isDuplicateSqlNameError(err: unknown): err is DuplicateSqlNameEr return err instanceof Error && err.name === "DuplicateSqlNameError"; } +/** One problem with a declared CHECK name (`@constraintName` on a validator or a field.enum). */ +export interface CheckNameProblem { + readonly table: string; + readonly name: string; + /** Another constraint on the same table already has the name. */ + readonly duplicateOf?: string; + /** The name is longer than Postgres's 63-byte identifier limit, in bytes. */ + readonly bytes?: number; +} + +/** + * A declared CHECK name that cannot be applied as written. Two constraints on one table with + * one name fail at apply on Postgres ("already exists") and, on SQLite, collapse in a + * name-keyed diff so every run proposes the CHECK again. A name over 63 bytes is truncated by + * Postgres when it is created, so the declared name never matches the database and the CHECK + * drifts forever. Either way the fix is to the metadata, so it is refused while the expected + * schema is built. + */ +export class InvalidCheckNameError extends Error { + override readonly name = "InvalidCheckNameError"; + readonly problems: readonly CheckNameProblem[]; + + constructor(problems: readonly CheckNameProblem[]) { + const lines = problems.map((p) => p.duplicateOf !== undefined + ? ` CHECK "${p.name}" on table "${p.table}" has the same name as ${p.duplicateOf}` + : ` CHECK "${p.name}" on table "${p.table}" is ${p.bytes} bytes; Postgres keeps only the first 63`); + super( + `ERR_CHECK_NAME: a declared @constraintName cannot be applied as written.\n${lines.join("\n")}\n` + + "Give each constraint on a table its own @constraintName, at most 63 bytes.", + ); + this.problems = problems; + } +} + +/** Is `err` the {@link InvalidCheckNameError} from a declared CHECK name that cannot be applied? */ +export function isInvalidCheckNameError(err: unknown): err is InvalidCheckNameError { + return err instanceof Error && err.name === "InvalidCheckNameError"; +} + +/** `migrate.conventions` names a schema-conventions preset that does not exist. */ +export class UnknownSchemaConventionsError extends Error { + override readonly name = "UnknownSchemaConventionsError"; + constructor(readonly selected: string, readonly presets: readonly string[]) { + super(`migrate.conventions "${selected}" is not a schema-conventions preset: expected one of ${presets.join(", ")}`); + } +} + +/** A project-supplied schema-conventions object lacks members this version calls. */ +export class InvalidSchemaConventionsError extends Error { + override readonly name = "InvalidSchemaConventionsError"; + constructor(readonly id: string, readonly missing: readonly string[]) { + super(`the schema conventions "${id}" are missing ${missing.join(", ")} — start from a preset ` + + `({ ...efcoreConventions, id: "${id}", … }) so every member this version calls is present`); + } +} + +/** Is `err` the {@link UnknownSchemaConventionsError}? */ +export function isUnknownSchemaConventionsError(err: unknown): err is UnknownSchemaConventionsError | InvalidSchemaConventionsError { + // Both are a project's conventions selection the tool cannot use: a config error. + return err instanceof Error && (err.name === "UnknownSchemaConventionsError" || err.name === "InvalidSchemaConventionsError"); +} + +/** Is `err` the {@link FkTargetNotUniqueError} from FKs whose targets are not unique keys? */ +export function isFkTargetNotUniqueError(err: unknown): err is FkTargetNotUniqueError { + return err instanceof Error && err.name === "FkTargetNotUniqueError"; +} + /** Is `err` the {@link DeclaredRenameError} from a declared rename that does not apply? */ export function isDeclaredRenameError(err: unknown): err is DeclaredRenameError { return err instanceof Error && err.name === "DeclaredRenameError"; diff --git a/server/typescript/packages/migrate-ts/src/expected-schema.ts b/server/typescript/packages/migrate-ts/src/expected-schema.ts index 6f41e955f7..e375f11d49 100644 --- a/server/typescript/packages/migrate-ts/src/expected-schema.ts +++ b/server/typescript/packages/migrate-ts/src/expected-schema.ts @@ -1,11 +1,15 @@ -import { DuplicateSqlNameError } from "./errors.js"; +import { + DuplicateSqlNameError, FkTargetNotUniqueError, InvalidCheckNameError, type CheckNameProblem, type FkTargetViolation, +} from "./errors.js"; +import { negateExpression, renderExpressionSql } from "./check-expression.js"; +import { canBeUniqueConstraint, resolveSchemaConventions, type SchemaConventions } from "./schema-conventions.js"; import type { ColumnNamingStrategy, MetaData, MetaField, MetaObject, MetaReferenceIdentity, 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_SUBTYPE_PRESENT_IFF, VALIDATOR_SUBTYPE_AT_LEAST_ONE, VALIDATOR_SUBTYPE_EXACTLY_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, @@ -46,6 +50,10 @@ import { FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, + FIELD_SUBTYPE_DURATION, + formatPostgresInterval, + parseDurationLiteral, + parsePostgresInterval, FIELD_SUBTYPE_ENUM, FIELD_ATTR_VALUES, FIELD_ATTR_INT_VALUE_MAP, @@ -59,7 +67,13 @@ import { STORAGE_FLATTENED, DOC_ATTR_DESCRIPTION, applyColumnNamingStrategy, physicalNamingOf, - resolveTableName, resolveColumnName, resolveTableSchema, resolveIndexName, + resolveTableName, resolveColumnName, resolveFlattenedColumnName, resolveTableSchema, resolveIndexName, + resolveEnumMaxLength, + IDENTITY_ATTR_SEQUENCE_NAME, + IDENTITY_ATTR_SEQUENCE_START, + VALIDATOR_ATTR_CONDITION, + VALIDATOR_ATTR_CONSTRAINT_NAME, + type ExprNode, } from "@metaobjectsdev/metadata"; import type { SqlType } from "./sql-type.js"; import type { @@ -69,6 +83,7 @@ import type { import { qualifiedDbName } from "./qualified-name.js"; import { SQLITE_ISO_NOW } from "./column-default.js"; import { viewFingerprint } from "./view-fingerprint.js"; +import { DIALECT_SQLITE, PG_IDENTIFIER_MAX_BYTES, isSqliteFamily } from "./types.js"; import { resolveReferentialActions, findReferentialActionConflict, @@ -106,8 +121,29 @@ export interface BuildExpectedSchemaOptions { * Advisory: the schema built is the same with or without it. */ onReferentialActionConflict?: (conflict: ReferentialActionConflict) => void; + /** + * Names and physical choices for the objects no code addresses (PK and FK constraint + * names, FK indexes, unique key kind). Absent = `defaultConventions`, what migrate has + * always emitted. Resolved by the caller (the CLI reads `migrate.conventions` and + * `metaobjects.config.ts`); this build reads no project setting off the model. + */ + conventions?: SchemaConventions | undefined; + /** + * Leave out a validator CHECK the column already guarantees: a `validator.length` maximum + * the column's VARCHAR(n) enforces (never on SQLite / D1, which do not enforce a declared + * length) and a length minimum of 0 or less. A validator with a declared `@constraintName` + * keeps its CHECK regardless. `migrate.checks.skipRedundant` in `.metaobjects/config.json`; + * absent = false, every CHECK emitted. + */ + skipRedundantChecks?: boolean | undefined; } +/** + * The project settings an expected-schema build takes, which every path that builds one + * (migrate, drift, the offline plan, a baseline) passes through unchanged so they agree. + */ +export type SchemaBuildSettings = Pick; + /** * A view as the caller (codegen-ts `buildProjectionViews`) produces it — structurally * an `ExpectedView`. It becomes a full `ViewDescriptor` in Pass 4, which is where the @@ -144,6 +180,19 @@ export type SchemaProvenance = ReadonlyMap; export interface ExpectedSchemaWithProvenance { snapshot: SchemaSnapshot; provenance: SchemaProvenance; + /** + * Foreign keys whose target columns are not a unique key of the table they reference. + * Not thrown here: `scopeExpectedSchema` refuses the ones on governed tables and returns + * the rest (an imported or out-of-scope holder is not this project's to fix), and + * {@link buildExpectedSchema} refuses them all. Absent when there are none. + */ + fkTargetViolations?: FkTargetViolation[]; + /** + * Set on SQLite (not D1) when there are violations: SQLite checks foreign keys only with + * `PRAGMA foreign_keys` on, which is off by default, so the `CREATE TABLE` succeeds and only + * a write with enforcement on fails. The violations are then reported, never refused. + */ + fkTargetsUnenforced?: true; } /** @@ -154,7 +203,12 @@ export function buildExpectedSchema( root: MetaData, opts?: BuildExpectedSchemaOptions, ): SchemaSnapshot { - return buildExpectedSchemaWithProvenance(root, opts).snapshot; + const built = buildExpectedSchemaWithProvenance(root, opts); + // No scope here: every FK whose target is not a unique key is refused. + if (built.fkTargetViolations !== undefined && built.fkTargetsUnenforced === undefined) { + throw new FkTargetNotUniqueError(built.fkTargetViolations); + } + return built.snapshot; } /** @@ -255,11 +309,16 @@ export function buildExpectedSchemaWithProvenance( const provenance = new Map(); // Pass 2: build full descriptors with FK resolution. + // The caller's conventions (checked for every member), else migrate's default. + const conventions = resolveSchemaConventions(opts?.conventions); + const skipRedundantChecks = opts?.skipRedundantChecks === true; + const fkCtx: ForeignKeyBuildContext = { conventions, dialect, origins: new Map() }; // Schema is resolved here (not stored in Pass 1) to avoid exactOptionalPropertyTypes // issues with `string | undefined` vs `schema?: string`. const tables: TableDescriptor[] = entities.map(({ entity, tableName }) => { const t = buildTable( - entity, tableName, resolveTargetTable, root as MetaRoot, strategy, dialect, opts?.onReferentialActionConflict, + entity, tableName, resolveTargetTable, root as MetaRoot, strategy, dialect, fkCtx, skipRedundantChecks, + opts?.onReferentialActionConflict, ); const schema = resolveTableSchema(entity); if (schema !== undefined) t.schema = schema; @@ -267,6 +326,12 @@ export function buildExpectedSchemaWithProvenance( return t; }); + // Checked against the BUILT tables, before the conventions add FK indexes: a key the model + // declares but never emits (on a TPH subtype) is no key a database can reference. + const fkTargetViolations = collectFkTargetViolations(tables, fkCtx.origins, provenance); + applySchemaConventions(tables, conventions, dialect); + checkDeclaredCheckNames(tables, dialect); + // Pass 3: dialect-specific SqlType normalization. if (dialect === "sqlite") { for (const table of tables) { @@ -289,6 +354,11 @@ export function buildExpectedSchemaWithProvenance( const normalized = normalizeBooleanLiteralForSqlite(col.default.value); if (normalized !== undefined) col.default = { kind: "literal", value: normalized }; } + // A duration's default is built in the Postgres interval spelling; the SQLite column holds + // the microsecond integer, which is what the runtime writes there. + if (kindBefore === "interval" && col.default?.kind === "literal") { + col.default = { kind: "literal", value: String(parsePostgresInterval(col.default.value)) }; + } // `now()` is Postgres-only; SQLite has no such function, so a // `DEFAULT now()` (the @autoSet insert-time default, and any authored // @default "now()") makes the emitted CREATE TABLE un-appliable @@ -399,6 +469,16 @@ export function buildExpectedSchemaWithProvenance( addOwner(t.schema, idx.name, label); } } + // A declared sequence name is a relation too (Postgres `pg_class`, per schema): two tables + // declaring one, or one naming a table, view or index, would fail at apply time — an + // identity.primary @sequenceName on an abstract base is inherited by every entity extending it. + for (const t of tables) { + for (const col of t.columns) { + if (col.sequence?.name !== undefined) { + addOwner(t.schema, col.sequence.name, `identity.primary @sequenceName "${col.sequence.name}" on table "${t.name}"`); + } + } + } const collisions = [...sqlNameOwners.entries()] .filter(([, owners]) => owners.length > 1) .map(([key, owners]) => ({ @@ -408,7 +488,13 @@ export function buildExpectedSchemaWithProvenance( })); if (collisions.length > 0) throw new DuplicateSqlNameError(collisions); - return { snapshot: { tables, views }, provenance }; + return { + snapshot: { tables, views }, + provenance, + ...(fkTargetViolations.length > 0 ? { fkTargetViolations } : {}), + // D1 always enforces foreign keys; plain SQLite only with the pragma on. + ...(fkTargetViolations.length > 0 && opts?.dialect === DIALECT_SQLITE ? { fkTargetsUnenforced: true as const } : {}), + }; } /** @@ -443,6 +529,9 @@ function normalizeForSqlite(sqlType: SqlType): SqlType { case "date": case "time": return { kind: "text" }; + case "interval": + // A duration is the 64-bit microsecond count: an integer orders and compares as the span. + return { kind: "integer", bits: 64 }; case "integer": // SQLite stores every INTEGER as a 64-bit value and Drizzle's int() emits // plain "INTEGER" regardless of source bit-width. Collapse 32 → 64 so the @@ -518,6 +607,8 @@ function buildTable( root: MetaRoot, strategy: ColumnNamingStrategy, dialect: Dialect | undefined, + fkCtx: ForeignKeyBuildContext, + skipRedundantChecks: boolean, onConflict?: (conflict: ReferentialActionConflict) => void, ): TableDescriptor { // Use effective accessors so inherited fields/identities (from `extends:` / @@ -553,7 +644,9 @@ function buildTable( // The parent field.object itself does NOT produce its own column. columns.push(...flattenObjectField(field, strategy)); } else { - columns.push(buildColumn(field, isPk, isPk ? pkGeneration : undefined, strategy)); + const col = buildColumn(field, isPk, isPk ? pkGeneration : undefined, strategy); + if (isPk && pkGeneration === "increment" && pkIdentity !== undefined) applyDeclaredSequence(col, pkIdentity); + columns.push(col); } } @@ -601,8 +694,8 @@ function buildTable( name: tableName, columns, indexes: buildSecondaryIndexes(entity, tableName, strategy), - foreignKeys: buildForeignKeys(entity, tableName, resolveTargetTable, root, strategy, onConflict), - checks: buildChecks(entity, tableName, strategy, dialect), + foreignKeys: buildForeignKeys(entity, tableName, resolveTargetTable, root, strategy, fkCtx, onConflict), + checks: buildChecks(entity, tableName, strategy, dialect, skipRedundantChecks), primaryKey, }; const entityDesc = readDescription(entity); @@ -750,7 +843,13 @@ function buildSecondaryIndexes( * column name verbatim (matching the enum-check convention). */ function validatorCheck( - v: MetaValidator, qcol: string, tableName: string, col: string, dialect: Dialect | undefined, + v: MetaValidator, + qcol: string, + tableName: string, + col: string, + dialect: Dialect | undefined, + skipRedundant = false, + enforcedMax?: number, ): CheckDescriptor | null { switch (v.subType) { case VALIDATOR_SUBTYPE_NUMERIC: { @@ -762,8 +861,10 @@ function validatorCheck( } case VALIDATOR_SUBTYPE_LENGTH: { const parts: string[] = []; - if (v.min !== undefined) parts.push(`length(${qcol}) >= ${v.min}`); - if (v.max !== undefined) parts.push(`length(${qcol}) <= ${v.max}`); + // Under skipRedundant, a minimum of 0 is always true and a maximum the column's + // VARCHAR(n) already enforces (n <= max) adds nothing. + if (v.min !== undefined && !(skipRedundant && v.min <= 0)) parts.push(`length(${qcol}) >= ${v.min}`); + if (v.max !== undefined && !(enforcedMax !== undefined && enforcedMax <= v.max)) parts.push(`length(${qcol}) <= ${v.max}`); if (parts.length === 0) return null; return { name: `${tableName}_${col}_length_chk`, expression: parts.join(" AND ") }; } @@ -797,7 +898,11 @@ function quoteCheckCol(col: string): string { } function buildChecks( - entity: MetaObject, tableName: string, strategy: ColumnNamingStrategy, dialect: Dialect | undefined, + entity: MetaObject, + tableName: string, + strategy: ColumnNamingStrategy, + dialect: Dialect | undefined, + skipRedundant: boolean, ): CheckDescriptor[] { const checks: CheckDescriptor[] = []; for (const field of entity.fields()) { @@ -840,19 +945,30 @@ function buildChecks( } else { expression = `${qcol} IN (${values.map((v) => `'${v.replace(/'/g, "''")}'`).join(", ")})`; } - checks.push({ name: `${tableName}_${col}_chk`, expression }); + const enumCheck = { name: `${tableName}_${col}_chk`, expression }; + checks.push(withDeclaredName(enumCheck, field)); } } // Validator-derived checks. + // The VARCHAR(n) the column already is, when the project skips what it enforces. Not on + // SQLite / D1: they accept a declared length and store longer values anyway. + const columnType = subtypeToSqlType(field); + const enforcesLength = !isSqliteFamily(dialect); + const enforcedMax = skipRedundant && enforcesLength && columnType.kind === "text" ? columnType.maxLength : undefined; for (const v of field.validators()) { - const check = validatorCheck(v, qcol, tableName, col, dialect); - if (check) checks.push(check); + // A declared @constraintName asks for the CHECK by name, so it is kept even where the + // project's `migrate.checks.skipRedundant` would leave it out: a declaration beats a default. + const keep = declaredCheckName(v) !== undefined; + const check = keep + ? validatorCheck(v, qcol, tableName, col, dialect) + : validatorCheck(v, qcol, tableName, col, dialect, skipRedundant, enforcedMax); + if (check) checks.push(withDeclaredName(check, v)); } } // 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); + if (check) checks.push(withDeclaredName(withCondition(check, v, entity, strategy, dialect), v)); } return checks; } @@ -888,14 +1004,33 @@ function resolveRef( return { qcol: quoteCheckCol(resolveColumnName(field, strategy)), field }; } +/** + * A literal compared with a `field.duration` column, in the form the column stores: the + * interval spelling on Postgres (which refuses a signed ISO-8601 duration), the microsecond + * integer on SQLite and D1. A literal that is not a duration is refused here, where the CHECK is + * built, rather than by the database when it is applied. + */ +function durationLiteralSql(field: MetaData, raw: unknown, dialect: Dialect | undefined, where: string): string { + const micros = typeof raw === "string" ? parseDurationLiteral(raw) : undefined; + if (micros === undefined) { + throw new Error( + `${where} compares field.duration '${field.name}' with ${JSON.stringify(raw)}, which is not an ISO-8601 ` + + `duration (such as PT1H30M; no years or months) — cannot build the CHECK.`, + ); + } + if (dialect === "sqlite" || dialect === "d1") return String(micros); + return `'${formatPostgresInterval(micros)}'`; +} + /** * 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. + * a duration → the form its column stores, everything else → quoted string. */ function renderEquals( raw: unknown, whenField: ReturnType[number], dialect: Dialect | undefined, ): string { + if (whenField.subType === FIELD_SUBTYPE_DURATION) return durationLiteralSql(whenField, raw, dialect, "@equals"); const s = String(raw); if (whenField.subType === FIELD_SUBTYPE_BOOLEAN) { const truthy = s === "true" || s === "1" || s === "TRUE"; @@ -909,6 +1044,86 @@ function renderEquals( return `'${s.replace(/'/g, "''")}'`; } +/** A validator's or field.enum's declared CHECK name (`@constraintName`; ADR-0039 resolving), if any. */ +function declaredCheckName(node: MetaData): string | undefined { + const name = node.attr(VALIDATOR_ATTR_CONSTRAINT_NAME); + return typeof name === "string" && name.length > 0 ? name : undefined; +} + +/** The CHECKs whose name was declared (`@constraintName`), so the name checks below apply only + * to names an author wrote: a derived name keeps the behaviour it always had. */ +const DECLARED_CHECKS = new WeakSet(); + +function withDeclaredName(check: CheckDescriptor, node: MetaData): CheckDescriptor { + const name = declaredCheckName(node); + if (name === undefined) return check; + const named = { ...check, name }; + DECLARED_CHECKS.add(named); + return named; +} + +/** + * Refuse a declared CHECK name another constraint on the same table already has, or (on + * Postgres) one over 63 bytes. Runs after the conventions have named primary keys, foreign + * keys and unique constraints. A collision among derived names is left as it always was. + */ +function checkDeclaredCheckNames(tables: readonly TableDescriptor[], dialect: Dialect | undefined): void { + const problems: CheckNameProblem[] = []; + const limited = !isSqliteFamily(dialect); + for (const t of tables) { + const declared = new Set(t.checks.filter((c) => DECLARED_CHECKS.has(c)).map((c) => c.name)); + if (declared.size === 0) continue; + const owners = new Map(); + const reported = new Set(); + const claim = (name: string, label: string): void => { + const prior = owners.get(name); + if (prior === undefined) { + owners.set(name, label); + } else if (declared.has(name) && !reported.has(name)) { + reported.add(name); + problems.push({ table: t.name, name, duplicateOf: prior }); + } + }; + if (t.primaryKey.length > 0) claim(t.primaryKeyName ?? `${t.name}_pkey`, "the primary key"); + for (const fk of t.foreignKeys) claim(fk.name, `the foreign key "${fk.name}"`); + for (const ix of t.indexes) if (ix.constraint === "unique") claim(ix.name, `the unique constraint "${ix.name}"`); + for (const c of t.checks) claim(c.name, `another CHECK named "${c.name}"`); + if (!limited) continue; + for (const name of declared) { + const bytes = Buffer.byteLength(name, "utf8"); + if (bytes > PG_IDENTIFIER_MAX_BYTES) problems.push({ table: t.name, name, bytes }); + } + } + if (problems.length > 0) throw new InvalidCheckNameError(problems); +} + +/** + * A cross-field validator's `@condition` (an attr.expression): the rule applies only when it + * holds, so the CHECK is the negated condition OR the rule. A row the condition is NULL for is + * not checked, as for any CHECK. + */ +function withCondition( + check: CheckDescriptor, v: MetaValidator, entity: MetaObject, strategy: ColumnNamingStrategy, dialect: Dialect | undefined, +): CheckDescriptor { + const condition = v.attr(VALIDATOR_ATTR_CONDITION); + if (condition === undefined || condition === null || typeof condition !== "object") return check; + const column = (name: string): string | undefined => { + const field = entity.fields().find((f) => f.name === name); + return field === undefined ? undefined : quoteCheckCol(resolveColumnName(field, strategy)); + }; + // An int-backed enum (@intValueMap) stores a member as its integer, so a member compared + // with it is rendered as that integer; a duration is rendered in the form its column stores. + const stored = (name: string, value: unknown): string | undefined => { + const field = entity.fields().find((f) => f.name === name); + if (field?.subType === FIELD_SUBTYPE_DURATION) return durationLiteralSql(field, value, dialect, "@condition"); + const intMap = field === undefined ? undefined : intValueMapOf(field); + const n = intMap === undefined || typeof value !== "string" ? undefined : intMap[value]; + return typeof n === "number" ? String(n) : undefined; + }; + const unless = renderExpressionSql(negateExpression(condition as ExprNode), column, dialect, stored); + return { ...check, expression: `(${unless}) OR (${check.expression})` }; +} + /** * 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. @@ -964,6 +1179,20 @@ function crossFieldCheck( expression: refs.map((r) => `${r!.qcol} IS NOT NULL`).join(" OR "), }; } + case VALIDATOR_SUBTYPE_EXACTLY_ONE: { + // ADR-0039: effective attr — @fields may be inherited via the validator's extends. + const raw = v.attr(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); + // Postgres counts the present fields with num_nonnulls; SQLite and D1 add the presence + // tests, which they evaluate as 0 / 1. + const count = isSqliteFamily(dialect) + ? `(${refs.map((r) => `(${r!.qcol} IS NOT NULL)`).join(" + ")})` + : `num_nonnulls(${refs.map((r) => r!.qcol).join(", ")})`; + return { name: `${tableName}_${firstCol}_exactlyone_chk`, expression: `${count} = 1` }; + } default: return null; } @@ -975,6 +1204,7 @@ function buildForeignKeys( resolveTargetTable: (target: MetaObject) => string | undefined, root: MetaRoot, strategy: ColumnNamingStrategy, + fkCtx: ForeignKeyBuildContext, onConflict?: (conflict: ReferentialActionConflict) => void, ): FkDescriptor[] { const fks: FkDescriptor[] = []; @@ -1042,7 +1272,7 @@ function buildForeignKeys( const constraintName = typeof constraintNameOverride === "string" && constraintNameOverride.length > 0 ? constraintNameOverride - : `${tableName}_${fkCols[0]}_fk`; + : fkCtx.conventions.foreignKeyName({ table: tableName, columns: fkCols, refTable, refColumns, dialect: fkCtx.dialect }); // Guard: ON DELETE SET NULL requires nullable FK columns. validateSetNullNullability(holder, refChild, onDelete, constraintName); @@ -1058,17 +1288,128 @@ function buildForeignKeys( // Two subtypes may declare the same FK column; it folds to ONE column, so ONE constraint. if (fks.some((existing) => existing.name === fk.name)) continue; fks.push(fk); + fkCtx.origins.set(fk, { + entityName: holder.name, referenceName: refChild.name, targetEntityName: targetObj.name, + targetKey: targetObj.resolutionKey(), targetFields: targetFieldNames, + }); } return fks; } +/** What building a table's foreign keys needs beyond the table: the conventions, and where refusals go. */ +interface ForeignKeyBuildContext { + conventions: SchemaConventions; + dialect: Dialect | undefined; + /** Where each built FK came from, for the target check that runs once every table is built. */ + origins: Map; +} + +interface FkOrigin { + entityName: string; + referenceName: string; + targetEntityName: string; + /** The target entity's resolution key, to tell a TPH subtype from the entity owning the table. */ + targetKey: string; + targetFields: string[]; +} + +/** + * Every built FK whose referenced columns are not a unique key of the BUILT target table: its + * primary key, or a unique index with no predicate or expression, as a column set (Postgres + * accepts any order). A target this model does not build — an @unmanaged table, another + * owner's — is skipped: its keys are not known here, and the database is the judge. + */ +function collectFkTargetViolations( + tables: TableDescriptor[], + origins: ReadonlyMap, + provenance: ReadonlyMap, +): FkTargetViolation[] { + const setOf = (columns: readonly string[]) => [...columns].sort().join(","); + const violations: FkTargetViolation[] = []; + for (const t of tables) { + for (const fk of t.foreignKeys) { + // The emitter assumes the referenced table lives in the holder's schema. + const target = tables.find((x) => x.name === fk.refTable && x.schema === t.schema); + if (target === undefined) continue; + const wanted = setOf(fk.refColumns); + const keys = [ + target.primaryKey, + ...target.indexes.filter((ix) => ix.unique && ix.where === undefined && ix.expr === undefined).map((ix) => ix.columns), + ]; + if (keys.some((k) => k.length > 0 && setOf(k) === wanted)) continue; + const origin = origins.get(fk); + if (origin === undefined) continue; + const owner = provenance.get(qualifiedDbName(target)); + const storedIn = owner !== undefined && owner !== origin.targetKey ? owner.split("::").pop() : undefined; + violations.push({ + table: qualifiedDbName(t), + entityName: origin.entityName, + referenceName: origin.referenceName, + targetEntityName: origin.targetEntityName, + targetFields: origin.targetFields, + ...(storedIn !== undefined ? { storedIn } : {}), + }); + } + } + return violations; +} + +/** + * The schema conventions' second pass, over every built table: the primary key's name, the + * kind of each unique key (an FK anywhere in the model may reference it), and the index each + * foreign key gets. FK names were decided while the FKs were built, where a declared + * `@constraintName` is known. The default conventions change nothing here. + */ +function applySchemaConventions(tables: TableDescriptor[], conventions: SchemaConventions, dialect: Dialect | undefined): void { + // An FK references a table in its own schema (the emitter's assumption), so the key is schema-qualified. + const keyOf = (schema: string | undefined, table: string, columns: readonly string[]) => + `${schema ?? ""}.${table}|${[...columns].sort().join(",")}`; + const referenced = new Set(tables.flatMap((t) => t.foreignKeys.map((fk) => keyOf(t.schema, fk.refTable, fk.refColumns)))); + for (const t of tables) { + if (t.primaryKey.length > 0) { + const pkName = conventions.primaryKeyName({ table: t.name, columns: t.primaryKey, dialect }); + if (pkName !== `${t.name}_pkey`) t.primaryKeyName = pkName; + } + for (const ix of t.indexes) { + // Only a plain key can be a UNIQUE constraint: one with a predicate, an expression, a DESC + // column or a non-btree method stays an index whatever the conventions prefer. + if (!canBeUniqueConstraint(ix)) continue; + const kind = conventions.uniqueKeyKind({ table: t.name, columns: ix.columns, dialect, referencedByForeignKey: referenced.has(keyOf(t.schema, t.name, ix.columns)) }); + if (kind === "constraint") ix.constraint = "unique"; + else if (kind === "index") ix.plainIndex = true; + } + const fkIndexes = conventions.foreignKeyIndexes({ + table: t.name, + dialect, + primaryKey: t.primaryKey, + indexes: t.indexes, + foreignKeys: t.foreignKeys.map((fk) => ({ table: t.name, columns: fk.columns, refTable: fk.refTable, refColumns: fk.refColumns, dialect })), + }); + for (const index of fkIndexes) { + if (t.indexes.some((ix) => ix.name === index.name)) { + throw new DuplicateSqlNameError([{ + name: index.name, + owners: [ + `the ${conventions.id} conventions' foreign-key index on [${index.columns.join(", ")}] of table "${t.name}"`, + `an index already named "${index.name}" on table "${t.name}"`, + ], + involvesIndex: true, + }]); + } + t.indexes.push({ name: index.name, columns: [...index.columns], unique: false }); + } + } +} + + /** * Expand a `field.object @storage "flattened"` into one ColumnDescriptor per - * nested field of the referenced value-object, prefixed by the parent field's - * resolved column name + underscore. + * nested field of the referenced value-object, each named by core naming's + * `resolveFlattenedColumnName` (the field's `@flattenedColumnPrefix`, else its resolved + * column and `_`, then the member's own column), so every port spells these + * columns the way this migration creates them. * - * EF OwnsOne pattern: no JSON column for the parent itself; each nested field - * becomes `_` in the owning entity's table. + * EF OwnsOne pattern: no JSON column for the parent itself. */ function flattenObjectField( field: MetaData, strategy: ColumnNamingStrategy, @@ -1076,13 +1417,10 @@ function flattenObjectField( // ADR-0039: resolving — @objectRef may be inherited via extends. const targetObject = refTarget(field, FIELD_ATTR_OBJECT_REF); if (targetObject === undefined || !isMetaObject(targetObject)) return []; - const prefix = resolveColumnName(field, strategy) + "_"; - const cols: ColumnDescriptor[] = []; - for (const nested of targetObject.fields()) { - const inner = buildColumn(nested, /* isPk */ false, /* pkGeneration */ undefined, strategy); - cols.push({ ...inner, name: prefix + inner.name }); - } - return cols; + return targetObject.fields().map((nested) => ({ + ...buildColumn(nested, /* isPk */ false, /* pkGeneration */ undefined, strategy), + name: resolveFlattenedColumnName(field, nested, strategy), + })); } /** @@ -1100,6 +1438,21 @@ const EXPR_DEFAULT_PATTERNS = [ /\(\)/, // anything function-like ]; +/** + * The declared start and name of an increment key's sequence (identity.primary + * @sequenceStart / @sequenceName; ADR-0039: resolving). Only what is declared is set, so only + * that is compared against a live sequence. + */ +function applyDeclaredSequence(col: ColumnDescriptor, pkIdentity: MetaData): void { + const start = pkIdentity.attr(IDENTITY_ATTR_SEQUENCE_START); + const name = pkIdentity.attr(IDENTITY_ATTR_SEQUENCE_NAME); + if (typeof start !== "number" && typeof name !== "string") return; + col.sequence = { + ...(typeof name === "string" ? { name } : {}), + ...(typeof start === "number" ? { start } : {}), + }; +} + function buildColumn( field: MetaData, isPk: boolean, @@ -1124,7 +1477,18 @@ function buildColumn( // against @values by the loader (FR-011 Check 5), so a miss here is unreachable; // throw rather than silently emit the symbol, which would fail only at apply time. const enumIntMap = field.subType === FIELD_SUBTYPE_ENUM ? intValueMapOf(field) : undefined; - if (enumIntMap !== undefined) { + if (field.subType === FIELD_SUBTYPE_DURATION && col.sqlType.kind === "interval") { + // Postgres refuses a signed ISO-8601 duration as interval input and stores any default in + // its own spelling, so the default is written in that spelling: it reads back unchanged. + const micros = parseDurationLiteral(defaultRaw); + if (micros === undefined) { + throw new Error( + `field.duration '${field.name}' @default '${defaultRaw}' is not an ISO-8601 duration ` + + `(such as PT1H30M; no years or months) — cannot build the column default for '${col.name}'.`, + ); + } + col.default = { kind: "literal", value: formatPostgresInterval(micros) }; + } else if (enumIntMap !== undefined) { const mapped = enumIntMap[defaultRaw]; if (typeof mapped !== "number") { throw new Error( @@ -1165,7 +1529,6 @@ function buildColumn( if (isPk && (pkGeneration === "increment" || pkGeneration === "uuid")) { col.identity = pkGeneration; } - const fieldDesc = readDescription(field); if (fieldDesc !== undefined) col.description = fieldDesc; @@ -1213,6 +1576,7 @@ function arrayElementSqlType(field: MetaData): SqlType | undefined { // ADR-0039: resolving — @localTime may be inherited via extends. return { kind: "timestamp", withTimezone: field.attr(FIELD_ATTR_LOCAL_TIME) !== true }; case FIELD_SUBTYPE_INET: return { kind: "inet" }; + case FIELD_SUBTYPE_DURATION: return { kind: "interval" }; default: return undefined; // object/map → single jsonb column } } @@ -1296,10 +1660,18 @@ function subtypeToSqlType(field: MetaData): SqlType { // stores as text (the native inet column would reject a not-strictly-valid // value at INSERT). ADR-0039: resolving — @lenient may be inherited via extends. case FIELD_SUBTYPE_INET: return field.attr(FIELD_ATTR_LENIENT) === true ? { kind: "text" } : { kind: "inet" }; + case FIELD_SUBTYPE_DURATION: return { kind: "interval" }; // Postgres native INTERVAL; a BIGINT count of microseconds on SQLite // A string-backed field.enum is a text column with a membership CHECK; an // INT-BACKED one (@intValueMap, design D5) stores the mapped integer instead. // The TS/wire type is the member string either way — only the column differs. - case FIELD_SUBTYPE_ENUM: return isIntBackedEnum(field) ? { kind: "integer", bits: 32 } : { kind: "text" }; + // A string-backed one with a length (its @maxLength, else the project's + // enums.maxLength — core's resolveEnumMaxLength, which refuses a member that does not + // fit) is VARCHAR(n); with none it stays unbounded text. + case FIELD_SUBTYPE_ENUM: { + if (isIntBackedEnum(field)) return { kind: "integer", bits: 32 }; + const length = resolveEnumMaxLength(field); + return length === undefined ? { kind: "text" } : { kind: "text", maxLength: length }; + } default: return { kind: "text" }; // unknown → text fallback } } diff --git a/server/typescript/packages/migrate-ts/src/expected.ts b/server/typescript/packages/migrate-ts/src/expected.ts index 2be7b18c73..a759a1a737 100644 --- a/server/typescript/packages/migrate-ts/src/expected.ts +++ b/server/typescript/packages/migrate-ts/src/expected.ts @@ -7,7 +7,7 @@ // connection; keep it that way. export { buildExpectedSchema, buildExpectedSchemaWithProvenance } from "./expected-schema.js"; -export type { BuildExpectedSchemaOptions, ExpectedSchemaWithProvenance, SchemaProvenance } from "./expected-schema.js"; +export type { BuildExpectedSchemaOptions, SchemaBuildSettings, ExpectedSchemaWithProvenance, SchemaProvenance } from "./expected-schema.js"; export { qualifiedDbName } from "./qualified-name.js"; export type { SqlType } from "./sql-type.js"; export type { diff --git a/server/typescript/packages/migrate-ts/src/index.ts b/server/typescript/packages/migrate-ts/src/index.ts index 8104ab2dce..b30ea8d700 100644 --- a/server/typescript/packages/migrate-ts/src/index.ts +++ b/server/typescript/packages/migrate-ts/src/index.ts @@ -14,9 +14,13 @@ export { describeReferentialActionConflict, type ReferentialActionConflict, } from "./referential-actions.js"; -export type { ExpectedSchemaWithProvenance, SchemaProvenance } from "./expected-schema.js"; +export type { ExpectedSchemaWithProvenance, SchemaProvenance, SchemaBuildSettings } from "./expected-schema.js"; export { introspect, introspectPostgres, introspectSqlite } from "./introspect/index.js"; export { diff } from "./diff/index.js"; +export { + defaultConventions, efcoreConventions, resolveSchemaConventions, SCHEMA_CONVENTION_PRESETS, + type SchemaConventions, type KeyContext, type ForeignKeyContext, type TableKeys, +} from "./schema-conventions.js"; export { collectUnmanagedNames } from "./unmanaged.js"; // Per-command scope (`migrate.scope`) — see scope.ts for why the suppression is // two-sided and why the pattern engine stays in @metaobjectsdev/sdk. @@ -57,8 +61,16 @@ export { DuplicateSqlNameError, allowOptionFor, isBlockedChangesError, isPrimaryKeyChangeError, isDeclaredRenameError, isDuplicateSqlNameError, + InvalidCheckNameError, + isInvalidCheckNameError, + isFkTargetNotUniqueError, + isUnknownSchemaConventionsError, + UnknownSchemaConventionsError, + InvalidSchemaConventionsError, + FkTargetNotUniqueError, + type FkTargetViolation, } from "./errors.js"; -export type { SqlNameCollision } from "./errors.js"; +export type { SqlNameCollision, CheckNameProblem } from "./errors.js"; // Drop+add pairs in one table that may be a rename — the CLI's hint text reads it so the // declared rename is offered ahead of the data-losing `--allow drop-column`. @@ -76,8 +88,9 @@ export type { ViewDescriptor, ViewChangeReason, FkAction, NameChange, Change, ChangeKind, ChangeStatus, AllowOptions, AmbiguousChange, AmbiguousResolution, AmbiguousCallback, - DiffResult, DataHazard, DeclaredRename, EmitResult, Dialect, ExprCanonPath, + DiffResult, DataHazard, DeclaredRename, EmitResult, Dialect, ExprCanonPath, SchemaAdvisory, } from "./types.js"; +export { DIALECT_POSTGRES, DIALECT_SQLITE, DIALECT_D1, DIALECTS, isSqliteFamily } from "./types.js"; export type { DiffArgs } from "./diff/index.js"; export type { EmitOptions } from "./emit/index.js"; export type { WriteMigrationOptions, WriteMigrationResult } from "./write-migration.js"; diff --git a/server/typescript/packages/migrate-ts/src/introspect/postgres.ts b/server/typescript/packages/migrate-ts/src/introspect/postgres.ts index 16008bfd43..dcc050c280 100644 --- a/server/typescript/packages/migrate-ts/src/introspect/postgres.ts +++ b/server/typescript/packages/migrate-ts/src/introspect/postgres.ts @@ -50,8 +50,9 @@ export async function introspectPostgres(db: Kysely>): P for (const { schema, name } of tableRefs) { const columns = await readColumns(k, schema, name); - const primaryKey = await readPrimaryKey(k, schema, name); + const { columns: primaryKey, name: primaryKeyName } = await readPrimaryKey(k, schema, name); const description = await readPgCatalogColumnFacts(k, schema, name, columns); + await readPgColumnSequences(k, schema, name, columns); tables.push({ name, schema, @@ -60,6 +61,9 @@ export async function introspectPostgres(db: Kysely>): P foreignKeys: await readPgForeignKeys(k, schema, name), checks: await readPgChecks(k, schema, name), primaryKey, + // Only a name other than Postgres's default `
_pkey`, as the expected side + // records it — a database built without schema conventions reads back unchanged. + ...(primaryKeyName !== undefined && primaryKeyName !== `${name}_pkey` ? { primaryKeyName } : {}), ...(description !== undefined ? { description } : {}), }); } @@ -184,6 +188,7 @@ export function pgTypeToSqlType( // INET (ADR-0036/0037 Wave 3 — field.inet) if (dt === "inet") return { kind: "inet" }; + if (dt === "interval") return { kind: "interval" }; // Unknown types (user-defined enums, citext, ltree, etc.) fall back to text // so we don't blow up on unrecognised types. @@ -529,16 +534,66 @@ async function readPgCatalogColumnFacts( } } +/** + * The sequence behind each auto-increment column — a GENERATED identity's or a legacy + * serial's — found through `pg_depend` (the column OWNS it), with its name and start value + * from `pg_sequence`. Sets `liveSequence` on `columns` in place (never `sequence`, which is + * the model's declaration). Compared only against what the model declares. + */ +async function readPgColumnSequences( + k: RawKysely, schema: string, tableName: string, columns: ColumnDescriptor[], +): Promise { + // pg-mem gap: pg_depend / pg_sequence do not exist there — the query throws and the + // columns carry no sequence, so nothing about one is compared. Only that error is + // swallowed: on a real server any other failure (a permission error) must surface, or + // the diff would fall back to the default sequence name and could rename a sequence that + // does not exist. + try { + const rows = await sql<{ column_name: string; sequence_schema: string; sequence_name: string; seqstart: string | number }>` + SELECT a.attname AS column_name, sn.nspname AS sequence_schema, seq.relname AS sequence_name, ps.seqstart + FROM pg_class c + JOIN pg_namespace n ON n.oid = c.relnamespace + JOIN pg_attribute a ON a.attrelid = c.oid AND a.attnum > 0 AND NOT a.attisdropped + JOIN pg_depend d ON d.refobjid = c.oid AND d.refobjsubid = a.attnum + AND d.classid = 'pg_class'::regclass AND d.refclassid = 'pg_class'::regclass + AND d.deptype IN ('a', 'i') + JOIN pg_class seq ON seq.oid = d.objid AND seq.relkind = 'S' + JOIN pg_namespace sn ON sn.oid = seq.relnamespace + JOIN pg_sequence ps ON ps.seqrelid = seq.oid + WHERE n.nspname = ${schema} + AND c.relname = ${tableName} + `.execute(k); + const byName = new Map(columns.map((c) => [c.name, c] as const)); + for (const r of rows.rows) { + const col = byName.get(r.column_name); + if (col === undefined) continue; + col.liveSequence = { + ...(r.sequence_schema !== schema ? { schema: r.sequence_schema } : {}), + name: r.sequence_name, + start: Number(r.seqstart), + }; + } + } catch (err) { + if (!isMissingCatalogRelation(err, ["pg_depend", "pg_sequence"])) throw err; + } +} + +/** pg-mem's error for a catalog relation it does not implement. */ +function isMissingCatalogRelation(err: unknown, relations: readonly string[]): boolean { + const message = err instanceof Error ? err.message : ""; + return relations.some((r) => message.includes(`relation "${r}" does not exist`)); +} + // Typed alias for raw Kysely — avoids per-call `as any` casts in the helpers below. // eslint-disable-next-line @typescript-eslint/no-explicit-any type RawKysely = Kysely; // eslint-disable-next-line @typescript-eslint/no-explicit-any -async function readPrimaryKey(k: Kysely, schema: string, tableName: string): Promise { +async function readPrimaryKey(k: Kysely, schema: string, tableName: string): Promise<{ columns: string[]; name: string | undefined }> { // Uses information_schema only — avoids pg_attribute / pg_constraint joins // that are either missing or return empty in pg-mem. - const rows = await sql<{ column_name: string; ordinal_position: number }>` - SELECT kcu.column_name, kcu.ordinal_position + const rows = await sql<{ column_name: string; ordinal_position: number; constraint_name: string }>` + SELECT kcu.column_name, kcu.ordinal_position, tc.constraint_name FROM information_schema.table_constraints tc JOIN information_schema.key_column_usage kcu ON kcu.constraint_name = tc.constraint_name @@ -549,7 +604,7 @@ async function readPrimaryKey(k: Kysely, schema: string, tableName: string) AND tc.table_name = ${tableName} ORDER BY kcu.ordinal_position `.execute(k); - return rows.rows.map((r) => r.column_name); + return { columns: rows.rows.map((r) => r.column_name), name: rows.rows[0]?.constraint_name }; } async function readPgIndexes(k: RawKysely, schema: string, table: string): Promise { @@ -703,7 +758,7 @@ export function indexDefKeyExpr(indexdef: string): string { } async function readPgForeignKeys(k: RawKysely, schema: string, table: string): Promise { - // pg-mem gap: information_schema.referential_constraints is not supported — + // pg-mem gap: pg_constraint (and the information_schema views before it) is not supported — // the query returns empty rows or throws. We catch and return [] so other // tests still pass on pg-mem. Real PG (Postgres 16) handles this correctly. let rows: { rows: Array<{ fk_name: string; column_name: string; ref_table: string; ref_column: string; update_rule: string; delete_rule: string; ordinal: number }> }; @@ -717,27 +772,34 @@ async function readPgForeignKeys(k: RawKysely, schema: string, table: string): P delete_rule: string; ordinal: number; }>` - SELECT tc.constraint_name AS fk_name, - kcu.column_name, - ccu.table_name AS ref_table, - ccu.column_name AS ref_column, - rc.update_rule, - rc.delete_rule, - kcu.ordinal_position AS ordinal - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON kcu.constraint_name = tc.constraint_name - AND kcu.table_schema = tc.table_schema - JOIN information_schema.referential_constraints rc - ON rc.constraint_name = tc.constraint_name - AND rc.constraint_schema = tc.table_schema - JOIN information_schema.constraint_column_usage ccu - ON ccu.constraint_name = tc.constraint_name - AND ccu.table_schema = tc.table_schema - WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_schema = ${schema} - AND tc.table_name = ${table} - ORDER BY tc.constraint_name, kcu.ordinal_position + -- pg_constraint, not information_schema: constraint_column_usage has no column + -- position, so joining it to key_column_usage paired every FK column with every + -- referenced column (a two-column FK read back as four). conkey and confkey are + -- parallel arrays; unnesting them together keeps each pair and the FK's order. + SELECT con.conname AS fk_name, + a.attname AS column_name, + rt.relname AS ref_table, + ra.attname AS ref_column, + CASE con.confupdtype WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'r' THEN 'RESTRICT' + WHEN 'd' THEN 'SET DEFAULT' ELSE 'NO ACTION' END AS update_rule, + CASE con.confdeltype WHEN 'c' THEN 'CASCADE' WHEN 'n' THEN 'SET NULL' WHEN 'r' THEN 'RESTRICT' + WHEN 'd' THEN 'SET DEFAULT' ELSE 'NO ACTION' END AS delete_rule, + key.ord AS ordinal + FROM pg_constraint con + JOIN pg_class t ON t.oid = con.conrelid + JOIN pg_namespace n ON n.oid = t.relnamespace + JOIN pg_class rt ON rt.oid = con.confrelid + CROSS JOIN LATERAL unnest(con.conkey, con.confkey) WITH ORDINALITY AS key(attnum, refattnum, ord) + JOIN pg_attribute a ON a.attrelid = con.conrelid AND a.attnum = key.attnum + JOIN pg_attribute ra ON ra.attrelid = con.confrelid AND ra.attnum = key.refattnum + WHERE con.contype = 'f' + AND n.nspname = ${schema} + AND t.relname = ${table} + -- Same-schema FKs only: FkDescriptor carries no referenced schema and the emitter + -- assumes the holder's, so a cross-schema FK would read as one to a same-named + -- table here. (The information_schema join this replaced excluded them too.) + AND rt.relnamespace = t.relnamespace + ORDER BY con.conname, key.ord `.execute(k); } catch { // pg-mem: referential_constraints not supported — return empty FK list. diff --git a/server/typescript/packages/migrate-ts/src/schema-conventions.ts b/server/typescript/packages/migrate-ts/src/schema-conventions.ts new file mode 100644 index 0000000000..3972191234 --- /dev/null +++ b/server/typescript/packages/migrate-ts/src/schema-conventions.ts @@ -0,0 +1,145 @@ +/** + * Schema conventions: the names and physical choices for database objects that NO CODE + * ADDRESSES — primary-key and foreign-key constraint names, whether a foreign key gets an + * index and what it is called, and whether a unique key is created as an index or a + * constraint. + * + * Names that code does address — tables, views, columns, and any constraint or index a + * program compares against — are not decided here. They resolve in core naming + * (`naming` in `.metaobjects/config.json`) and from metadata overrides, the same in every + * port. A metadata override always beats a convention: `@constraintName` on an + * `identity.reference`, and an index node's own name. + * + * Selected with `migrate.conventions` in `.metaobjects/config.json` (a preset name), or + * supplied as an object by the project (`schemaConventions` in `metaobjects.config.ts`). + * `default` is what migrate has always emitted, so a project that sets nothing sees no + * change. `efcore` is a reference preset: it reproduces one ORM's documented naming (EF + * Core's, for a database first built by EF Core migrations). The mechanism is general; a + * project with another tool's conventions supplies its own object. + * + * Which CHECKs exist is not a convention: that is the project's `migrate.checks` setting, + * passed to the expected-schema build as `skipRedundantChecks`. + */ + +import { InvalidSchemaConventionsError, UnknownSchemaConventionsError } from "./errors.js"; +import { PG_IDENTIFIER_MAX_BYTES, isSqliteFamily, type Dialect, type IndexDescriptor } from "./types.js"; + +/** A key on one table: the table's database name and the key's columns, in key order, and the + * database the schema is built for (undefined is Postgres, as elsewhere in migrate). */ +export interface KeyContext { + table: string; + columns: readonly string[]; + dialect?: Dialect | undefined; +} + +/** A foreign key: its own columns, and the table and columns it references. */ +export interface ForeignKeyContext extends KeyContext { + refTable: string; + refColumns: readonly string[]; +} + +/** A table as the FK-index decision sees it: its keys, its declared indexes, its foreign keys. */ +export interface TableKeys { + table: string; + dialect?: Dialect | undefined; + primaryKey: readonly string[]; + /** The indexes the model declares (unique keys included); the conventions add to these. */ + indexes: readonly { columns: readonly string[]; where?: string; expr?: string }[]; + foreignKeys: readonly ForeignKeyContext[]; +} + +export interface SchemaConventions { + /** Shown in diagnostics; a preset's name, or the project's own. */ + readonly id: string; + primaryKeyName(key: KeyContext): string; + /** Used when the identity.reference declares no `@constraintName`. */ + foreignKeyName(fk: ForeignKeyContext): string; + /** The indexes the conventions add for a table's foreign keys (none, for most conventions). */ + foreignKeyIndexes(table: TableKeys): { name: string; columns: string[] }[]; + /** + * How a unique key (`identity.secondary`) is created. A partial or expression key is always an index. + * `either` creates a plain index and accepts a live key of either kind, so a database whose keys + * are UNIQUE constraints (Drizzle's `unique()`) is left as it is. `index` and `constraint` are a + * preference: a live key of the other kind is converted. + */ + uniqueKeyKind(key: KeyContext & { referencedByForeignKey: boolean }): "index" | "constraint" | "either"; +} + +/** + * EF Core's truncation: a name over the provider's identifier limit keeps one character fewer + * than the limit and ends in `~`. Only Postgres has a limit here; EF Core's SQLite provider + * truncates nothing, so neither does this on SQLite or D1. + */ +function efTruncate(name: string, dialect: Dialect | undefined): string { + if (isSqliteFamily(dialect)) return name; + return name.length <= PG_IDENTIFIER_MAX_BYTES ? name : `${name.slice(0, PG_IDENTIFIER_MAX_BYTES - 1)}~`; +} + +/** True when `columns` are the leading columns of `key`, in order. */ +function startsWith(key: readonly string[], columns: readonly string[]): boolean { + return columns.length > 0 && columns.length <= key.length && columns.every((c, i) => key[i] === c); +} + +export const defaultConventions: SchemaConventions = { + id: "default", + primaryKeyName: ({ table }) => `${table}_pkey`, + foreignKeyName: ({ table, columns }) => `${table}_${columns[0]}_fk`, + foreignKeyIndexes: () => [], + uniqueKeyKind: () => "either", +}; + +export const efcoreConventions: SchemaConventions = { + id: "efcore", + primaryKeyName: ({ table, dialect }) => efTruncate(`PK_${table}`, dialect), + foreignKeyName: ({ table, columns, refTable, dialect }) => efTruncate(`FK_${table}_${refTable}_${columns.join("_")}`, dialect), + // EF Core's ForeignKeyIndexConvention: every foreign key gets an index unless a key or + // another index already starts with its columns. Its AreIndexedBy compares property lists + // only — a filter is a relational annotation the convention never sees, so a partial index + // covers — and an index added later removes a convention index it covers, so the result + // does not depend on the order the foreign keys are declared in. + foreignKeyIndexes: (t) => { + const candidates: string[][] = []; + for (const fk of t.foreignKeys) { + const columns = [...fk.columns]; + if (startsWith(t.primaryKey, columns)) continue; + if (t.indexes.some((ix) => ix.expr === undefined && startsWith(ix.columns, columns))) continue; + if (candidates.some((c) => c.length === columns.length && startsWith(c, columns))) continue; + candidates.push(columns); + } + return candidates + .filter((c) => !candidates.some((o) => o.length > c.length && startsWith(o, c))) + .map((columns) => ({ name: efTruncate(`IX_${t.table}_${columns.join("_")}`, t.dialect), columns })); + }, + // An alternate key (one an FK references) is a UNIQUE constraint; any other unique key is an index. + uniqueKeyKind: ({ referencedByForeignKey }) => (referencedByForeignKey ? "constraint" : "index"), +}; + +const PRESETS: Readonly> = { + [defaultConventions.id]: defaultConventions, + [efcoreConventions.id]: efcoreConventions, +}; + +/** The preset names `migrate.conventions` accepts in `.metaobjects/config.json`. */ +export const SCHEMA_CONVENTION_PRESETS = Object.keys(PRESETS) as readonly string[]; + +/** A preset by name, a project-supplied object as itself, and nothing as `default`. */ +export function resolveSchemaConventions(selected: string | SchemaConventions | undefined): SchemaConventions { + if (selected === undefined) return defaultConventions; + if (typeof selected !== "string") { + const members = ["primaryKeyName", "foreignKeyName", "foreignKeyIndexes", "uniqueKeyKind"] as const; + const missing = members.filter((m) => typeof (selected as unknown as Record)[m] !== "function"); + const id = String((selected as { id?: unknown }).id ?? "(no id)"); + if (missing.length > 0) throw new InvalidSchemaConventionsError(id, missing); + return selected; + } + const preset = PRESETS[selected]; + if (preset === undefined) throw new UnknownSchemaConventionsError(selected, SCHEMA_CONVENTION_PRESETS); + return preset; +} + +/** A unique key a `UNIQUE` constraint can express: plain ascending btree columns, no predicate. */ +export function canBeUniqueConstraint(ix: IndexDescriptor): boolean { + return ix.unique && ix.where === undefined && ix.expr === undefined + && (ix.using === undefined || ix.using === "btree") + && (ix.orders === undefined || ix.orders.every((o) => o === "asc")); +} diff --git a/server/typescript/packages/migrate-ts/src/scope.ts b/server/typescript/packages/migrate-ts/src/scope.ts index b6f4dbb786..6b5f716453 100644 --- a/server/typescript/packages/migrate-ts/src/scope.ts +++ b/server/typescript/packages/migrate-ts/src/scope.ts @@ -52,6 +52,7 @@ import { DEFAULT_DB_SCHEMA_POSTGRES } from "@metaobjectsdev/metadata"; import type { DiffArgs } from "./diff/index.js"; import type { ExpectedSchemaWithProvenance } from "./expected-schema.js"; +import { FkTargetNotUniqueError, type FkTargetViolation } from "./errors.js"; import { qualifiedDbName } from "./qualified-name.js"; import type { SchemaSnapshot } from "./types.js"; @@ -96,6 +97,17 @@ export interface ScopedExpectedSchema { * declares no dependencies. */ importedOutOfScope?: string[]; + /** + * FKs whose target is not a unique key, on tables this run does NOT govern (imported, or + * outside `migrate.scope`): never emitted by this project, so reported rather than + * refused. The ones on governed tables are refused by `scopeExpectedSchema` itself. + */ + ungovernedFkTargetViolations?: FkTargetViolation[]; + /** + * SQLite only: FKs whose target is not a unique key, on any table. SQLite accepts the DDL + * and checks foreign keys only with `PRAGMA foreign_keys` on, so they are reported, not refused. + */ + unenforcedFkTargetViolations?: FkTargetViolation[]; } /** @@ -277,7 +289,7 @@ export function scopeExpectedSchema( ): ScopedExpectedSchema { const imported = opts?.imported; if (inScope === undefined && imported === undefined) { - return { snapshot: built.snapshot, outOfScope: [] }; + return withFkTargetVerdict(built, { snapshot: built.snapshot, outOfScope: [] }); } // PASS 1 — the import partition. An imported object survives only when the @@ -321,7 +333,7 @@ export function scopeExpectedSchema( return false; }; - return { + return withFkTargetVerdict(built, { snapshot: { ...remainder, tables: remainder.tables.filter(governed), @@ -332,5 +344,19 @@ export function scopeExpectedSchema( outOfScope: [...importedOutOfScope, ...scopeOutOfScope], ...(declared.length > 0 ? { declaredSchemas: declared } : {}), ...(imported !== undefined ? { importedOutOfScope } : {}), - }; + }); +} + +/** + * Refuse every FK-target violation on a table the scoped run governs (its FK would be + * emitted and fail at apply), and return the others for the caller to report. + */ +function withFkTargetVerdict(built: ExpectedSchemaWithProvenance, scoped: ScopedExpectedSchema): ScopedExpectedSchema { + const violations = built.fkTargetViolations ?? []; + if (violations.length === 0) return scoped; + if (built.fkTargetsUnenforced !== undefined) return { ...scoped, unenforcedFkTargetViolations: violations }; + const governedTables = new Set(scoped.snapshot.tables.map(qualifiedDbName)); + const governed = violations.filter((v) => governedTables.has(v.table)); + if (governed.length > 0) throw new FkTargetNotUniqueError(governed); + return { ...scoped, ungovernedFkTargetViolations: violations }; } diff --git a/server/typescript/packages/migrate-ts/src/shadow-expected.ts b/server/typescript/packages/migrate-ts/src/shadow-expected.ts index b08ad3f746..2b0bb0a3cd 100644 --- a/server/typescript/packages/migrate-ts/src/shadow-expected.ts +++ b/server/typescript/packages/migrate-ts/src/shadow-expected.ts @@ -21,10 +21,30 @@ export type ShadowCatalog = { catalog: SchemaSnapshot } | { unavailable: string /** Applies `expected` to a scratch database and reads it back (see shadow-canon.ts). */ export type ShadowSchemaSource = (expected: SchemaSnapshot) => Promise; -/** `expected` re-read from `shadow`: each model table replaced by the shadow's table of the same name. */ +/** + * `expected` re-read from `shadow`: each model table replaced by the shadow's table of the same + * name. A column's DECLARED sequence (`sequence`) is the model's, carried across, and the + * shadow's own read of it (`liveSequence`) is dropped: the shadow database names every + * sequence, declared or not, and only what the model declares may be compared or emitted. + */ export function expectedFromShadow(shadow: SchemaSnapshot, expected: SchemaSnapshot): SchemaSnapshot { const byId = new Map(shadow.tables.map((t) => [qualifiedDbName(t), t] as const)); - return { ...expected, tables: expected.tables.map((model) => byId.get(qualifiedDbName(model)) ?? model) }; + return { + ...expected, + tables: expected.tables.map((model) => { + const read = byId.get(qualifiedDbName(model)); + if (read === undefined) return model; + const declared = new Map(model.columns.map((c) => [c.name, c.sequence] as const)); + return { + ...read, + columns: read.columns.map((c) => { + const { liveSequence: _live, sequence: _read, ...rest } = c; + const seq = declared.get(c.name); + return seq === undefined ? rest : { ...rest, sequence: seq }; + }), + }; + }), + }; } /** diff --git a/server/typescript/packages/migrate-ts/src/snapshot/plan.ts b/server/typescript/packages/migrate-ts/src/snapshot/plan.ts index c1c0b7ca22..a96e613189 100644 --- a/server/typescript/packages/migrate-ts/src/snapshot/plan.ts +++ b/server/typescript/packages/migrate-ts/src/snapshot/plan.ts @@ -5,9 +5,10 @@ import { diff, type DiffArgs } from "../diff/index.js"; import { collectUnmanagedNames } from "../unmanaged.js"; import { carryForwardOutOfScope, scopeExpectedSchema, scopedDiffInputs, type ObjectScopePredicate } from "../scope.js"; import type { Dialect, DiffResult, SchemaSnapshot } from "../types.js"; -import type { ExpectedViewInput } from "../expected-schema.js"; +import type { ExpectedViewInput, SchemaBuildSettings } from "../expected-schema.js"; -export interface PlanOfflineArgs extends Pick { +/** `conventions` and `skipRedundantChecks`: the project's schema settings, passed to the build. */ +export interface PlanOfflineArgs extends Pick, SchemaBuildSettings { metadata: MetaData; dialect: Dialect; /** The stored reference snapshot (the "from" side). Use `{ tables: [], views: [] }` for a fresh project. */ @@ -67,6 +68,8 @@ export async function planOffline(args: PlanOfflineArgs): Promise ({ + ...t, + columns: t.columns.map(({ liveSequence: _live, ...c }) => c), + })), + }; +} + export function serializeSnapshot(snapshot: SchemaSnapshot): string { const file: SnapshotFile = { formatVersion: SNAPSHOT_FORMAT_VERSION, - snapshot: canonicalize(snapshot), + snapshot: canonicalize(withoutLiveSequences(snapshot)), }; return stableStringify(file) + "\n"; } diff --git a/server/typescript/packages/migrate-ts/src/sql-type.ts b/server/typescript/packages/migrate-ts/src/sql-type.ts index 63bdfd6e68..82ec80c76f 100644 --- a/server/typescript/packages/migrate-ts/src/sql-type.ts +++ b/server/typescript/packages/migrate-ts/src/sql-type.ts @@ -19,6 +19,7 @@ export type SqlType = | { kind: "blob" } | { kind: "uuid" } | { kind: "inet" } // Postgres-native inet (IPv4/IPv6) — field.inet (ADR-0036/0037 Wave 3) + | { kind: "interval" } // Postgres-native interval — field.duration (a BIGINT count of microseconds elsewhere) | { kind: "array"; element: SqlType }; // native SQL array (e.g. uuid[], text[]) /** @@ -64,6 +65,7 @@ export function sqlTypeEquals(a: SqlType, b: SqlType): boolean { case "blob": case "uuid": case "inet": + case "interval": return true; } } @@ -115,6 +117,7 @@ export function isWidening(from: SqlType, to: SqlType): boolean { case "blob": case "uuid": case "inet": + case "interval": case "timestamp": case "array": // Array element-type changes are not auto-widened — require explicit allow. diff --git a/server/typescript/packages/migrate-ts/src/types.ts b/server/typescript/packages/migrate-ts/src/types.ts index a7527d5ce8..15a6bdeb0d 100644 --- a/server/typescript/packages/migrate-ts/src/types.ts +++ b/server/typescript/packages/migrate-ts/src/types.ts @@ -39,6 +39,13 @@ export interface TableDescriptor { foreignKeys: FkDescriptor[]; checks: CheckDescriptor[]; primaryKey: string[]; // column names; [] if none + /** + * The primary-key constraint's name, when the project's schema conventions name it + * something other than `
_pkey` (absent = `
_pkey`, which keeps every + * existing snapshot byte-identical). Rendered at create; on Postgres the diff compares it + * and renames a live key whose name differs (`rename-constraints`). + */ + primaryKeyName?: string; /** * Human-readable description threaded from entity `@description`. * Postgres: emitted as `COMMENT ON TABLE … IS '…';` after CREATE TABLE. @@ -61,6 +68,20 @@ export interface ColumnDescriptor { udtName?: string; default?: ColumnDefault; identity?: "increment" | "uuid"; + /** + * The sequence an `increment` identity draws from, as the MODEL declares it: only what the + * identity.primary declares (`@sequenceStart` → `start`, `@sequenceName` → `name`). The only + * sequence fact the emitter renders and a snapshot records, and the only one compared, so an + * undeclared start or name never produces a change or a different byte of SQL. + */ + sequence?: { name?: string; start?: number }; + /** + * Postgres introspection only: the sequence the live column actually owns (`pg_depend`), its + * schema, name and start (`pg_sequence`). Read by the diff to compare against `sequence` and + * to address an `alter-sequence`; never emitted, never written to a snapshot, so it changes + * nothing for a model that declares no sequence. + */ + liveSequence?: { schema?: string; name: string; start: number }; /** * Human-readable description threaded from field `@description`. * Postgres: emitted as `COMMENT ON COLUMN … IS '…';` after CREATE TABLE or ADD COLUMN. @@ -112,16 +133,23 @@ export interface IndexDescriptor { * refuses `DROP INDEX` on such an index ("cannot drop index X because constraint * X on table Y requires it"), so the emitter must drop the CONSTRAINT instead. * - * Never authored in metadata and never present on the expected side — it describes - * how the database happens to hold an index, not what the model asks for. It is - * therefore deliberately NOT compared by `indexEquals`: a unique index and a unique - * constraint over the same columns are the same model-level thing. + * Never authored in metadata. On the expected side it is set only by the project's + * schema conventions (`uniqueKeyKind`), so the emitter creates the key as a UNIQUE + * constraint (EF Core's alternate keys are constraints). It is deliberately NOT + * compared by `indexEquals`: a unique index and a unique constraint over the same + * columns are the same model-level thing, so an existing database never churns. * * This matters far beyond an edge case: Drizzle's `unique()` produces constraints, * so every adopter migrating from a Drizzle-managed schema has constraint-backed * unique indexes (#285). */ constraint?: "unique" | "primary" | "exclude"; + /** + * EXPECTED-ONLY. Set when the schema conventions want this unique key as a plain index + * (`uniqueKeyKind` answers `index`), so a live UNIQUE constraint over it is converted. Absent, + * a live key of either kind satisfies the model. + */ + plainIndex?: true; } export interface CheckDescriptor { @@ -251,6 +279,25 @@ export type Change = indexRenames?: NameChange[]; status: ChangeStatus; } + | { + kind: "rename-constraints"; table: string; schema?: string; + /** + * Constraints (FK / CHECK / constraint-backed index) on a table that is NOT renamed, + * whose definition is unchanged and whose name differs — e.g. after the project's + * schema conventions changed. Renamed in place, never dropped and re-created. + * Postgres only; sqlite/d1 cannot rename a constraint and keep the drop+add. + */ + constraintRenames?: NameChange[]; + /** Plain indexes on that table whose definition is unchanged and whose name differs. Postgres only. */ + indexRenames?: NameChange[]; + /** + * Plain unique indexes the schema conventions now want as UNIQUE constraints, with the + * same name and definition. Promoted in place (`ADD CONSTRAINT … UNIQUE USING INDEX`), so + * a foreign key that relies on the index keeps it. Postgres only. + */ + uniquePromotions?: IndexDescriptor[]; + status: ChangeStatus; + } | { kind: "add-column"; table: string; schema?: string; column: ColumnDescriptor; status: ChangeStatus } | { kind: "drop-column"; table: string; schema?: string; column: string; restore?: ColumnDescriptor; status: ChangeStatus; @@ -290,7 +337,17 @@ export type Change = from?: ColumnDefault; to?: ColumnDefault; status: ChangeStatus } /** Postgres, shadow path only: an `increment` identity (`GENERATED BY DEFAULT AS IDENTITY`) added or dropped. */ | { kind: "change-column-identity"; table: string; schema?: string; column: string; - from?: "increment"; to?: "increment"; status: ChangeStatus } + from?: "increment"; to?: "increment"; + /** The model's declared sequence for an identity being ADDED (rendered as its options). */ + sequence?: { name?: string; start?: number }; + status: ChangeStatus } + /** + * Postgres: an increment identity's sequence whose declared start or name differs from the + * live one, altered in place (`ALTER SEQUENCE … START WITH` / `RENAME TO`). `sequence` is the + * live name the change addresses; each pair is set only when it differs. + */ + | { kind: "alter-sequence"; table: string; schema?: string; column: string; sequence: string; + sequenceSchema?: string; fromStart?: number; toStart?: number; toName?: string; status: ChangeStatus } /** * Postgres, shadow path only: a table comment (no `column`) or column comment set, changed or * cleared. Clearing one the model does not declare is gated behind `allow.dropComment`. @@ -569,8 +626,24 @@ export interface DiffResult { exprCanonPath?: ExprCanonPath; /** Postgres: why no scratch (shadow) database was used, when one was asked for and could not be. */ shadowUnavailable?: string; + /** Things that are not drift but deserve a word: see {@link SchemaAdvisory}. Empty when none. */ + advisories: SchemaAdvisory[]; } +/** + * Not drift, but a better model exists. + * - `interval-as-string`: a live Postgres `interval` column the model declares as something + * other than a `field.duration` (a `field.string`, the only way to model one before + * `field.duration` existed). It is compared as text, so it does not drift; modelling it as + * `field.duration` gives the native type and span ordering. + * - `sequence-not-applied`: a column created on SQLite or D1 whose primary key declares + * `@sequenceStart` or `@sequenceName`. SQLite has no sequence, so neither is applied; the + * key counts from 1. Reported when the column is created, not on every run. + */ +export type SchemaAdvisory = + | { kind: "interval-as-string"; table: string; schema?: string; column: string } + | { kind: "sequence-not-applied"; table: string; schema?: string; column: string }; + /** * Where Postgres's own spelling of an expected expression came from. * - `shadow`: the expected schema applied to a scratch database (shadow-canon.ts). @@ -628,4 +701,16 @@ export interface EmitResult { notices?: readonly string[]; } -export type Dialect = "postgres" | "sqlite" | "d1"; +export const DIALECT_POSTGRES = "postgres"; +export const DIALECT_SQLITE = "sqlite"; +export const DIALECT_D1 = "d1"; +export const DIALECTS = [DIALECT_POSTGRES, DIALECT_SQLITE, DIALECT_D1] as const; +export type Dialect = (typeof DIALECTS)[number]; + +/** SQLite and D1 (which is SQLite) share one SQL dialect. */ +export function isSqliteFamily(dialect: Dialect | undefined): boolean { + return dialect === DIALECT_SQLITE || dialect === DIALECT_D1; +} + +/** Postgres's identifier limit, in bytes (NAMEDATALEN - 1). The server truncates a longer name. */ +export const PG_IDENTIFIER_MAX_BYTES = 63; diff --git a/server/typescript/packages/migrate-ts/test/expected-schema-field-object-storage.test.ts b/server/typescript/packages/migrate-ts/test/expected-schema-field-object-storage.test.ts index 06d16e9746..9324b15aca 100644 --- a/server/typescript/packages/migrate-ts/test/expected-schema-field-object-storage.test.ts +++ b/server/typescript/packages/migrate-ts/test/expected-schema-field-object-storage.test.ts @@ -39,6 +39,31 @@ describe("buildExpectedSchema — field.object @storage", () => { expect(cols).not.toContain("shipping_address"); }); + test("@flattenedColumnPrefix replaces the default prefix, verbatim — \"\" keeps the members' own columns", async () => { + const root = await loadDoc({ + "metadata.root": { + package: "test", + children: [ + { "object.value": { name: "Address", children: [ + { "field.string": { name: "street" } }, + { "field.string": { name: "postalCode", "@column": "ZIP" } }, + ]}}, + { "object.entity": { name: "Customer", children: [ + { "source.rdb": { "@table": "customers" } }, + { "field.long": { name: "id" } }, + { "field.object": { name: "home", "@objectRef": "Address", "@storage": "flattened", "@flattenedColumnPrefix": "" } }, + { "field.object": { name: "work", "@objectRef": "Address", "@storage": "flattened", "@flattenedColumnPrefix": "Office" } }, + { "field.object": { name: "billing", "@objectRef": "Address", "@storage": "flattened", "@column": "bill" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + ]}}, + ], + }, + }); + const snap = buildExpectedSchema(root, { dialect: "postgres" }); + const cols = snap.tables.find((t) => t.name === "customers")!.columns.map((c) => c.name); + expect(cols).toEqual(["id", "street", "ZIP", "Officestreet", "OfficeZIP", "bill_street", "bill_ZIP"]); + }); + test("@storage \"flattened\" preserves nested @required per column", async () => { const root = await loadDoc({ "metadata.root": { diff --git a/server/typescript/packages/migrate-ts/test/integration/pg-constraint-backed-index-285.test.ts b/server/typescript/packages/migrate-ts/test/integration/pg-constraint-backed-index-285.test.ts index 09c1af2cdb..d450eea642 100644 --- a/server/typescript/packages/migrate-ts/test/integration/pg-constraint-backed-index-285.test.ts +++ b/server/typescript/packages/migrate-ts/test/integration/pg-constraint-backed-index-285.test.ts @@ -19,7 +19,10 @@ * The gate is a REAL engine, per this package's doctrine — emitted-SQL inspection is * exactly what missed the bug, since the SQL looked perfectly reasonable: * 1. Build the live shape the way Drizzle does (a UNIQUE *constraint*). - * 2. Diff against metadata that names the key differently → drop-index + add-index. + * 2. Diff against metadata whose key is DEFINED differently (here: partial) → drop-index + * + add-index. (A key that differs only in NAME is renamed in place and never dropped; + * one the conventions also want as a plain index is renamed, then demoted. The last + * three tests below gate those.) * 3. APPLY to the real engine. Pre-fix this throws; that is the regression. * 4. Re-introspect and re-diff → MUST be empty (convergence, not just "it ran"). * 5. Prove uniqueness is still enforced afterwards — a dropped constraint that took @@ -33,6 +36,7 @@ import { Pool } from "pg"; import { Kysely, PostgresDialect, sql } from "kysely"; import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; import { buildExpectedSchema } from "../../src/expected-schema.js"; +import { defaultConventions } from "../../src/schema-conventions.js"; import { introspectPostgres } from "../../src/introspect/postgres.js"; import { diff } from "../../src/diff/index.js"; import { emit } from "../../src/emit/index.js"; @@ -40,8 +44,9 @@ import { emit } from "../../src/emit/index.js"; const PG_URL = process.env["MIGRATE_TS_PG_URL"]; // `identity.secondary` names the unique key `byMessageId`, while the live constraint is -// called `work_item_message_id_unique` — the rename that triggers drop+add. -const META = JSON.stringify({ +// called `work_item_message_id_unique`. META_PARTIAL also makes the key partial — a change +// of DEFINITION, which is what drops it; META alone is a pure rename. +const meta = (secondary: Record) => JSON.stringify({ "metadata.root": { package: "acme", children: [ @@ -53,13 +58,15 @@ const META = JSON.stringify({ { "field.long": { name: "id" } }, { "field.string": { name: "messageId", "@column": "message_id", "@required": true } }, { "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } }, - { "identity.secondary": { name: "byMessageId", "@fields": ["messageId"] } }, + { "identity.secondary": { name: "byMessageId", "@fields": ["messageId"], ...secondary } }, ], }, }, ], }, }); +const META = meta({}); +const META_PARTIAL = meta({ "@where": "message_id <> ''" }); async function applyRaw(k: Kysely>, sqlText: string): Promise { for (const stmt of sqlText.split(";").map((s) => s.trim()).filter((s) => s.length > 0)) { @@ -115,7 +122,7 @@ describe("#285 — constraint-backed index drops as a constraint (PG)", () => { expect(bare?.constraint).toBeUndefined(); }); - test("REGRESSION: the rename applies against a real engine, converges, and keeps uniqueness", async () => { + test("REGRESSION: a redefined key is dropped as a constraint, applies, converges, and keeps uniqueness", async () => { await sql.raw("DROP TABLE IF EXISTS work_item CASCADE").execute(kysely); await sql .raw( @@ -127,14 +134,14 @@ describe("#285 — constraint-backed index drops as a constraint (PG)", () => { ) .execute(kysely); - const loaded = await new MetaDataLoader().load([new InMemoryStringSource(META)]); + const loaded = await new MetaDataLoader().load([new InMemoryStringSource(META_PARTIAL)]); expect(loaded.errors).toEqual([]); const expectedSchema = buildExpectedSchema(loaded.root, { dialect: "postgres" }); const actual = await introspectPostgres(kysely); const d = await diff({ expected: expectedSchema, actual, dialect: "postgres", allow: { dropIndex: true } }); - // The rename really does present as drop+add — the precondition for the bug. + // The redefinition really does present as drop+add — the precondition for the bug. expect(d.changes.some((c) => c.kind === "drop-index")).toBe(true); const sqlText = emit(d.changes, { dialect: "postgres" }).up; @@ -163,4 +170,100 @@ describe("#285 — constraint-backed index drops as a constraint (PG)", () => { sql.raw(`INSERT INTO work_item (message_id) VALUES ('m-1')`).execute(kysely), ).rejects.toThrow(/unique|duplicate key/i); }); + + test("a key that differs only in NAME is renamed in place: ALTER INDEX RENAME, converges, still unique", async () => { + await sql.raw("DROP TABLE IF EXISTS work_item CASCADE").execute(kysely); + await sql + .raw( + `CREATE TABLE work_item (id serial PRIMARY KEY, message_id text NOT NULL); + CREATE UNIQUE INDEX work_item_message_id_unique ON work_item (message_id)`, + ) + .execute(kysely); + const loaded = await new MetaDataLoader().load([new InMemoryStringSource(META)]); + expect(loaded.errors).toEqual([]); + const expectedSchema = buildExpectedSchema(loaded.root, { dialect: "postgres" }); + + const d = await diff({ expected: expectedSchema, actual: await introspectPostgres(kysely), dialect: "postgres" }); + // (The fixture's `serial` id against the model's long also yields a column-type change.) + expect(d.changes.filter((c) => c.kind === "rename-constraints")).toHaveLength(1); + expect(d.changes.some((c) => c.kind === "drop-index" || c.kind === "add-index")).toBe(false); + const sqlText = emit(d.changes, { dialect: "postgres" }).up; + expect(sqlText).toContain('RENAME TO "byMessageId"'); + expect(sqlText).not.toMatch(/DROP (INDEX|CONSTRAINT)/); + + await applyRaw(kysely, sqlText); + const reDiff = await diff({ expected: expectedSchema, actual: await introspectPostgres(kysely), dialect: "postgres" }); + expect(reDiff.changes.map((c) => c.kind)).toEqual([]); + await sql.raw(`INSERT INTO work_item (message_id) VALUES ('m-1')`).execute(kysely); + await expect( + sql.raw(`INSERT INTO work_item (message_id) VALUES ('m-1')`).execute(kysely), + ).rejects.toThrow(/unique|duplicate key/i); + }); + + test("a UNIQUE constraint under another name, under the default conventions: renamed and kept a constraint", async () => { + await sql.raw("DROP TABLE IF EXISTS work_item CASCADE").execute(kysely); + await sql + .raw( + `CREATE TABLE work_item ( + id serial PRIMARY KEY, + message_id text NOT NULL, + CONSTRAINT work_item_message_id_unique UNIQUE (message_id) + )`, + ) + .execute(kysely); + const loaded = await new MetaDataLoader().load([new InMemoryStringSource(META)]); + expect(loaded.errors).toEqual([]); + const expectedSchema = buildExpectedSchema(loaded.root, { dialect: "postgres" }); + + // Drizzle's unique() makes constraints; the default conventions accept either kind. + const d = await diff({ expected: expectedSchema, actual: await introspectPostgres(kysely), dialect: "postgres" }); + expect(d.changes.some((c) => c.kind === "drop-index" || c.kind === "add-index")).toBe(false); + const sqlText = emit(d.changes, { dialect: "postgres" }).up; + expect(sqlText).toContain('RENAME CONSTRAINT "work_item_message_id_unique" TO "byMessageId"'); + expect(sqlText).not.toMatch(/DROP (INDEX|CONSTRAINT)/); + + await applyRaw(kysely, sqlText); + const live = await introspectPostgres(kysely); + expect(live.tables.find((t) => t.name === "work_item")!.indexes.find((i) => i.name === "byMessageId")?.constraint).toBe("unique"); + const reDiff = await diff({ expected: expectedSchema, actual: live, dialect: "postgres" }); + expect(reDiff.changes.map((c) => c.kind)).toEqual([]); + }); + + test("a UNIQUE constraint under another name, wanted as a plain index: renamed AND demoted in one migration", async () => { + await sql.raw("DROP TABLE IF EXISTS work_item CASCADE").execute(kysely); + await sql + .raw( + `CREATE TABLE work_item ( + id serial PRIMARY KEY, + message_id text NOT NULL, + CONSTRAINT work_item_message_id_unique UNIQUE (message_id) + )`, + ) + .execute(kysely); + const loaded = await new MetaDataLoader().load([new InMemoryStringSource(META)]); + expect(loaded.errors).toEqual([]); + const expectedSchema = buildExpectedSchema(loaded.root, { + dialect: "postgres", + conventions: { ...defaultConventions, id: "plain-unique-index", uniqueKeyKind: () => "index" }, + }); + + // The demotion drops the constraint, so it is gated like any drop-index. + const gated = await diff({ expected: expectedSchema, actual: await introspectPostgres(kysely), dialect: "postgres" }); + expect(gated.changes.find((c) => c.kind === "drop-index")?.status.state).toBe("blocked"); + + const d = await diff({ expected: expectedSchema, actual: await introspectPostgres(kysely), dialect: "postgres", allow: { dropIndex: true } }); + const sqlText = emit(d.changes, { dialect: "postgres" }).up; + // Renamed first, then dropped as a constraint under its new name and re-created as an index. + expect(sqlText).toContain('RENAME CONSTRAINT "work_item_message_id_unique" TO "byMessageId"'); + expect(sqlText).toMatch(/DROP CONSTRAINT IF EXISTS "byMessageId"/); + expect(sqlText.indexOf("RENAME CONSTRAINT")).toBeLessThan(sqlText.indexOf("DROP CONSTRAINT")); + + await applyRaw(kysely, sqlText); + const reDiff = await diff({ expected: expectedSchema, actual: await introspectPostgres(kysely), dialect: "postgres" }); + expect(reDiff.changes.map((c) => c.kind)).toEqual([]); + await sql.raw(`INSERT INTO work_item (message_id) VALUES ('m-1')`).execute(kysely); + await expect( + sql.raw(`INSERT INTO work_item (message_id) VALUES ('m-1')`).execute(kysely), + ).rejects.toThrow(/unique|duplicate key/i); + }); }); diff --git a/server/typescript/packages/migrate-ts/test/integration/pg-cross-field-checks-roundtrip.test.ts b/server/typescript/packages/migrate-ts/test/integration/pg-cross-field-checks-roundtrip.test.ts new file mode 100644 index 0000000000..12e473fc22 --- /dev/null +++ b/server/typescript/packages/migrate-ts/test/integration/pg-cross-field-checks-roundtrip.test.ts @@ -0,0 +1,103 @@ +/** + * Real Postgres: the CHECKs the cross-field validator extensions emit (exactlyOne, a + * @condition with every node kind, an int-backed enum member in a condition) apply, and a + * second diff proposes nothing. The diff here runs WITHOUT the expression-canonicalisation + * probe, so this is the fallback normaliser a read-only `verify --db` role gets: Postgres + * rewrites a BETWEEN and re-parenthesises every comparison, and a spelling the normaliser + * cannot match would drift on every run. + * + * Gated on MIGRATE_TS_PG_URL; skips cleanly when unset. + */ + +import { test, expect, beforeAll, afterAll, describe } from "bun:test"; +import { Pool } from "pg"; +import { Kysely, PostgresDialect, sql } from "kysely"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import { buildExpectedSchema } from "../../src/expected-schema.js"; +import { introspectPostgres } from "../../src/introspect/postgres.js"; +import { diff } from "../../src/diff/index.js"; +import { emit } from "../../src/emit/index.js"; + +const PG_URL = process.env["MIGRATE_TS_PG_URL"]; +const realDescribe = PG_URL ? describe : describe.skip; +const TABLE = "xfield_checks_rt"; + +const META = JSON.stringify({ "metadata.root": { package: "acme", children: [{ "object.entity": { name: "Note", children: [ + { "source.rdb": { "@table": TABLE } }, + { "field.long": { name: "id" } }, + { "field.long": { name: "a" } }, { "field.long": { name: "b" } }, { "field.long": { name: "c" } }, + { "field.boolean": { name: "flag" } }, + { "field.string": { name: "label" } }, + { "field.enum": { name: "kind", "@values": ["Owned", "Shared"], "@intValueMap": { Owned: 1, Shared: 2 } } }, + { "field.duration": { name: "wait" } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, + { "validator.atLeastOne": { name: "few", "@fields": ["a", "b", "c"], "@constraintName": "CK_Note_Few" } }, + { "validator.exactlyOne": { name: "one", "@fields": ["a", "b"], "@constraintName": "CK_Note_One", + "@condition": { op: "eq", left: { field: "kind" }, right: { value: "Owned" } } } }, + { "validator.comparison": { name: "cmp", "@left": "a", "@op": "lt", "@right": "b", "@constraintName": "CK_Note_Cmp", + "@condition": { op: "and", args: [ + { op: "isNotNull", arg: { field: "label" } }, + { op: "not", arg: { op: "eq", left: { field: "flag" }, right: { value: true } } }, + { op: "eq", left: { op: "isNull", arg: { field: "c" } }, right: { field: "flag" } }, + ] } } }, + { "validator.requiredWhen": { name: "rw", "@field": "c", "@when": "label", "@equals": "x", + "@condition": { op: "gte", left: { fn: "coalesce", args: [{ field: "a" }, { field: "b" }] }, right: { value: 10 } } } }, + // A duration literal is rendered in the interval spelling: Postgres refuses a signed ISO-8601 one. + { "validator.comparison": { name: "waitCmp", "@left": "a", "@op": "lt", "@right": "b", "@constraintName": "CK_Note_Wait", + "@condition": { op: "gt", left: { field: "wait" }, right: { value: "-PT90S" } } } }, + { "validator.requiredWhen": { name: "hourly", "@field": "label", "@when": "wait", "@equals": "PT1H", "@constraintName": "CK_Note_Hourly" } }, +] } }] } }); + +let k: Kysely>; +let pool: Pool; + +if (PG_URL) { + beforeAll(() => { + pool = new Pool({ connectionString: PG_URL }); + k = new Kysely>({ dialect: new PostgresDialect({ pool }) }); + }); + afterAll(async () => { + await sql.raw(`DROP TABLE IF EXISTS "${TABLE}" CASCADE`).execute(k); + await k.destroy(); + }); +} + +const ours = (changes: readonly unknown[]) => + changes.filter((c) => JSON.stringify(c).includes(TABLE)); + +realDescribe("cross-field CHECK extensions — apply, then re-diff with no probe", () => { + test("every emitted CHECK reads back as itself", async () => { + await sql.raw(`DROP TABLE IF EXISTS "${TABLE}" CASCADE`).execute(k); + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(META)]); + expect(errors.map((e) => e.message)).toEqual([]); + const expected = buildExpectedSchema(root, { dialect: "postgres" }); + const first = await diff({ expected, actual: await introspectPostgres(k), dialect: "postgres" }); + const { up } = emit(ours(first.changes) as typeof first.changes, { dialect: "postgres", expectedSchema: expected }); + for (const stmt of up.split(";").map((s) => s.trim()).filter(Boolean)) await sql.raw(stmt).execute(k); + + const followup = ours((await diff({ expected, actual: await introspectPostgres(k), dialect: "postgres" })).changes); + if (followup.length > 0) for (const c of followup) console.error(" -", JSON.stringify(c)); + expect(followup).toEqual([]); + }); + + test("the guarded rule is enforced only when the condition holds", async () => { + await sql.raw(`DELETE FROM "${TABLE}"`).execute(k); + // kind = Shared (2): the exactly-one rule does not apply, so two present is fine. + await sql.raw(`INSERT INTO "${TABLE}" ("a", "b", "kind") VALUES (1, 2, 2)`).execute(k); + // kind = Owned (1): exactly one of a, b. + await expect(sql.raw(`INSERT INTO "${TABLE}" ("a", "b", "kind") VALUES (1, 2, 1)`).execute(k)).rejects.toThrow(/CK_Note_One/); + await sql.raw(`INSERT INTO "${TABLE}" ("a", "kind") VALUES (1, 1)`).execute(k); + // atLeastOne: none of a, b, c present is refused. + await expect(sql.raw(`INSERT INTO "${TABLE}" ("kind") VALUES (2)`).execute(k)).rejects.toThrow(/CK_Note_Few/); + }); + + test("a duration literal in a condition or @equals compares as the span", async () => { + await sql.raw(`DELETE FROM "${TABLE}"`).execute(k); + // wait <= -PT90S: the a < b rule does not apply. + await sql.raw(`INSERT INTO "${TABLE}" ("a", "b", "wait") VALUES (2, 1, '-00:05:00')`).execute(k); + await expect(sql.raw(`INSERT INTO "${TABLE}" ("a", "b", "wait") VALUES (2, 1, '00:00:10')`).execute(k)).rejects.toThrow(/CK_Note_Wait/); + // label is required when wait is exactly an hour, however it is spelled. + await expect(sql.raw(`INSERT INTO "${TABLE}" ("a", "wait") VALUES (1, '60 minutes')`).execute(k)).rejects.toThrow(/CK_Note_Hourly/); + await sql.raw(`INSERT INTO "${TABLE}" ("a", "wait", "label") VALUES (1, '01:00:00', 'set')`).execute(k); + }); +}); diff --git a/server/typescript/packages/migrate-ts/test/integration/pg-duration-roundtrip.test.ts b/server/typescript/packages/migrate-ts/test/integration/pg-duration-roundtrip.test.ts new file mode 100644 index 0000000000..47b832a045 --- /dev/null +++ b/server/typescript/packages/migrate-ts/test/integration/pg-duration-roundtrip.test.ts @@ -0,0 +1,110 @@ +/** + * Real Postgres: a field.duration column is a native INTERVAL. Its table applies, a value in the + * canonical ISO-8601 form is stored as the span it names, and a second diff proposes nothing — + * including for a declared @default, which Postgres stores in its own interval spelling. + * + * Gated on MIGRATE_TS_PG_URL; skips cleanly when unset. + */ + +import { test, expect, beforeAll, afterAll, describe } from "bun:test"; +import { Pool } from "pg"; +import { Kysely, PostgresDialect, sql } from "kysely"; +import { MetaDataLoader, InMemoryStringSource, formatPostgresInterval, parseDurationLiteral } from "@metaobjectsdev/metadata"; +import { buildExpectedSchema } from "../../src/expected-schema.js"; +import { introspectPostgres } from "../../src/introspect/postgres.js"; +import { diff } from "../../src/diff/index.js"; +import { emit } from "../../src/emit/index.js"; + +const PG_URL = process.env["MIGRATE_TS_PG_URL"]; +const realDescribe = PG_URL ? describe : describe.skip; +const TABLE = "duration_rt"; + +const META = JSON.stringify({ "metadata.root": { package: "acme", children: [{ "object.entity": { name: "Timer", children: [ + { "source.rdb": { "@table": TABLE } }, + { "field.long": { name: "id" } }, + { "field.duration": { name: "timeout", "@required": true } }, + { "field.duration": { name: "grace", "@default": "PT1H30M" } }, + { "field.duration": { name: "backoff", isArray: true } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, +] } }] } }); + +let k: Kysely>; + +if (PG_URL) { + beforeAll(() => { + k = new Kysely>({ dialect: new PostgresDialect({ pool: new Pool({ connectionString: PG_URL }) }) }); + }); + afterAll(async () => { + await sql.raw(`DROP TABLE IF EXISTS "${TABLE}" CASCADE`).execute(k); + await k.destroy(); + }); +} + +const ours = (changes: readonly unknown[]) => changes.filter((c) => JSON.stringify(c).includes(TABLE)); + +realDescribe("field.duration on Postgres", () => { + test("the table applies as INTERVAL columns and re-diffs empty", async () => { + await sql.raw(`DROP TABLE IF EXISTS "${TABLE}" CASCADE`).execute(k); + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(META)]); + expect(errors.map((e) => e.message)).toEqual([]); + const expected = buildExpectedSchema(root, { dialect: "postgres" }); + const first = await diff({ expected, actual: await introspectPostgres(k), dialect: "postgres" }); + const { up } = emit(ours(first.changes) as typeof first.changes, { dialect: "postgres", expectedSchema: expected }); + expect(up).toContain('"timeout" INTERVAL NOT NULL'); + for (const stmt of up.split(";").map((s) => s.trim()).filter(Boolean)) await sql.raw(stmt).execute(k); + + const types = (await sql.raw( + `SELECT column_name, data_type FROM information_schema.columns WHERE table_name = '${TABLE}' ORDER BY column_name`, + ).execute(k)).rows as Array<{ column_name: string; data_type: string }>; + expect(Object.fromEntries(types.map((r) => [r.column_name, r.data_type]))).toEqual({ + backoff: "ARRAY", grace: "interval", id: "bigint", timeout: "interval", + }); + + const followup = ours((await diff({ expected, actual: await introspectPostgres(k), dialect: "postgres" })).changes); + if (followup.length > 0) for (const c of followup) console.error(" -", JSON.stringify(c)); + expect(followup).toEqual([]); + }); + + test("a value bound in the Postgres spelling is stored as the span it names; the default applies", async () => { + // Postgres reads a non-negative ISO-8601 duration as interval input but refuses a signed one + // (`-PT1M30S`), which is why a string is bound in its own spelling (formatPostgresInterval). + await expect(sql.raw(`SELECT '-PT1M30S'::interval`).execute(k)).rejects.toThrow(/invalid input syntax/); + const bound = [parseDurationLiteral("PT26H0.5S")!, parseDurationLiteral("PT1S")!, parseDurationLiteral("-PT1M30S")!].map(formatPostgresInterval); + await sql.raw(`INSERT INTO "${TABLE}" ("timeout", "backoff") VALUES ('${bound[0]}', ARRAY['${bound[1]}','${bound[2]}']::interval[])`).execute(k); + const row = (await sql.raw( + `SELECT EXTRACT(EPOCH FROM "timeout")::text AS t, EXTRACT(EPOCH FROM "grace")::text AS g, "backoff"::text AS b FROM "${TABLE}"`, + ).execute(k)).rows[0] as Record; + expect(Number(row["t"])).toBe(26 * 3600 + 0.5); + expect(Number(row["g"])).toBe(5400); + expect(row["b"]).toBe('{00:00:01,-00:01:30}'); + // Read back, each element is exactly the spelling it was bound in. + const back = (await sql.raw(`SELECT "timeout"::text AS t FROM "${TABLE}"`).execute(k)).rows[0] as Record; + expect(back["t"]).toBe(bound[0]); + }); + + test("an existing interval column modelled as a string, as it had to be before field.duration, is not drift", async () => { + const legacy = "duration_legacy_rt"; + await sql.raw(`DROP TABLE IF EXISTS "${legacy}" CASCADE`).execute(k); + await sql.raw(`CREATE TABLE "${legacy}" ("id" bigint PRIMARY KEY, "wait" interval, "waits" interval[])`).execute(k); + try { + const doc = JSON.stringify({ "metadata.root": { package: "acme", children: [{ "object.entity": { name: "Legacy", children: [ + { "source.rdb": { "@table": legacy } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "wait" } }, + { "field.string": { name: "waits", isArray: true } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + ] } }] } }); + const { root } = await new MetaDataLoader().load([new InMemoryStringSource(doc)]); + const expected = buildExpectedSchema(root, { dialect: "postgres" }); + const result = await diff({ expected, actual: await introspectPostgres(k), dialect: "postgres" }); + expect(result.changes.filter((c) => JSON.stringify(c).includes(legacy))).toEqual([]); + // Not drift, but said: each such column is an advisory pointing at field.duration. + expect(result.advisories.filter((a) => a.table === legacy)).toEqual([ + { kind: "interval-as-string", table: legacy, column: "wait" }, + { kind: "interval-as-string", table: legacy, column: "waits" }, + ]); + } finally { + await sql.raw(`DROP TABLE IF EXISTS "${legacy}" CASCADE`).execute(k); + } + }); +}); diff --git a/server/typescript/packages/migrate-ts/test/integration/postgres-composite-fk-introspect.test.ts b/server/typescript/packages/migrate-ts/test/integration/postgres-composite-fk-introspect.test.ts new file mode 100644 index 0000000000..a62dbd27a0 --- /dev/null +++ b/server/typescript/packages/migrate-ts/test/integration/postgres-composite-fk-introspect.test.ts @@ -0,0 +1,87 @@ +/** + * Composite foreign keys introspect as the columns they have, paired in order. + * + * information_schema.constraint_column_usage carries no column position, so joining it + * to key_column_usage pairs every FK column with every referenced column: a two-column + * FK read back as four columns, a three-column one as nine. `meta migrate` reads its + * expected side back from a shadow database, so the cross product reached the emitted + * DDL ("foreign key referenced-columns list must not contain duplicates"). The FK below + * also lists its columns in a different order from the target key, so a reader that + * pairs by position in the target rather than in the FK fails too. + */ +import { describe, test, expect } from "bun:test"; +import { Kysely, PostgresDialect, sql } from "kysely"; +import { Pool } from "pg"; +import { introspectPostgres } from "../../src/introspect/postgres.js"; + +const PG_URL = process.env.MIGRATE_TS_PG_URL; +const d = PG_URL ? describe : describe.skip; + +d("postgres composite FK introspection (real PG)", () => { + test("reads each composite FK column once, paired with its referenced column", async () => { + const k = new Kysely>({ dialect: new PostgresDialect({ pool: new Pool({ connectionString: PG_URL }) }) }); + const p = "cfk_parent_" + Math.random().toString(36).slice(2, 8); + const c = "cfk_child_" + Math.random().toString(36).slice(2, 8); + try { + await sql.raw(`CREATE TABLE ${p} ( id INTEGER PRIMARY KEY, region INTEGER NOT NULL, kind INTEGER NOT NULL )`).execute(k); + await sql.raw(`CREATE UNIQUE INDEX ${p}_ak ON ${p} (id, region, kind)`).execute(k); + await sql.raw( + `CREATE TABLE ${c} ( id INTEGER PRIMARY KEY, p_kind INTEGER, p_id INTEGER, p_region INTEGER, ` + + `CONSTRAINT ${c}_fk FOREIGN KEY (p_kind, p_id, p_region) REFERENCES ${p} (kind, id, region) ON DELETE RESTRICT )`, + ).execute(k); + + const snap = await introspectPostgres(k); + const fk = snap.tables.find((x) => x.name === c)!.foreignKeys.find((f) => f.name === `${c}_fk`)!; + + expect(fk.columns).toEqual(["p_kind", "p_id", "p_region"]); + expect(fk.refColumns).toEqual(["kind", "id", "region"]); + expect(fk.refTable).toBe(p); + expect(fk.onDelete).toBe("restrict"); + } finally { + await sql.raw(`DROP TABLE IF EXISTS ${c}`).execute(k); + await sql.raw(`DROP TABLE IF EXISTS ${p}`).execute(k); + await k.destroy(); + } + }); + + test("reads a primary key's constraint name when it is not
_pkey", async () => { + const k = new Kysely>({ dialect: new PostgresDialect({ pool: new Pool({ connectionString: PG_URL }) }) }); + const named = "pkn_" + Math.random().toString(36).slice(2, 8); + const plain = "pkp_" + Math.random().toString(36).slice(2, 8); + try { + await sql.raw(`CREATE TABLE ${named} ( id INTEGER, CONSTRAINT "PK_${named}" PRIMARY KEY (id) )`).execute(k); + await sql.raw(`CREATE TABLE ${plain} ( id INTEGER PRIMARY KEY )`).execute(k); + + const snap = await introspectPostgres(k); + + expect(snap.tables.find((x) => x.name === named)!.primaryKeyName).toBe(`PK_${named}`); + // the default name is left absent, so a snapshot without conventions is unchanged + expect(snap.tables.find((x) => x.name === plain)!.primaryKeyName).toBeUndefined(); + } finally { + await sql.raw(`DROP TABLE IF EXISTS ${named}`).execute(k); + await sql.raw(`DROP TABLE IF EXISTS ${plain}`).execute(k); + await k.destroy(); + } + }); + + test("an FK into another schema is not read (FkDescriptor has no referenced schema; as before)", async () => { + const k = new Kysely>({ dialect: new PostgresDialect({ pool: new Pool({ connectionString: PG_URL }) }) }); + const a = "xsa_" + Math.random().toString(36).slice(2, 8); + const b = "xsb_" + Math.random().toString(36).slice(2, 8); + try { + await sql.raw(`CREATE SCHEMA ${a}; CREATE SCHEMA ${b}`).execute(k); + await sql.raw(`CREATE TABLE ${b}.users ( id INTEGER PRIMARY KEY )`).execute(k); + await sql.raw(`CREATE TABLE ${a}.users ( id INTEGER PRIMARY KEY )`).execute(k); + await sql.raw(`CREATE TABLE ${a}.profiles ( id INTEGER PRIMARY KEY, user_id INTEGER REFERENCES ${b}.users (id), local_id INTEGER REFERENCES ${a}.users (id) )`).execute(k); + + const snap = await introspectPostgres(k); + const profiles = snap.tables.find((x) => x.name === "profiles" && x.schema === a)!; + + // Only the same-schema FK: the cross-schema one would read as an FK to a.users. + expect(profiles.foreignKeys.map((f) => f.columns)).toEqual([["local_id"]]); + } finally { + await sql.raw(`DROP SCHEMA IF EXISTS ${a} CASCADE; DROP SCHEMA IF EXISTS ${b} CASCADE`).execute(k); + await k.destroy(); + } + }); +}); diff --git a/server/typescript/packages/migrate-ts/test/unit/check-redundant.test.ts b/server/typescript/packages/migrate-ts/test/unit/check-redundant.test.ts new file mode 100644 index 0000000000..2b295be031 --- /dev/null +++ b/server/typescript/packages/migrate-ts/test/unit/check-redundant.test.ts @@ -0,0 +1,92 @@ +/** + * CHECK constraints the schema already guarantees. + * + * - `skipRedundantChecks` on the build (`migrate.checks.skipRedundant` in the project config, + * off by default so nothing changes for an existing project): a `validator.length` bound the + * column's VARCHAR(n) already enforces, and a minimum of 0 (always true), emit nothing; the + * rest of the check stands. + * - It is not a schema convention: the efcore conventions alone emit every CHECK. + * - Not on SQLite / D1, which accept a declared length and store longer values anyway: there + * the length CHECK is the only thing enforcing it. + */ +import { describe, test, expect } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import { buildExpectedSchema } from "../../src/expected-schema.js"; +import { defaultConventions, efcoreConventions } from "../../src/schema-conventions.js"; +import { baselineFromMetadata, planOffline } from "../../src/snapshot/plan.js"; +import { computeDriftFromActual } from "../../src/drift/drift.js"; + +async function root(lengthValidator: Record, maxLength?: number) { + const doc = { "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Ticket", children: [ + { "source.rdb": { "@table": "tickets" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "code", ...(maxLength !== undefined ? { "@maxLength": maxLength } : {}), children: [ + { "validator.length": { name: "len", ...lengthValidator } }, + ] } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, + ] } }, + ] } }; + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(doc))]); + expect(errors).toHaveLength(0); + return root; +} + +const checksOf = (s: ReturnType) => + s.tables.find((t) => t.name === "tickets")!.checks.map((c) => c.expression); + +const skip = { skipRedundantChecks: true } as const; + +describe("redundant CHECKs", () => { + test("by default: unchanged — the length check is emitted whole", async () => { + const r = await root({ "@min": 0, "@max": 3 }, 3); + expect(checksOf(buildExpectedSchema(r))).toEqual(['length("code") >= 0 AND length("code") <= 3']); + expect(checksOf(buildExpectedSchema(r, { skipRedundantChecks: false }))).toEqual(['length("code") >= 0 AND length("code") <= 3']); + }); + + test("the efcore conventions alone skip nothing", async () => { + const r = await root({ "@min": 0, "@max": 3 }, 3); + expect(checksOf(buildExpectedSchema(r, { conventions: efcoreConventions }))).toEqual(['length("code") >= 0 AND length("code") <= 3']); + expect(checksOf(buildExpectedSchema(r, { conventions: defaultConventions, ...skip }))).toEqual([]); + }); + + test("a max the VARCHAR(n) enforces and a min of 0 emit no check", async () => { + const r = await root({ "@min": 0, "@max": 3 }, 3); + expect(checksOf(buildExpectedSchema(r, skip))).toEqual([]); + }); + + test("a max looser than the VARCHAR(n) is redundant too; a real min stays", async () => { + const r = await root({ "@min": 2, "@max": 10 }, 3); + expect(checksOf(buildExpectedSchema(r, skip))).toEqual(['length("code") >= 2']); + }); + + test("a max tighter than the column, or on an unbounded column, stays", async () => { + expect(checksOf(buildExpectedSchema(await root({ "@max": 2 }, 3), skip))).toEqual(['length("code") <= 2']); + expect(checksOf(buildExpectedSchema(await root({ "@max": 2 }), skip))).toEqual(['length("code") <= 2']); + }); + + test("on SQLite the length check stays, since SQLite does not enforce VARCHAR(n)", async () => { + const r = await root({ "@min": 0, "@max": 3 }, 3); + expect(checksOf(buildExpectedSchema(r, { ...skip, dialect: "sqlite" }))).toEqual(['length("code") <= 3']); + }); +}); + +describe("skipRedundantChecks reaches every build", () => { + test("the offline plan, the baseline and the drift computation pass it to the build", async () => { + const r = await root({ "@min": 0, "@max": 3 }, 3); + const empty = { tables: [], views: [] }; + const tableChecks = (s: { tables: { name: string; checks: unknown[] }[] }) => s.tables.find((t) => t.name === "tickets")!.checks; + + expect(tableChecks((await planOffline({ metadata: r, dialect: "postgres", snapshot: empty, ...skip })).expected)).toEqual([]); + expect(tableChecks((await planOffline({ metadata: r, dialect: "postgres", snapshot: empty })).expected)).toHaveLength(1); + expect(tableChecks(baselineFromMetadata(r, "postgres", undefined, skip))).toEqual([]); + expect(tableChecks(baselineFromMetadata(r, "postgres"))).toHaveLength(1); + + // The database has the CHECK; skipping it makes it extra on the live side, not missing. + const live = buildExpectedSchema(r, { dialect: "postgres" }); + const kinds = async (opts: { skipRedundantChecks?: boolean }) => + (await computeDriftFromActual(live, "postgres", r, opts)).changes.map((c) => c.kind); + expect(await kinds({})).toEqual([]); + expect(await kinds(skip)).toEqual(["drop-check"]); + }); +}); diff --git a/server/typescript/packages/migrate-ts/test/unit/cross-field-check-extensions.test.ts b/server/typescript/packages/migrate-ts/test/unit/cross-field-check-extensions.test.ts new file mode 100644 index 0000000000..da0149d593 --- /dev/null +++ b/server/typescript/packages/migrate-ts/test/unit/cross-field-check-extensions.test.ts @@ -0,0 +1,249 @@ +/** + * CHECKs a database names by hand, an exactly-one presence rule, and a rule that applies in + * one case only: + * - `@constraintName` on a validator or a field.enum is the CHECK's database name; + * - `validator.exactlyOne` is exactly one of its fields present; + * - `@condition` on a cross-field validator: the CHECK is the negated condition OR the rule, + * the negation pushed into the comparison so it reads as written by hand. + */ +import { describe, test, expect } from "bun:test"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import { buildExpectedSchema } from "../../src/expected-schema.js"; +import { efcoreConventions } from "../../src/schema-conventions.js"; +import { isInvalidCheckNameError } from "../../src/errors.js"; + +const FIXTURE = join(import.meta.dir, "../../../../../../fixtures/conformance/validator-constraint-name-condition-exactly-one/input/meta.checks.json"); + +async function checks(dialect: "postgres" | "sqlite" = "postgres") { + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(readFileSync(FIXTURE, "utf8"))]); + expect(errors).toHaveLength(0); + const t = buildExpectedSchema(root, { dialect }).tables.find((x) => x.name === "StoreHours")!; + return Object.fromEntries(t.checks.map((c) => [c.name, c.expression])); +} + +describe("cross-field CHECK extensions", () => { + test("@constraintName names the enum, field-validator (length, regex, numeric) and cross-field CHECKs", async () => { + expect(Object.keys(await checks()).sort()).toEqual([ + "CK_StoreHours_AnyTime", + "CK_StoreHours_Code", + "CK_StoreHours_CodeShape", + "CK_StoreHours_ExactlyOneLocation", + "CK_StoreHours_Kind", + "CK_StoreHours_SameDayTimes", + "CK_StoreHours_StartDay", + ]); + }); + + test("exactlyOne is exactly one present (Postgres num_nonnulls), under its @condition", async () => { + expect((await checks())["CK_StoreHours_ExactlyOneLocation"]) + .toBe(`("kind" <> 'InStore') OR (num_nonnulls("store_id", "kiosk_id") = 1)`); + }); + + test("exactlyOne on SQLite counts the present fields, under its @condition", async () => { + expect((await checks("sqlite"))["CK_StoreHours_ExactlyOneLocation"]) + .toBe(`("kind" <> 'InStore') OR ((("store_id" IS NOT NULL) + ("kiosk_id" IS NOT NULL)) = 1)`); + }); + + test("atLeastOne takes @constraintName like the other cross-field validators", async () => { + expect((await checks())["CK_StoreHours_AnyTime"]).toBe('"start_time" IS NOT NULL OR "end_time" IS NOT NULL'); + }); + + test("@condition: the negated condition, then the rule", async () => { + expect((await checks())["CK_StoreHours_SameDayTimes"]).toBe('("start_day" <> "end_day") OR ("end_time" > "start_time")'); + }); + + test("exactlyOne with no @constraintName:
__exactlyone_chk", async () => { + const doc = { "metadata.root": { package: "acme", children: [{ "object.entity": { name: "T", children: [ + { "source.rdb": { "@table": "t" } }, { "field.long": { name: "id" } }, + { "field.long": { name: "a" } }, { "field.long": { name: "b" } }, { "field.long": { name: "c" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + { "validator.exactlyOne": { name: "one", "@fields": ["a", "b", "c"] } }, + ] } }] } }; + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(doc))]); + expect(errors).toHaveLength(0); + const t = buildExpectedSchema(root, { dialect: "postgres" }).tables[0]!; + expect(t.checks).toEqual([{ name: "t_a_exactlyone_chk", expression: 'num_nonnulls("a", "b", "c") = 1' }]); + }); + + test("exactlyOne over one field is refused at load: that is a required field", async () => { + const doc = { "metadata.root": { package: "acme", children: [{ "object.entity": { name: "T", children: [ + { "source.rdb": { "@table": "t" } }, { "field.long": { name: "id" } }, { "field.long": { name: "a" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + { "validator.exactlyOne": { name: "one", "@fields": ["a"] } }, + ] } }] } }; + const { errors } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(doc))]); + expect(errors.map((e) => (e as { code?: string }).code)).toEqual(["ERR_BAD_ATTR_VALUE"]); + expect(errors[0]!.message).toContain("make that field required"); + }); + + test("no @condition, no @constraintName: unchanged", async () => { + const doc = { "metadata.root": { package: "acme", children: [{ "object.entity": { name: "T", children: [ + { "source.rdb": { "@table": "t" } }, { "field.long": { name: "id" } }, + { "field.long": { name: "a" } }, { "field.long": { name: "b" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + { "validator.atLeastOne": { name: "one", "@fields": ["a", "b"] } }, + ] } }] } }; + const { root } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(doc))]); + const t = buildExpectedSchema(root, { dialect: "postgres" }).tables[0]!; + expect(t.checks).toEqual([{ name: "t_a_atleastone_chk", expression: '"a" IS NOT NULL OR "b" IS NOT NULL' }]); + }); + +}); + +/** An entity "T" (table "t") with an id key and the given children, loaded with no errors. */ +async function load(children: unknown[]) { + const doc = { "metadata.root": { package: "acme", children: [{ "object.entity": { name: "T", children: [ + { "source.rdb": { "@table": "t" } }, { "field.long": { name: "id" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + ...children, + ] } }] } }; + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(doc))]); + expect(errors.map((e) => e.message)).toEqual([]); + return root; +} + +async function checksOf(children: unknown[], dialect: "postgres" | "sqlite" = "postgres") { + const t = buildExpectedSchema(await load(children), { dialect }).tables[0]!; + return t.checks.map((c) => c.expression); +} + +const AB = [{ "field.long": { name: "a" } }, { "field.long": { name: "b" } }]; + +describe("@condition rendering", () => { + test("and / or / not / isNull / isNotNull negate by De Morgan, down to the comparisons", async () => { + const condition = { op: "and", args: [{ op: "isNull", arg: { field: "a" } }, { op: "not", arg: { op: "eq", left: { field: "b" }, right: { value: 1 } } }] }; + expect(await checksOf([...AB, { "validator.atLeastOne": { name: "one", "@fields": ["a", "b"], "@condition": condition } }])) + .toEqual(['(("a" IS NOT NULL) OR ("b" = 1)) OR ("a" IS NOT NULL OR "b" IS NOT NULL)']); + }); + + test("exactlyOne takes a @condition", async () => { + const condition = { op: "isNotNull", arg: { field: "a" } }; + expect(await checksOf([...AB, { "validator.exactlyOne": { name: "one", "@fields": ["a", "b"], "@condition": condition } }])) + .toEqual(['("a" IS NULL) OR (num_nonnulls("a", "b") = 1)']); + }); + + test("an isNotNull condition negates to isNull; or negates to and", async () => { + const condition = { op: "or", args: [{ op: "isNotNull", arg: { field: "a" } }, { op: "gt", left: { field: "b" }, right: { value: 2 } }] }; + expect(await checksOf([...AB, { "validator.atLeastOne": { name: "one", "@fields": ["a", "b"], "@condition": condition } }])) + .toEqual(['(("a" IS NULL) AND ("b" <= 2)) OR ("a" IS NOT NULL OR "b" IS NOT NULL)']); + }); + + test("coalesce is an operand, not a compound", async () => { + const condition = { op: "eq", left: { fn: "coalesce", args: [{ field: "a" }, { field: "b" }] }, right: { value: 1 } }; + expect(await checksOf([...AB, { "validator.atLeastOne": { name: "one", "@fields": ["a", "b"], "@condition": condition } }])) + .toEqual(['(COALESCE("a", "b") <> 1) OR ("a" IS NOT NULL OR "b" IS NOT NULL)']); + }); + + test("a compound comparison operand is parenthesised (IS binds looser than =)", async () => { + const flag = { "field.boolean": { name: "flag" } }; + const condition = { op: "eq", left: { op: "isNull", arg: { field: "a" } }, right: { field: "flag" } }; + expect(await checksOf([...AB, flag, { "validator.atLeastOne": { name: "one", "@fields": ["a", "b"], "@condition": condition } }])) + .toEqual(['(("a" IS NULL) <> "flag") OR ("a" IS NOT NULL OR "b" IS NOT NULL)']); + }); + + test("a boolean literal is 1 / 0 on SQLite", async () => { + const flag = { "field.boolean": { name: "flag" } }; + const condition = { op: "eq", left: { field: "flag" }, right: { value: true } }; + expect(await checksOf([...AB, flag, { "validator.atLeastOne": { name: "one", "@fields": ["a", "b"], "@condition": condition } }], "sqlite")) + .toEqual(['("flag" <> 1) OR ("a" IS NOT NULL OR "b" IS NOT NULL)']); + }); + + test("an int-backed enum member is compared as its stored integer", async () => { + const kind = { "field.enum": { name: "kind", "@values": ["Owned", "Shared"], "@intValueMap": { Owned: 1, Shared: 2 } } }; + const condition = { op: "eq", left: { field: "kind" }, right: { value: "Owned" } }; + const out = await checksOf([...AB, kind, { "validator.atLeastOne": { name: "one", "@fields": ["a", "b"], "@condition": condition } }]); + expect(out).toContain('("kind" <> 1) OR ("a" IS NOT NULL OR "b" IS NOT NULL)'); + }); + + test("requiredWhen and presentIff take a @condition too", async () => { + const kind = { "field.string": { name: "kind" } }; + const condition = { op: "eq", left: { field: "kind" }, right: { value: "x" } }; + const out = await checksOf([...AB, kind, + { "validator.requiredWhen": { name: "rw", "@field": "a", "@when": "b", "@equals": "1", "@condition": condition } }, + { "validator.presentIff": { name: "pi", "@field": "b", "@when": "a", "@equals": "2", "@condition": condition } }, + ]); + expect(out).toEqual([ + `("kind" <> 'x') OR (("b" IS DISTINCT FROM 1) OR ("a" IS NOT NULL))`, + `("kind" <> 'x') OR (("b" IS NOT NULL) = ("a" IS NOT DISTINCT FROM 2))`, + ]); + }); +}); + +describe("declared CHECK names", () => { + const len = (constraintName: string) => ({ "field.string": { name: "code", "@maxLength": 10, children: [ + { "validator.length": { name: "len", "@max": 10, "@constraintName": constraintName } }, + ] } }); + + test("two CHECKs on one table with one declared name are refused", async () => { + const root = await load([...AB, + { "validator.atLeastOne": { name: "one", "@fields": ["a", "b"], "@constraintName": "CK_T_X" } }, + { "validator.comparison": { name: "cmp", "@left": "a", "@op": "lt", "@right": "b", "@constraintName": "CK_T_X" } }, + ]); + let err: unknown; + try { buildExpectedSchema(root, { dialect: "postgres" }); } catch (e) { err = e; } + expect(isInvalidCheckNameError(err)).toBe(true); + expect((err as Error).message).toContain('CHECK "CK_T_X" on table "t" has the same name as another CHECK named "CK_T_X"'); + }); + + test("a declared name equal to the primary key's is refused", async () => { + const root = await load([...AB, { "validator.atLeastOne": { name: "one", "@fields": ["a", "b"], "@constraintName": "t_pkey" } }]); + expect(() => buildExpectedSchema(root, { dialect: "postgres" })).toThrow(/has the same name as the primary key/); + }); + + test("a declared name over 63 bytes is refused on Postgres, accepted on SQLite", async () => { + const long = "CK_" + "x".repeat(61); + const root = await load([...AB, { "validator.atLeastOne": { name: "one", "@fields": ["a", "b"], "@constraintName": long } }]); + expect(() => buildExpectedSchema(root, { dialect: "postgres" })).toThrow(/is 64 bytes; Postgres keeps only the first 63/); + expect(buildExpectedSchema(root, { dialect: "sqlite" }).tables[0]!.checks.map((c) => c.name)).toEqual([long]); + }); + + test("a declared name keeps a length CHECK skipRedundantChecks would leave out", async () => { + const opts = { dialect: "postgres", conventions: efcoreConventions, skipRedundantChecks: true } as const; + const t = buildExpectedSchema(await load([len("CK_T_Code")]), opts).tables[0]!; + expect(t.checks).toEqual([{ name: "CK_T_Code", expression: 'length("code") <= 10' }]); + const plain = buildExpectedSchema(await load([{ "field.string": { name: "code", "@maxLength": 10, children: [ + { "validator.length": { name: "len", "@max": 10 } }, + ] } }]), opts).tables[0]!; + expect(plain.checks).toEqual([]); + }); +}); + +describe("a duration literal in a CHECK", () => { + const model = [...AB, { "field.duration": { name: "wait" } }, { "field.string": { name: "label" } }, + { "validator.comparison": { name: "cmp", "@left": "a", "@op": "lt", "@right": "b", + "@condition": { op: "gt", left: { field: "wait" }, right: { value: "-PT90S" } } } }, + { "validator.requiredWhen": { name: "rw", "@field": "label", "@when": "wait", "@equals": "PT1H" } }, + ]; + + test("is the interval spelling on Postgres and the microsecond integer on SQLite", async () => { + const root = await load(model); + const on = (dialect: "postgres" | "sqlite") => buildExpectedSchema(root, { dialect }).tables[0]!.checks.map((c) => c.expression); + expect(on("postgres")).toEqual([ + `("wait" <= '-00:01:30') OR ("a" < "b")`, + `("wait" IS DISTINCT FROM '01:00:00') OR ("label" IS NOT NULL)`, + ]); + expect(on("sqlite")).toEqual([ + `("wait" <= -90000000) OR ("a" < "b")`, + `("wait" IS NOT 3600000000) OR ("label" IS NOT NULL)`, + ]); + }); + + test("a literal compared with a coalesce of durations takes the column's form too", async () => { + const root = await load([...AB, { "field.duration": { name: "wait" } }, { "field.duration": { name: "hold" } }, + { "validator.comparison": { name: "cmp", "@left": "a", "@op": "lt", "@right": "b", + "@condition": { op: "gt", left: { fn: "coalesce", args: [{ field: "wait" }, { field: "hold" }] }, right: { value: "-PT1M" } } } }]); + expect(buildExpectedSchema(root, { dialect: "sqlite" }).tables[0]!.checks.map((c) => c.expression)) + .toEqual([`(COALESCE("wait", "hold") <= -60000000) OR ("a" < "b")`]); + expect(buildExpectedSchema(root, { dialect: "postgres" }).tables[0]!.checks.map((c) => c.expression)) + .toEqual([`(COALESCE("wait", "hold") <= '-00:01:00') OR ("a" < "b")`]); + }); + + test("a literal that is not a duration is refused when the CHECK is built", async () => { + const root = await load([...AB, { "field.duration": { name: "wait" } }, + { "validator.comparison": { name: "cmp", "@left": "a", "@op": "lt", "@right": "b", + "@condition": { op: "gt", left: { field: "wait" }, right: { value: "P1M" } } } }]); + expect(() => buildExpectedSchema(root, { dialect: "postgres" })).toThrow(/compares field.duration 'wait' with "P1M", which is not an ISO-8601 duration/); + }); +}); diff --git a/server/typescript/packages/migrate-ts/test/unit/diff-rename-constraints.test.ts b/server/typescript/packages/migrate-ts/test/unit/diff-rename-constraints.test.ts new file mode 100644 index 0000000000..31659d3e8c --- /dev/null +++ b/server/typescript/packages/migrate-ts/test/unit/diff-rename-constraints.test.ts @@ -0,0 +1,115 @@ +/** + * A constraint or index whose definition is unchanged and whose NAME differs is renamed, + * not dropped and re-created — on a table that is not itself renamed, too. + * + * Switching a project's schema conventions (default → efcore) renames every FK. Before + * this, each one became a gated `drop-fk` plus an `add-fk`: the migration was blocked + * behind `--allow drop-fk`, and allowing it dropped and rebuilt every foreign key on a + * live database. On Postgres the pair is one `ALTER TABLE … RENAME CONSTRAINT`, the same + * fold a table rename already gets. SQLite cannot rename a constraint, so it keeps the + * rebuild there. + */ +import { describe, test, expect } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import { buildExpectedSchema } from "../../src/expected-schema.js"; +import { defaultConventions, efcoreConventions } from "../../src/schema-conventions.js"; +import { diff } from "../../src/diff/index.js"; +import { emit } from "../../src/emit/index.js"; + +const DOC = { + "metadata.root": { + package: "acme", + children: [ + { "object.entity": { name: "Order", children: [ + { "source.rdb": { "@table": "Orders" } }, + { "field.long": { name: "id", "@column": "Id" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + ] } }, + { "object.entity": { name: "OrderLine", children: [ + { "source.rdb": { "@table": "OrderLines" } }, + { "field.long": { name: "id", "@column": "Id" } }, + { "field.long": { name: "orderId", "@column": "OrderId" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + { "index.lookup": { name: "IX_OrderLines_OrderId", "@fields": ["orderId"] } }, + { "identity.reference": { name: "order", "@fields": ["orderId"], "@references": "Order" } }, + ] } }, + ], + }, +}; + +async function schemas() { + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(DOC))]); + expect(errors).toHaveLength(0); + return { + live: buildExpectedSchema(root, { conventions: defaultConventions }), + wanted: buildExpectedSchema(root, { conventions: efcoreConventions }), + }; +} + +describe("an FK whose only change is its name", () => { + test("Postgres: one rename-constraints change per table, no drop-fk / add-fk", async () => { + const { live, wanted } = await schemas(); + const { changes } = await diff(wanted, live, { dialect: "postgres" }); + + expect(changes.filter((c) => c.kind === "drop-fk" || c.kind === "add-fk")).toEqual([]); + const renames = changes.filter((c) => c.kind === "rename-constraints"); + expect(renames.map((r) => r.table).sort()).toEqual(["OrderLines", "Orders"]); + expect(renames.find((r) => r.table === "OrderLines")).toMatchObject({ + constraintRenames: [ + { from: "OrderLines_OrderId_fk", to: "FK_OrderLines_Orders_OrderId" }, + { from: "OrderLines_pkey", to: "PK_OrderLines" }, + ], + }); + expect(renames.find((r) => r.table === "Orders")).toMatchObject({ + constraintRenames: [{ from: "Orders_pkey", to: "PK_Orders" }], + }); + }); + + test("Postgres: rendered as RENAME CONSTRAINT, and the down renames it back", async () => { + const { live, wanted } = await schemas(); + const { changes } = await diff(wanted, live, { dialect: "postgres" }); + const { up, down } = emit(changes, { dialect: "postgres" }); + + expect(up).toContain('ALTER TABLE "OrderLines" RENAME CONSTRAINT "OrderLines_OrderId_fk" TO "FK_OrderLines_Orders_OrderId";'); + expect(down).toContain('ALTER TABLE "OrderLines" RENAME CONSTRAINT "FK_OrderLines_Orders_OrderId" TO "OrderLines_OrderId_fk";'); + expect(up).toContain('ALTER TABLE "Orders" RENAME CONSTRAINT "Orders_pkey" TO "PK_Orders";'); + expect(down).toContain('ALTER TABLE "Orders" RENAME CONSTRAINT "PK_Orders" TO "Orders_pkey";'); + expect(up).not.toContain("DROP CONSTRAINT"); + }); + + test("after the rename, the database matches and nothing is proposed", async () => { + const { wanted } = await schemas(); + const { changes } = await diff(wanted, structuredClone(wanted), { dialect: "postgres" }); + expect(changes).toEqual([]); + }); + + test("a primary key the database truncated is compared as stored", async () => { + const { live } = await schemas(); + const long = "T".repeat(60); + const want = structuredClone(live); + want.tables.find((t) => t.name === "Orders")!.name = long; + const actual = structuredClone(want); + actual.tables.find((t) => t.name === long)!.primaryKeyName = `${long}_pk`; + const { changes } = await diff(want, actual, { dialect: "postgres" }); + expect(changes.filter((c) => c.kind === "rename-constraints")).toEqual([]); + }); + + test("an FK whose definition also changed is still dropped and re-added", async () => { + const { live, wanted } = await schemas(); + const actual = structuredClone(live); + actual.tables.find((t) => t.name === "OrderLines")!.foreignKeys[0]!.onDelete = "cascade"; + const { changes } = await diff(wanted, actual, { dialect: "postgres" }); + + expect(changes.some((c) => c.kind === "drop-fk")).toBe(true); + // Only the primary keys are renamed; the changed FK is not folded into a rename. + const renamed = changes.flatMap((c) => (c.kind === "rename-constraints" ? c.constraintRenames ?? [] : [])); + expect(renamed.map((r) => r.to).sort()).toEqual(["PK_OrderLines", "PK_Orders"]); + }); + + test("SQLite does not fold (it cannot rename a constraint)", async () => { + const { live, wanted } = await schemas(); + const { changes } = await diff(wanted, live, { dialect: "sqlite" }); + + expect(changes.some((c) => c.kind === "rename-constraints")).toBe(false); + }); +}); diff --git a/server/typescript/packages/migrate-ts/test/unit/diff-unique-key-kind.test.ts b/server/typescript/packages/migrate-ts/test/unit/diff-unique-key-kind.test.ts new file mode 100644 index 0000000000..6f03d888b6 --- /dev/null +++ b/server/typescript/packages/migrate-ts/test/unit/diff-unique-key-kind.test.ts @@ -0,0 +1,106 @@ +/** + * A unique key whose definition and name are unchanged but which the schema conventions now + * want as the other kind (plain unique index vs UNIQUE constraint). Postgres only. Conventions + * with no preference (the default) accept either kind, so nothing is converted. + * + * Index to constraint is done in place (`ADD CONSTRAINT … UNIQUE USING INDEX`), so a foreign + * key that relies on the index keeps working. Constraint to index has no in-place form: the + * constraint is dropped and the index re-created, and every foreign key onto the key is + * dropped first and added back after, because Postgres refuses to drop a key an FK relies on. + * Those drops are gated like any other. + */ +import { describe, test, expect } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import { buildExpectedSchema } from "../../src/expected-schema.js"; +import { defaultConventions, efcoreConventions } from "../../src/schema-conventions.js"; +import { diff } from "../../src/diff/index.js"; +import { emit } from "../../src/emit/index.js"; +import type { SchemaSnapshot } from "../../src/types.js"; + +const DOC = { + "metadata.root": { + package: "acme", + children: [ + { "object.entity": { name: "Account", children: [ + { "source.rdb": { "@table": "Accounts" } }, + { "field.long": { name: "id", "@column": "Id" } }, + { "field.string": { name: "code", "@column": "Code", "@required": true } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + { "identity.secondary": { name: "IX_Accounts_Code", "@fields": ["code"] } }, + ] } }, + { "object.entity": { name: "Ledger", children: [ + { "source.rdb": { "@table": "Ledgers" } }, + { "field.long": { name: "id", "@column": "Id" } }, + { "field.string": { name: "accountCode", "@column": "AccountCode" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + { "identity.reference": { name: "account", "@fields": ["accountCode"], "@references": "Account.code" } }, + ] } }, + ], + }, +}; + +/** + * The same model with the key as a constraint (efcore), as an index the conventions ask for, and + * as the default conventions leave it (no preference), with every other name held to the wanted one. + */ +async function schemas(): Promise<{ asIndex: SchemaSnapshot; asConstraint: SchemaSnapshot; asEither: SchemaSnapshot }> { + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(DOC))]); + expect(errors).toHaveLength(0); + const asConstraint = buildExpectedSchema(root, { conventions: efcoreConventions }); + const plain = buildExpectedSchema(root, { conventions: defaultConventions }); + const asIndex = structuredClone(asConstraint); + for (const t of asIndex.tables) { + for (const ix of t.indexes) { + const p = plain.tables.find((x) => x.name === t.name)!.indexes.find((x) => x.name === ix.name || x.columns.join() === ix.columns.join()); + if (p !== undefined && p.constraint === undefined) delete ix.constraint; + } + } + const asEither = structuredClone(asIndex); + for (const t of asIndex.tables) for (const ix of t.indexes) if (ix.unique && ix.constraint === undefined) ix.plainIndex = true; + return { asIndex, asConstraint, asEither }; +} + +describe("a unique key that changes only between index and constraint", () => { + test("the fixture really differs only in the key's kind", async () => { + const { asIndex, asConstraint } = await schemas(); + const key = (s: SchemaSnapshot) => s.tables.find((t) => t.name === "Accounts")!.indexes.find((i) => i.unique)!; + expect(key(asConstraint).constraint).toBe("unique"); + expect(key(asIndex).constraint).toBeUndefined(); + }); + + test("index to constraint: promoted in place, and the down demotes it", async () => { + const { asIndex, asConstraint } = await schemas(); + const { changes } = await diff(asConstraint, asIndex, { dialect: "postgres" }); + expect(changes.filter((c) => c.kind !== "rename-constraints")).toEqual([]); + const name = asConstraint.tables.find((t) => t.name === "Accounts")!.indexes.find((i) => i.unique)!.name; + const { up, down } = emit(changes, { dialect: "postgres" }); + expect(up).toContain(`ALTER TABLE "Accounts" ADD CONSTRAINT "${name}" UNIQUE USING INDEX "${name}";`); + expect(down).toContain(`ALTER TABLE "Accounts" DROP CONSTRAINT "${name}";`); + expect(down).toContain(`CREATE UNIQUE INDEX "${name}" ON "Accounts"`); + }); + + test("constraint to index: the FK onto it is dropped first and added back after", async () => { + const { asIndex, asConstraint } = await schemas(); + // Both drops are gated like any other: the key and the FK are briefly gone. + const gated = await diff(asIndex, asConstraint, { dialect: "postgres" }); + expect(gated.changes.filter((c) => c.status.state !== "allowed").map((c) => c.kind).sort()).toEqual(["drop-fk", "drop-index"]); + const { changes } = await diff(asIndex, asConstraint, { dialect: "postgres", allow: { dropIndex: true, dropFk: true } }); + expect(changes.map((c) => c.kind).sort()).toEqual(["add-fk", "add-index", "drop-fk", "drop-index"]); + const { up } = emit(changes, { dialect: "postgres" }); + const at = (s: string) => up.indexOf(s); + expect(at("DROP CONSTRAINT IF EXISTS \"FK_")).toBeLessThan(at("CREATE UNIQUE INDEX")); + expect(at("CREATE UNIQUE INDEX")).toBeLessThan(at("ADD CONSTRAINT \"FK_")); + }); + + test("no preference (the default conventions): a live constraint is left as it is", async () => { + const { asEither, asConstraint } = await schemas(); + const { changes } = await diff(asEither, asConstraint, { dialect: "postgres" }); + expect(changes).toEqual([]); + }); + + test("SQLite has no such distinction to compare", async () => { + const { asIndex, asConstraint } = await schemas(); + const { changes } = await diff(asConstraint, asIndex, { dialect: "sqlite" }); + expect(changes).toEqual([]); + }); +}); diff --git a/server/typescript/packages/migrate-ts/test/unit/duration-column.test.ts b/server/typescript/packages/migrate-ts/test/unit/duration-column.test.ts new file mode 100644 index 0000000000..7275b933a3 --- /dev/null +++ b/server/typescript/packages/migrate-ts/test/unit/duration-column.test.ts @@ -0,0 +1,59 @@ +/** + * field.duration's column: a native INTERVAL on Postgres (a @default in Postgres's own interval + * spelling, which it reads back unchanged), and on SQLite the 64-bit microsecond INTEGER the + * runtimes store, so it orders and compares as the span does (a @default is that integer too). + */ +import { describe, test, expect } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import { buildExpectedSchema } from "../../src/expected-schema.js"; +import { emit } from "../../src/emit/index.js"; +import { diff } from "../../src/diff/index.js"; + +const DOC = JSON.stringify({ "metadata.root": { package: "acme", children: [{ "object.entity": { name: "Timer", children: [ + { "source.rdb": { "@table": "timers" } }, + { "field.long": { name: "id" } }, + { "field.duration": { name: "grace", "@default": "-PT90M" } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment" } }, +] } }] } }); + +async function column(dialect: "postgres" | "sqlite") { + const { root } = await new MetaDataLoader().load([new InMemoryStringSource(DOC)]); + const schema = buildExpectedSchema(root, { dialect }); + return { schema, col: schema.tables[0]!.columns.find((c) => c.name === "grace")! }; +} + +describe("field.duration column", () => { + test("Postgres: INTERVAL, default in the interval spelling", async () => { + const { col } = await column("postgres"); + expect(col.sqlType).toEqual({ kind: "interval" }); + expect(col.default).toEqual({ kind: "literal", value: "-01:30:00" }); + }); + + test("SQLite: a 64-bit INTEGER of microseconds, default included", async () => { + const { schema, col } = await column("sqlite"); + expect(col.sqlType).toEqual({ kind: "integer", bits: 64 }); + expect(col.default).toEqual({ kind: "literal", value: "-5400000000" }); + const { changes } = await diff({ expected: schema, actual: { tables: [], views: [] }, dialect: "sqlite" }); + expect(emit(changes, { dialect: "sqlite", expectedSchema: schema }).up).toContain('"grace" INTEGER DEFAULT -5400000000'); + }); + + test("a live interval modelled as text is read as text with an advisory; modelled as anything else, it is a type change", async () => { + const live = (expected: Awaited>["schema"]) => ({ + ...expected, + tables: expected.tables.map((t) => ({ ...t, columns: t.columns.map((c) => c.name === "grace" ? { ...c, sqlType: { kind: "interval" as const } } : c) })), + }); + const as = async (subType: string) => { + const doc = DOC.replace('"field.duration":{"name":"grace","@default":"-PT90M"}', `"field.${subType}":{"name":"grace"}`); + expect(doc).not.toBe(DOC); + const { root } = await new MetaDataLoader().load([new InMemoryStringSource(doc)]); + const expected = buildExpectedSchema(root, { dialect: "postgres" }); + return diff({ expected, actual: live(expected), dialect: "postgres" }); + }; + const asString = await as("string"); + expect(asString.changes.filter((c) => c.kind === "change-column-type")).toEqual([]); + expect(asString.advisories).toEqual([{ kind: "interval-as-string", table: "timers", column: "grace" }]); + const asInt = await as("int"); + expect(asInt.changes.filter((c) => c.kind === "change-column-type").map((c) => (c as { from: unknown }).from)).toEqual([{ kind: "interval" }]); + expect(asInt.advisories).toEqual([]); + }); +}); diff --git a/server/typescript/packages/migrate-ts/test/unit/enum-max-length.test.ts b/server/typescript/packages/migrate-ts/test/unit/enum-max-length.test.ts new file mode 100644 index 0000000000..823e69257f --- /dev/null +++ b/server/typescript/packages/migrate-ts/test/unit/enum-max-length.test.ts @@ -0,0 +1,89 @@ +/** + * A string-stored field.enum's column is VARCHAR(n) when it has a length (its @maxLength, + * else the project's enums.maxLength, resolveEnumMaxLength), and unbounded text otherwise, + * as before. An @intValueMap enum stays an integer. A member longer than the project + * default refuses the build before any SQL is emitted. + */ +import { describe, test, expect } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource, MetaModelError } from "@metaobjectsdev/metadata"; +import { buildExpectedSchema } from "../../src/expected-schema.js"; +import { emit } from "../../src/emit/index.js"; +import { diff } from "../../src/diff/index.js"; + +async function schema(enumDecls: Record[], maxLength?: number) { + const doc = { "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Ticket", children: [ + { "source.rdb": { "@table": "tickets" } }, + { "field.long": { name: "id" } }, + ...enumDecls.map((d) => ({ "field.enum": d })), + { "identity.primary": { name: "pk", "@fields": "id" } }, + ] } }, + ] } }; + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(doc))]); + expect(errors).toHaveLength(0); + if (maxLength !== undefined) root.enumDefaults = { maxLength }; + return () => buildExpectedSchema(root); +} + +const column = (s: ReturnType, name: string) => + s.tables.find((t) => t.name === "tickets")!.columns.find((c) => c.name === name)!; + +describe("a string-stored enum's column length", () => { + test("@maxLength → VARCHAR(n) in the CREATE TABLE", async () => { + const s = (await schema([{ name: "status", "@values": ["OPEN", "CLOSED"], "@maxLength": 16 }]))(); + expect(column(s, "status").sqlType).toEqual({ kind: "text", maxLength: 16 }); + const { changes } = await diff(s, { tables: [], views: [] } as never, { dialect: "postgres" }); + expect(emit(changes, { dialect: "postgres" }).up).toContain('"status" VARCHAR(16)'); + }); + + test("the project default sizes an enum with no @maxLength", async () => { + const s = (await schema([{ name: "status", "@values": ["OPEN", "CLOSED"] }], 32))(); + expect(column(s, "status").sqlType).toEqual({ kind: "text", maxLength: 32 }); + }); + + test("no length anywhere → unbounded text, as before", async () => { + const s = (await schema([{ name: "status", "@values": ["OPEN", "CLOSED"] }]))(); + expect(column(s, "status").sqlType).toEqual({ kind: "text" }); + }); + + test("an @intValueMap enum stays an integer under a project default", async () => { + const s = (await schema([{ name: "rank", "@values": ["A", "B"], "@intValueMap": { A: 1, B: 2 } }], 32))(); + expect(column(s, "rank").sqlType).toEqual({ kind: "integer", bits: 32 }); + }); + + test("a member longer than the project default refuses the build", async () => { + const build = await schema([{ name: "status", "@values": ["OPEN", "IN_PROGRESS"] }], 10); + expect(build).toThrow(MetaModelError); + expect(build).toThrow(/IN_PROGRESS/); + }); +}); + +describe("turning a length on for an enum column that already exists", () => { + // text → VARCHAR(n) fails on any existing row longer than n, and the member list says + // nothing about rows written before the enum was narrowed, so it is a lossy type change: + // gated behind --allow type-change like any other narrowing. + test("text → VARCHAR(n) is a change-column-type gated as lossy", async () => { + const before = (await schema([{ name: "status", "@values": ["OPEN", "CLOSED"] }]))(); + const after = (await schema([{ name: "status", "@values": ["OPEN", "CLOSED"] }], 32))(); + const { changes } = await diff(after, before, { dialect: "postgres" }); + const change = changes.find((c) => c.kind === "change-column-type"); + expect(change).toMatchObject({ column: "status", from: { kind: "text" }, to: { kind: "text", maxLength: 32 } }); + expect(change?.status).toMatchObject({ state: "blocked" }); + const allowed = await diff(after, before, { dialect: "postgres", allow: { typeChange: true } }); + expect(emit(allowed.changes, { dialect: "postgres" }).up).toContain('ALTER COLUMN "status" TYPE VARCHAR(32)'); + }); + + test("turning it off again (VARCHAR(n) → text) widens, so it is allowed", async () => { + const before = (await schema([{ name: "status", "@values": ["OPEN", "CLOSED"] }], 32))(); + const after = (await schema([{ name: "status", "@values": ["OPEN", "CLOSED"] }]))(); + const { changes } = await diff(after, before, { dialect: "postgres" }); + expect(changes.find((c) => c.kind === "change-column-type")?.status).toEqual({ state: "allowed" }); + }); + + test("SQLite does not enforce a VARCHAR length, so there is nothing to change", async () => { + const before = (await schema([{ name: "status", "@values": ["OPEN", "CLOSED"] }]))(); + const after = (await schema([{ name: "status", "@values": ["OPEN", "CLOSED"] }], 32))(); + const { changes } = await diff(after, before, { dialect: "sqlite" }); + expect(changes.filter((c) => c.kind === "change-column-type")).toEqual([]); + }); +}); diff --git a/server/typescript/packages/migrate-ts/test/unit/expected-schema-conventions.test.ts b/server/typescript/packages/migrate-ts/test/unit/expected-schema-conventions.test.ts new file mode 100644 index 0000000000..526f74d2f2 --- /dev/null +++ b/server/typescript/packages/migrate-ts/test/unit/expected-schema-conventions.test.ts @@ -0,0 +1,171 @@ +/** + * buildExpectedSchema applies the project's schema conventions to the objects no code + * addresses, and the Postgres emitter renders what they decide. A metadata override + * (`@constraintName`, an index node's name) still wins, and the `default` conventions + * leave the expected schema exactly as it has always been. + */ +import { describe, test, expect } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import { buildExpectedSchema } from "../../src/expected-schema.js"; +import { efcoreConventions, type SchemaConventions } from "../../src/schema-conventions.js"; +import { InvalidSchemaConventionsError } from "../../src/errors.js"; +import { emit } from "../../src/emit/index.js"; +import { diff } from "../../src/diff/index.js"; + +const DOC = { + "metadata.root": { + package: "acme", + children: [ + { "object.entity": { name: "Order", children: [ + { "source.rdb": { "@table": "Orders" } }, + { "field.long": { name: "id", "@column": "Id" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + ] } }, + { "object.entity": { name: "OrderLine", children: [ + { "source.rdb": { "@table": "OrderLines" } }, + { "field.long": { name: "id", "@column": "Id" } }, + { "field.long": { name: "orderId", "@column": "OrderId" } }, + { "field.long": { name: "replacesOrderId", "@column": "ReplacesOrderId" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + { "identity.reference": { name: "order", "@fields": ["orderId"], "@references": "Order" } }, + { "identity.reference": { name: "replaces", "@fields": ["replacesOrderId"], "@references": "Order", "@constraintName": "FK_Legacy_Name" } }, + ] } }, + { "object.entity": { name: "Region", children: [ + { "source.rdb": { "@table": "Regions" } }, + { "field.long": { name: "id", "@column": "Id" } }, + { "field.string": { name: "code", "@column": "Code" } }, + { "field.string": { name: "name", "@column": "Name" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + { "identity.secondary": { name: "AK_Regions_Code", "@fields": ["code"] } }, + { "identity.secondary": { name: "IX_Regions_Name", "@fields": ["name"] } }, + ] } }, + { "object.entity": { name: "Store", children: [ + { "source.rdb": { "@table": "Stores" } }, + { "field.long": { name: "id", "@column": "Id" } }, + { "field.string": { name: "regionCode", "@column": "RegionCode" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + { "index.lookup": { name: "IX_Stores_RegionCode_Id", "@fields": ["regionCode", "id"] } }, + { "identity.reference": { name: "region", "@fields": ["regionCode"], "@references": "Region.code" } }, + ] } }, + ], + }, +}; + +async function load() { + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(DOC))]); + expect(errors).toHaveLength(0); + return root; +} + +const table = (s: ReturnType, name: string) => s.tables.find((t) => t.name === name)!; + +describe("buildExpectedSchema with the efcore conventions", () => { + test("PK and FK names follow EF Core; a declared @constraintName still wins", async () => { + const s = buildExpectedSchema(await load(), { conventions: efcoreConventions }); + + expect(table(s, "Orders").primaryKeyName).toBe("PK_Orders"); + expect(table(s, "OrderLines").foreignKeys.map((f) => f.name).sort()).toEqual(["FK_Legacy_Name", "FK_OrderLines_Orders_OrderId"]); + }); + + test("an FK gets an IX_ index unless an existing index already starts with its columns", async () => { + const s = buildExpectedSchema(await load(), { conventions: efcoreConventions }); + + expect(table(s, "OrderLines").indexes.map((i) => [i.name, i.columns, i.unique])).toEqual([ + ["IX_OrderLines_OrderId", ["OrderId"], false], + ["IX_OrderLines_ReplacesOrderId", ["ReplacesOrderId"], false], + ]); + // IX_Stores_RegionCode_Id already leads with RegionCode + expect(table(s, "Stores").indexes.map((i) => i.name)).toEqual(["IX_Stores_RegionCode_Id"]); + }); + + test("a unique key an FK references is a constraint; another stays an index", async () => { + const s = buildExpectedSchema(await load(), { conventions: efcoreConventions }); + const kinds = Object.fromEntries(table(s, "Regions").indexes.map((i) => [i.name, i.constraint ?? "index"])); + + expect(kinds).toEqual({ AK_Regions_Code: "unique", IX_Regions_Name: "index" }); + }); + + test("the Postgres emitter renders the PK name and the unique constraint", async () => { + const s = buildExpectedSchema(await load(), { conventions: efcoreConventions }); + const { changes } = await diff(s, { tables: [], views: [] }, { dialect: "postgres" }); + const { up, down } = emit(changes, { dialect: "postgres" }); + + expect(up).toContain('CONSTRAINT "PK_Orders" PRIMARY KEY ("Id")'); + expect(up).toContain('ALTER TABLE "Regions" ADD CONSTRAINT "AK_Regions_Code" UNIQUE ("Code");'); + expect(up).toContain('CREATE UNIQUE INDEX "IX_Regions_Name" ON "Regions" ("Name");'); + expect(up).toContain('CREATE INDEX "IX_OrderLines_OrderId" ON "OrderLines" ("OrderId");'); + expect(down).toContain('ALTER TABLE "Regions" DROP CONSTRAINT "AK_Regions_Code";'); + }); +}); + +describe("buildExpectedSchema with the default conventions", () => { + test("is exactly what it was without any conventions", async () => { + const root = await load(); + + expect(buildExpectedSchema(root, { conventions: undefined })).toEqual(buildExpectedSchema(root)); + const s = buildExpectedSchema(root); + expect(table(s, "Orders").primaryKeyName).toBeUndefined(); + expect(table(s, "OrderLines").foreignKeys.map((f) => f.name).sort()).toEqual(["FK_Legacy_Name", "OrderLines_OrderId_fk"]); + expect(table(s, "OrderLines").indexes).toEqual([]); + expect(table(s, "Regions").indexes.every((i) => i.constraint === undefined)).toBe(true); + }); +}); + +describe("conventions come only from the caller", () => { + test("a model never supplies them: no option is the default conventions", async () => { + const root = await load(); + + expect(table(buildExpectedSchema(root), "Orders").primaryKeyName).toBeUndefined(); + expect(table(buildExpectedSchema(root, { conventions: efcoreConventions }), "Orders").primaryKeyName).toBe("PK_Orders"); + }); + + test("a conventions object missing a member is refused by name", async () => { + const root = await load(); + const partial = { id: "acme", primaryKeyName: () => "pk" } as unknown as SchemaConventions; + + expect(() => buildExpectedSchema(root, { conventions: partial })).toThrow(InvalidSchemaConventionsError); + }); +}); + +describe("efcore unique keys that must stay indexes", () => { + const doc = (secondary: Record, extra: unknown[] = []) => ({ "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Region", children: [ + { "source.rdb": { "@table": "Regions", "@schema": "a" } }, + { "field.long": { name: "id", "@column": "Id" } }, + { "field.string": { name: "code", "@column": "Code" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + { "identity.secondary": { name: "AK_Regions_Code", "@fields": ["code"], ...secondary } }, + ] } }, + { "object.entity": { name: "Store", children: [ + { "source.rdb": { "@table": "Stores", "@schema": "a" } }, + { "field.long": { name: "id", "@column": "Id" } }, + { "field.string": { name: "regionCode", "@column": "RegionCode" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + { "identity.reference": { name: "region", "@fields": ["regionCode"], "@references": "Region.code" } }, + ] } }, + ...extra, + ] } }); + const kind = async (d: unknown, schema: string) => { + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(d))]); + expect(errors).toHaveLength(0); + const t = buildExpectedSchema(root, { conventions: efcoreConventions }).tables.find((x) => x.name === "Regions" && x.schema === schema)!; + return t.indexes.find((i) => i.name.startsWith("AK_"))!.constraint ?? "index"; + }; + + test("a DESC key or a non-btree method stays an index (a UNIQUE constraint carries neither)", async () => { + expect(await kind(doc({}), "a")).toBe("unique"); + expect(await kind(doc({ "@orders": ["desc"] }), "a")).toBe("index"); + expect(await kind(doc({ "@using": "hash" }), "a")).toBe("index"); + }); + + test("an FK in one schema does not make a same-named key in another schema a constraint", async () => { + const other = { "object.entity": { name: "OtherRegion", children: [ + { "source.rdb": { "@table": "Regions", "@schema": "b" } }, + { "field.long": { name: "id", "@column": "Id" } }, + { "field.string": { name: "code", "@column": "Code" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + { "identity.secondary": { name: "AK_OtherRegions_Code", "@fields": ["code"] } }, + ] } }; + expect(await kind(doc({}, [other]), "b")).toBe("index"); + }); +}); diff --git a/server/typescript/packages/migrate-ts/test/unit/fk-target-unique.test.ts b/server/typescript/packages/migrate-ts/test/unit/fk-target-unique.test.ts new file mode 100644 index 0000000000..1159cf4ac5 --- /dev/null +++ b/server/typescript/packages/migrate-ts/test/unit/fk-target-unique.test.ts @@ -0,0 +1,266 @@ +/** + * Guard: a foreign key must reference columns the target table holds unique. + * + * A reference whose dotted @references names target fields other than the target's + * primary key, with nothing declaring those fields unique, used to pass load and emit + * an FK Postgres refuses at apply time ("there is no unique constraint matching given + * keys for referenced table"). buildExpectedSchema() must refuse it before any SQL is + * emitted, naming the reference and the fix. + * + * Unique means: exactly the primary key's fields, exactly the fields of an + * identity.secondary that is a plain unique key (no @where, no @expr), or a single + * field carrying @unique. Field order does not matter; a subset or superset does. + */ +import { describe, test, expect } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import { buildExpectedSchema } from "../../src/expected-schema.js"; +import { FkTargetNotUniqueError } from "../../src/errors.js"; +import { buildExpectedSchemaWithProvenance } from "../../src/expected-schema.js"; +import { scopeExpectedSchema } from "../../src/scope.js"; +import { emit } from "../../src/emit/index.js"; +import { diff } from "../../src/diff/index.js"; +import { Database } from "bun:sqlite"; + +async function loadDoc(doc: unknown) { + const { root, errors } = await new MetaDataLoader().load([ + new InMemoryStringSource(JSON.stringify(doc)), + ]); + expect(errors).toHaveLength(0); + return root; +} + +/** A target entity (Person: id, region, code) and a holder (Visit) referencing it. */ +function makeDoc(options: { + references: string; + holderFields: string[]; + targetExtra?: unknown[]; + codeUnique?: boolean; +}) { + return { + "metadata.root": { + package: "acme", + children: [ + { + "object.entity": { + name: "Person", + children: [ + { "source.rdb": {} }, + { "field.long": { name: "id" } }, + { "field.long": { name: "region" } }, + { "field.string": { name: "code", ...(options.codeUnique ? { "@unique": true } : {}) } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + ...(options.targetExtra ?? []), + ], + }, + }, + { + "object.entity": { + name: "Visit", + children: [ + { "source.rdb": {} }, + { "field.long": { name: "id" } }, + { "field.long": { name: "personId" } }, + { "field.long": { name: "personRegion" } }, + { "field.string": { name: "personCode" } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + { + "identity.reference": { + name: "ref_person", + "@fields": options.holderFields, + "@references": options.references, + }, + }, + ], + }, + }, + ], + }, + }; +} + +async function build(options: Parameters[0]) { + const root = await loadDoc(makeDoc(options)); + return () => buildExpectedSchema(root); +} + +describe("buildExpectedSchema — an FK must reference a unique key", () => { + test("a non-key target field with nothing unique on it → FkTargetNotUniqueError naming the fix", async () => { + const run = await build({ references: "Person.code", holderFields: ["personCode"] }); + + expect(run).toThrow(FkTargetNotUniqueError); + expect(run).toThrow(/Entity "Visit": reference "ref_person" targets \[code\] on "Person"/); + expect(run).toThrow(/identity\.secondary/); + }); + + test("every offending reference, across tables, in ONE error — not one per run", async () => { + const doc = makeDoc({ references: "Person.code", holderFields: ["personCode"] }); + const children = (doc["metadata.root"] as { children: unknown[] }).children; + children.push({ + "object.entity": { + name: "Badge", + children: [ + { "source.rdb": {} }, + { "field.long": { name: "id" } }, + { "field.long": { name: "ownerRegion" } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + { "identity.reference": { name: "ref_owner", "@fields": ["ownerRegion"], "@references": "Person.region" } }, + ], + }, + }); + const root = await loadDoc(doc); + + let caught: unknown; + try { buildExpectedSchema(root); } catch (e) { caught = e; } + + expect(caught).toBeInstanceOf(FkTargetNotUniqueError); + const err = caught as FkTargetNotUniqueError; + expect(err.violations.map((v) => `${v.entityName}.${v.referenceName}`).sort()).toEqual(["Badge.ref_owner", "Visit.ref_person"]); + expect(err.message).toContain('reference "ref_person" targets [code] on "Person"'); + expect(err.message).toContain('reference "ref_owner" targets [region] on "Person"'); + }); + + test("a composite target that is not a declared key → throws", async () => { + const run = await build({ references: "Person.id,region", holderFields: ["personId", "personRegion"] }); + + expect(run).toThrow(FkTargetNotUniqueError); + }); + + test("a partial unique index is not a key an FK can reference → throws", async () => { + const run = await build({ + references: "Person.code", + holderFields: ["personCode"], + targetExtra: [{ "identity.secondary": { name: "ux_person_code", "@fields": ["code"], "@where": "\"region\" = 1" } }], + }); + + expect(run).toThrow(FkTargetNotUniqueError); + }); + + test("an identity.secondary on exactly the target fields, in any order → no throw", async () => { + const run = await build({ + references: "Person.id,region", + holderFields: ["personId", "personRegion"], + targetExtra: [{ "identity.secondary": { name: "ak_person_region_id", "@fields": ["region", "id"] } }], + }); + + expect(run).not.toThrow(); + }); + + test("a single target field with @unique → no throw", async () => { + const run = await build({ references: "Person.code", holderFields: ["personCode"], codeUnique: true }); + + expect(run).not.toThrow(); + }); + + test("the target's primary key, bare or dotted → no throw", async () => { + expect(await build({ references: "Person", holderFields: ["personId"] })).not.toThrow(); + expect(await build({ references: "Person.id", holderFields: ["personId"] })).not.toThrow(); + }); + + test("a TPH subtype's unique key is not emitted, so it does not count — the hint names the base", async () => { + const doc = { "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Party", "@discriminator": "kind", children: [ + { "source.rdb": { "@table": "parties" } }, + { "field.long": { name: "id" } }, + { "field.enum": { name: "kind", "@values": ["Carrier", "Broker"] } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + ] } }, + { "object.entity": { name: "Carrier", extends: "Party", "@discriminatorValue": "Carrier", children: [ + { "field.string": { name: "code" } }, + { "identity.secondary": { name: "ux_carrier_code", "@fields": ["code"] } }, + ] } }, + { "object.entity": { name: "Policy", children: [ + { "source.rdb": { "@table": "policies" } }, + { "field.long": { name: "id" } }, + { "field.string": { name: "carrierCode" } }, + { "identity.primary": { name: "id", "@fields": "id" } }, + { "identity.reference": { name: "ref_carrier", "@fields": ["carrierCode"], "@references": "Carrier.code" } }, + ] } }, + ] } }; + const root = await loadDoc(doc); + + expect(() => buildExpectedSchema(root)).toThrow(FkTargetNotUniqueError); + expect(() => buildExpectedSchema(root)).toThrow(/stored in the table of "Party".*declare it on "Party"/); + }); + + test("a bare reference into a composite primary key with one FK field is refused", async () => { + const doc = { "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Line", children: [ + { "source.rdb": {} }, + { "field.long": { name: "orderId" } }, + { "field.int": { name: "lineNo" } }, + { "identity.primary": { name: "pk", "@fields": ["orderId", "lineNo"] } }, + ] } }, + { "object.entity": { name: "Note", children: [ + { "source.rdb": {} }, + { "field.long": { name: "id" } }, + { "field.long": { name: "lineOrderId" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + { "identity.reference": { name: "ref_line", "@fields": ["lineOrderId"], "@references": "Line" } }, + ] } }, + ] } }; + + const root = await loadDoc(doc); + + expect(() => buildExpectedSchema(root)).toThrow(FkTargetNotUniqueError); + expect(() => buildExpectedSchema(root)).toThrow(/reference "ref_line" targets \[orderId\] on "Line"/); + }); + + test("a target this model does not build (@unmanaged) is not refused: its keys are not known here", async () => { + const doc = makeDoc({ references: "Person.code", holderFields: ["personCode"] }); + const person = (doc["metadata.root"].children[0] as { "object.entity": { children: unknown[] } })["object.entity"]; + person.children[0] = { "source.rdb": { "@unmanaged": true } }; + const root = await loadDoc(doc); + + expect(() => buildExpectedSchema(root)).not.toThrow(); + }); + + test("through scopeExpectedSchema only a governed holder is refused; the others are returned", async () => { + const root = await loadDoc(makeDoc({ references: "Person.code", holderFields: ["personCode"] })); + const built = buildExpectedSchemaWithProvenance(root); + + expect(built.fkTargetViolations?.map((v) => v.referenceName)).toEqual(["ref_person"]); + expect(() => scopeExpectedSchema(built, undefined)).toThrow(FkTargetNotUniqueError); + const outside = scopeExpectedSchema(built, (fqn) => !fqn.endsWith("Visit")); + expect(outside.ungovernedFkTargetViolations?.map((v) => v.referenceName)).toEqual(["ref_person"]); + }); +}); + +describe("on SQLite an FK onto a non-unique target is reported, not refused", () => { + // SQLite checks foreign keys only with PRAGMA foreign_keys on (off by default), so the + // DDL is accepted and only an enforced write fails. D1 always enforces them, and Postgres + // refuses the DDL itself, so both keep the refusal. + test("buildExpectedSchema on SQLite does not throw; on Postgres and D1 it does", async () => { + const root = await loadDoc(makeDoc({ references: "Person.code", holderFields: ["personCode"] })); + expect(() => buildExpectedSchema(root, { dialect: "sqlite" })).not.toThrow(); + expect(() => buildExpectedSchema(root, { dialect: "d1" })).toThrow(FkTargetNotUniqueError); + expect(() => buildExpectedSchema(root, { dialect: "postgres" })).toThrow(FkTargetNotUniqueError); + }); + + test("scopeExpectedSchema returns them as unenforced, governed or not", async () => { + const root = await loadDoc(makeDoc({ references: "Person.code", holderFields: ["personCode"] })); + const scoped = scopeExpectedSchema(buildExpectedSchemaWithProvenance(root, { dialect: "sqlite" }), undefined); + expect(scoped.unenforcedFkTargetViolations?.map((v) => v.referenceName)).toEqual(["ref_person"]); + expect(scoped.ungovernedFkTargetViolations).toBeUndefined(); + }); + + test("the SQLite DDL applies; a write fails only once foreign keys are enforced", async () => { + const root = await loadDoc(makeDoc({ references: "Person.code", holderFields: ["personCode"] })); + const want = buildExpectedSchema(root, { dialect: "sqlite" }); + const { changes } = await diff(want, { tables: [], views: [] }, { dialect: "sqlite" }); + const { up } = emit(changes, { dialect: "sqlite" }); + const db = new Database(":memory:"); + try { + db.exec(up); + const person = want.tables.find((t) => t.name !== "visit" && t.columns.some((c) => c.name === "code"))!; + const visit = want.tables.find((t) => t !== person)!; + const col = (t: typeof person, n: string) => t.columns.find((c) => c.name.replace(/_/g, "").toLowerCase() === n.toLowerCase())!.name; + db.exec(`INSERT INTO "${person.name}" ("${col(person, "id")}", "${col(person, "region")}", "${col(person, "code")}") VALUES (1, 1, 'a')`); + const insertVisit = `INSERT INTO "${visit.name}" ("${col(visit, "id")}", "${col(visit, "personId")}", "${col(visit, "personRegion")}", "${col(visit, "personCode")}") VALUES (1, 1, 1, 'a')`; + db.exec(insertVisit); + db.exec("PRAGMA foreign_keys = ON"); + expect(() => db.exec(insertVisit.replace("VALUES (1,", "VALUES (2,"))).toThrow(/foreign key mismatch/); + } finally { + db.close(); + } + }); +}); diff --git a/server/typescript/packages/migrate-ts/test/unit/identity-sequence.test.ts b/server/typescript/packages/migrate-ts/test/unit/identity-sequence.test.ts new file mode 100644 index 0000000000..0267d331e9 --- /dev/null +++ b/server/typescript/packages/migrate-ts/test/unit/identity-sequence.test.ts @@ -0,0 +1,158 @@ +/** + * identity.primary @sequenceStart / @sequenceName: the sequence an @generation "increment" + * key draws from. Postgres creates it with GENERATED BY DEFAULT AS IDENTITY + * (SEQUENCE NAME … START WITH …); a live sequence whose declared start or name differs is + * altered in place (ALTER SEQUENCE … START WITH / RENAME TO), never dropped. Only what the + * model DECLARES is compared, so a database whose sequence kept an old name after a table + * rename sees no new change until the model names it. SQLite has no sequences to name. + */ +import { describe, test, expect } from "bun:test"; +import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import { buildExpectedSchema } from "../../src/expected-schema.js"; +import { diff } from "../../src/diff/index.js"; +import { emit } from "../../src/emit/index.js"; +import type { SchemaSnapshot as DatabaseSchema } from "../../src/types.js"; +import { expectedFromShadow } from "../../src/shadow-expected.js"; +import { serializeSnapshot } from "../../src/snapshot/serialize.js"; +import { DuplicateSqlNameError } from "../../src/errors.js"; + +async function schema(pk: Record) { + const doc = { "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Ticket", children: [ + { "source.rdb": { "@table": "Ticket" } }, + { "field.int": { name: "id", "@column": "Id" } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment", ...pk } }, + ] } }, + ] } }; + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(doc))]); + expect(errors).toHaveLength(0); + return buildExpectedSchema(root); +} + +const EMPTY = { tables: [], views: [] } as unknown as DatabaseSchema; +const idColumn = (s: DatabaseSchema) => s.tables.find((t) => t.name === "Ticket")!.columns.find((c) => c.name === "Id")!; + +/** The live side: the same table, its sequence as Postgres reports it (introspection only). */ +function live(expected: DatabaseSchema, sequence: { name: string; start: number }): DatabaseSchema { + const copy = structuredClone(expected); + delete idColumn(copy).sequence; + idColumn(copy).liveSequence = sequence; + return copy; +} + +describe("identity.primary sequence start and name", () => { + test("the expected column carries only what the model declares", async () => { + expect(idColumn(await schema({ "@sequenceStart": 1000000000, "@sequenceName": "Old_Id_seq" })).sequence) + .toEqual({ start: 1000000000, name: "Old_Id_seq" }); + expect(idColumn(await schema({})).sequence).toBeUndefined(); + }); + + test("Postgres CREATE names the sequence and its start", async () => { + const s = await schema({ "@sequenceStart": 1000000000, "@sequenceName": "Old_Id_seq" }); + const { changes } = await diff(s, EMPTY, { dialect: "postgres" }); + expect(emit(changes, { dialect: "postgres" }).up) + .toContain('"Id" INTEGER GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME "Old_Id_seq" START WITH 1000000000)'); + }); + + test("a live sequence with another start and name is altered in place, and the down reverses it", async () => { + const s = await schema({ "@sequenceStart": 1000000000, "@sequenceName": "Old_Id_seq" }); + const { changes } = await diff(s, live(s, { name: "Ticket_Id_seq", start: 1 }), { dialect: "postgres" }); + expect(changes.filter((c) => c.kind === "alter-sequence")).toHaveLength(1); + const { up, down } = emit(changes, { dialect: "postgres" }); + expect(up).toContain('ALTER SEQUENCE "Ticket_Id_seq" START WITH 1000000000;'); + // START WITH alone only records a restart point: the counter is moved up to it, never down. + expect(up).toContain(`SELECT setval('"Ticket_Id_seq"', 1000000000, false) FROM "Ticket_Id_seq" WHERE last_value < 1000000000;`); + expect(up).toContain('ALTER SEQUENCE "Ticket_Id_seq" RENAME TO "Old_Id_seq";'); + expect(down).toContain('ALTER SEQUENCE "Old_Id_seq" RENAME TO "Ticket_Id_seq";'); + expect(down).toContain('ALTER SEQUENCE "Ticket_Id_seq" START WITH 1;'); + expect(up).not.toMatch(/DROP|IDENTITY/); + }); + + test("a live sequence matching the declaration is no change", async () => { + const s = await schema({ "@sequenceStart": 1000000000, "@sequenceName": "Old_Id_seq" }); + const { changes } = await diff(s, live(s, { name: "Old_Id_seq", start: 1000000000 }), { dialect: "postgres" }); + expect(changes.filter((c) => c.kind === "alter-sequence")).toEqual([]); + }); + + test("undeclared start and name are not compared, whatever the live sequence says", async () => { + const s = await schema({}); + const { changes } = await diff(s, live(s, { name: "Renamed_Id_seq", start: 500 }), { dialect: "postgres" }); + expect(changes.filter((c) => c.kind === "alter-sequence")).toEqual([]); + }); + + test("SQLite has no sequence to name: no clause, no change, and an advisory when the key is created", async () => { + const s = await schema({ "@sequenceStart": 1000000000, "@sequenceName": "Old_Id_seq" }); + const { changes, advisories } = await diff(s, EMPTY, { dialect: "sqlite" }); + const { up } = emit(changes, { dialect: "sqlite" }); + expect(up).not.toContain("SEQUENCE"); + expect(up).not.toContain("1000000000"); + expect(advisories).toEqual([{ kind: "sequence-not-applied", table: "Ticket", column: "Id" }]); + // Once the table exists there is nothing to create, so nothing to say on every later run. + expect((await diff(s, s, { dialect: "sqlite" })).advisories).toEqual([]); + // Postgres applies both, so it says nothing. + expect((await diff(s, EMPTY, { dialect: "postgres" })).advisories).toEqual([]); + }); + + test("shadow path: a model that declares nothing gets no sequence clause and no change", async () => { + const model = await schema({}); + // The shadow database's own read of the model's tables: introspection fills liveSequence. + const shadow = live(model, { name: "Ticket_Id_seq", start: 1 }); + const expected = expectedFromShadow(shadow, model); + expect(idColumn(expected).sequence).toBeUndefined(); + // A live sequence left behind by a table rename, with a non-default start: not compared. + const { changes } = await diff(expected, live(model, { name: "OldTicket_Id_seq", start: 1000 }), { dialect: "postgres" }); + expect(changes.filter((c) => c.kind === "alter-sequence")).toEqual([]); + // CREATE from the shadow-read expected side: plain IDENTITY, as before. + const created = await diff(expected, EMPTY, { dialect: "postgres" }); + expect(emit(created.changes, { dialect: "postgres" }).up).not.toContain("SEQUENCE NAME"); + }); + + test("shadow path: what the model declares survives the shadow read", async () => { + const model = await schema({ "@sequenceStart": 1000000000, "@sequenceName": "Old_Id_seq" }); + const expected = expectedFromShadow(live(model, { name: "Old_Id_seq", start: 1000000000 }), model); + expect(idColumn(expected).sequence).toEqual({ start: 1000000000, name: "Old_Id_seq" }); + }); + + test("offline (committed snapshot): a changed declared start is a change, addressed by the default name", async () => { + const before = await schema({ "@sequenceStart": 1000 }); + const after = await schema({ "@sequenceStart": 2000 }); + const { changes } = await diff(after, before, { dialect: "postgres" }); + const alter = changes.filter((c) => c.kind === "alter-sequence"); + expect(alter).toHaveLength(1); + expect(emit(changes, { dialect: "postgres" }).up).toContain('ALTER SEQUENCE "Ticket_Id_seq" START WITH 2000;'); + }); + + test("adding the identity to an existing column carries the declared options", () => { + const { up } = emit([{ + kind: "change-column-identity", table: "Ticket", column: "Id", to: "increment", + sequence: { name: "Old_Id_seq", start: 1000000000 }, status: "allowed", + } as never], { dialect: "postgres" }); + expect(up).toContain('ALTER TABLE "Ticket" ALTER COLUMN "Id" ADD GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME "Old_Id_seq" START WITH 1000000000);'); + }); + + test("a dropped table's down rebuilds it as before: the live sequence is never emitted", async () => { + const s = await schema({}); + const { changes } = await diff(EMPTY, live(s, { name: "OldTicket_Id_seq", start: 1000 }), { dialect: "postgres", allow: { dropTable: true } } as never); + expect(emit(changes, { dialect: "postgres" }).down).not.toContain("SEQUENCE NAME"); + }); + + test("a snapshot never records the live sequence", async () => { + const s = await schema({}); + const text = serializeSnapshot(live(s, { name: "OldTicket_Id_seq", start: 1000 })); + expect(text).not.toContain("OldTicket_Id_seq"); + expect(text).not.toContain("liveSequence"); + }); + + test("two tables declaring one sequence name are refused before any SQL is emitted", async () => { + const doc = { "metadata.root": { package: "acme", children: ["A", "B"].map((n) => ( + { "object.entity": { name: n, children: [ + { "source.rdb": { "@table": n } }, + { "field.int": { name: "id" } }, + { "identity.primary": { name: "pk", "@fields": "id", "@generation": "increment", "@sequenceName": "shared_seq" } }, + ] } })) } }; + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(doc))]); + expect(errors).toHaveLength(0); + expect(() => buildExpectedSchema(root)).toThrow(DuplicateSqlNameError); + expect(() => buildExpectedSchema(root)).toThrow(/shared_seq/); + }); +}); diff --git a/server/typescript/packages/migrate-ts/test/unit/schema-conventions.test.ts b/server/typescript/packages/migrate-ts/test/unit/schema-conventions.test.ts new file mode 100644 index 0000000000..5980574104 --- /dev/null +++ b/server/typescript/packages/migrate-ts/test/unit/schema-conventions.test.ts @@ -0,0 +1,109 @@ +/** + * Schema conventions: the names and physical choices for database objects that no code + * addresses — primary-key and foreign-key constraint names, whether a foreign key gets an + * index (and its name), and whether a unique key is an index or a constraint. + * + * `default` is what migrate has always emitted, so a project that sets nothing sees no + * change. `efcore` reproduces EF Core's own conventions, so a database first built by EF + * migrations and one built by `meta migrate` have the same catalog. + */ +import { describe, test, expect } from "bun:test"; +import { + defaultConventions, efcoreConventions, resolveSchemaConventions, SCHEMA_CONVENTION_PRESETS, +} from "../../src/schema-conventions.js"; + +const fk = { table: "OrderLines", columns: ["OrderId"], refTable: "Orders", refColumns: ["Id"] }; + +describe("efcore conventions", () => { + test("PK_{table}, FK_{table}_{principal}_{columns}, IX_{table}_{columns}", () => { + expect(efcoreConventions.primaryKeyName({ table: "Orders", columns: ["Id"] })).toBe("PK_Orders"); + expect(efcoreConventions.foreignKeyName(fk)).toBe("FK_OrderLines_Orders_OrderId"); + expect(efcoreConventions.foreignKeyName({ ...fk, columns: ["OrderId", "Region"], refColumns: ["Id", "Region"] })) + .toBe("FK_OrderLines_Orders_OrderId_Region"); + expect(efcoreConventions.foreignKeyIndexes({ table: "OrderLines", primaryKey: ["Id"], indexes: [], foreignKeys: [fk] })) + .toEqual([{ name: "IX_OrderLines_OrderId", columns: ["OrderId"] }]); + }); + + test("on Postgres a name over 63 characters is cut to 62 and marked with ~, as EF Core does", () => { + const long = { table: "WarehouseShippingPreferences", columns: ["ShippingPreferenceId"], refTable: "ShippingPreferences", refColumns: ["Id"] }; + const name = efcoreConventions.foreignKeyName(long); + + expect(name).toBe("FK_WarehouseShippingPreferences_ShippingPreferences_ShippingPr~"); + expect(name.length).toBe(63); + expect(efcoreConventions.foreignKeyName({ ...long, dialect: "postgres" })).toBe(name); + expect(efcoreConventions.primaryKeyName({ table: "A".repeat(61), columns: ["Id"] })).toBe(`PK_${"A".repeat(59)}~`); + }); + + test("on SQLite and D1 nothing is truncated: EF Core's SQLite provider has no identifier limit", () => { + const long = { table: "WarehouseShippingPreferences", columns: ["ShippingPreferenceId"], refTable: "ShippingPreferences", refColumns: ["Id"] }; + for (const dialect of ["sqlite", "d1"] as const) { + expect(efcoreConventions.foreignKeyName({ ...long, dialect })) + .toBe("FK_WarehouseShippingPreferences_ShippingPreferences_ShippingPreferenceId"); + expect(efcoreConventions.primaryKeyName({ table: "A".repeat(61), columns: ["Id"], dialect })).toBe(`PK_${"A".repeat(61)}`); + expect(efcoreConventions.foreignKeyIndexes({ table: "A".repeat(61), dialect, primaryKey: ["Id"], indexes: [], + foreignKeys: [{ table: "A".repeat(61), columns: ["BId"], refTable: "B", refColumns: ["Id"], dialect }] })[0]!.name) + .toBe(`IX_${"A".repeat(61)}_BId`); + } + }); + + test("no FK index when the primary key or an existing index already starts with the FK's columns", () => { + const ix = (primaryKey: string[], indexes: { columns: string[]; where?: string; expr?: string }[]) => + efcoreConventions.foreignKeyIndexes({ table: "OrderLines", primaryKey, indexes, foreignKeys: [fk] }).map((i) => i.name); + + expect(ix(["OrderId", "LineNo"], [])).toEqual([]); + expect(ix(["Id"], [{ columns: ["OrderId", "Sku"] }])).toEqual([]); + // an index that merely CONTAINS the columns later does not cover them + expect(ix(["Id"], [{ columns: ["Sku", "OrderId"] }])).toEqual(["IX_OrderLines_OrderId"]); + // a PARTIAL index covers them: EF Core's AreIndexedBy compares properties, and a filter is + // a relational annotation that convention never sees + expect(ix(["Id"], [{ columns: ["OrderId"], where: "x" }])).toEqual([]); + // an expression index has no columns to lead with + expect(ix(["Id"], [{ columns: [], expr: "lower(x)" }])).toEqual(["IX_OrderLines_OrderId"]); + }); + + test("FK indexes do not depend on FK order: one an FK index covers is dropped, as EF Core does", () => { + const a = { table: "hs", columns: ["a"], refTable: "x", refColumns: ["id"] }; + const ab = { table: "hs", columns: ["a", "b"], refTable: "y", refColumns: ["id", "k"] }; + const names = (foreignKeys: typeof a[]) => + efcoreConventions.foreignKeyIndexes({ table: "hs", primaryKey: ["id"], indexes: [], foreignKeys }).map((i) => i.name); + + expect(names([a, ab])).toEqual(["IX_hs_a_b"]); + expect(names([ab, a])).toEqual(["IX_hs_a_b"]); + // two FKs on the same columns get one index + expect(names([a, { ...a, refTable: "z" }])).toEqual(["IX_hs_a"]); + }); + + test("a unique key an FK references is a constraint; any other is an index", () => { + expect(efcoreConventions.uniqueKeyKind({ table: "Orders", columns: ["Code"], referencedByForeignKey: true })).toBe("constraint"); + expect(efcoreConventions.uniqueKeyKind({ table: "Orders", columns: ["Code"], referencedByForeignKey: false })).toBe("index"); + }); +}); + +describe("default conventions (what migrate has always emitted)", () => { + test("{table}_pkey, {table}_{firstColumn}_fk, no FK index, a unique key of either kind", () => { + expect(defaultConventions.primaryKeyName({ table: "orders", columns: ["id"] })).toBe("orders_pkey"); + expect(defaultConventions.foreignKeyName({ ...fk, table: "order_lines", columns: ["order_id", "region"] })).toBe("order_lines_order_id_fk"); + expect(defaultConventions.foreignKeyIndexes({ table: "OrderLines", primaryKey: ["Id"], indexes: [], foreignKeys: [fk] })).toEqual([]); + expect(defaultConventions.uniqueKeyKind({ table: "orders", columns: ["code"], referencedByForeignKey: true })).toBe("either"); + }); +}); + +describe("resolveSchemaConventions", () => { + test("a preset by name, a supplied object as itself, nothing as default", () => { + expect(resolveSchemaConventions("efcore")).toBe(efcoreConventions); + expect(resolveSchemaConventions(undefined)).toBe(defaultConventions); + const custom = { ...defaultConventions, id: "acme" }; + expect(resolveSchemaConventions(custom)).toBe(custom); + }); + + test("an unknown preset is refused, naming the ones that exist", () => { + expect(() => resolveSchemaConventions("hibernate")).toThrow(/migrate\.conventions "hibernate".*default, efcore/); + expect(SCHEMA_CONVENTION_PRESETS).toEqual(["default", "efcore"]); + }); + + test("a supplied object missing a member is refused by name (e.g. written for another version)", () => { + const partial = { id: "acme", primaryKeyName: () => "pk" } as unknown as Parameters[0]; + expect(() => resolveSchemaConventions(partial)) + .toThrow(/schema conventions "acme" are missing foreignKeyName, foreignKeyIndexes, uniqueKeyKind/); + }); +}); From 43f26af6ab620a4b34c82fdd113a8c003dce0119 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 02:47:30 -0500 Subject: [PATCH 03/12] feat(codegen): duration wire, exactlyOne and @condition in every port's generated code - field.duration is mapped natively in every port: TimeSpan, java.time.Duration, timedelta, and a string in TypeScript. - wire.durations=iso makes every port write one ISO-8601 form, and accept it as filter operands. - A malformed duration filter operand now returns 400 invalid_filter_value in Java and Python, where it used to throw. - The generated create models enforce validator.exactlyOne, and check any rule that has an @condition only when the condition is TRUE under SQL three-valued logic. Ports: - Zod superRefine - IValidatableObject - Bean Validation - KotlinCrossFieldRules - Pydantic model_validator - Names artifacts carry a flattened value object's member columns, and no longer carry a column for the field itself. New conformance: - fixtures/duration-wire vectors - an api-contract duration/ sub-corpus, generated lane, in all five ports - a Shift entity in the cross-field validation corpus: 14 cases for exactlyOne and @condition --- .../drizzle-fastify/filter-allowlist.ts | 2 + .../runtime/drizzle-fastify/filter-parser.ts | 16 +- .../drizzle-fastify/filter-value-format.ts | 4 + .../codegen/templates/drizzle-schema.ts | 18 +- .../codegen/templates/duration-wire-helper.ts | 213 ++++++ .../codegen/templates/field-meta.ts | 5 + .../codegen/templates/filter-allowlist.ts | 4 + .../codegen/templates/inferred-types.ts | 3 + .../codegen/templates/projection-decl.ts | 9 +- .../codegen/templates/view-decl.ts | 20 +- .../codegen/templates/zod-validators.ts | 231 ++++++- .../.metaobjects/.gen-state/.hashes.json | 2 +- .../drizzle-fastify/filter-allowlist.ts | 2 + .../runtime/drizzle-fastify/filter-parser.ts | 16 +- .../drizzle-fastify/filter-value-format.ts | 4 + .../codegen/templates/drizzle-schema.ts | 18 +- .../codegen/templates/duration-wire-helper.ts | 213 ++++++ .../showcase/codegen/templates/field-meta.ts | 5 + .../codegen/templates/filter-allowlist.ts | 4 + .../codegen/templates/inferred-types.ts | 3 + .../codegen/templates/projection-decl.ts | 9 +- .../showcase/codegen/templates/view-decl.ts | 20 +- .../codegen/templates/zod-validators.ts | 231 ++++++- .../generated/python/subscriber_router.py | 3 +- .../.claude/skills/metaobjects-audit/SKILL.md | 4 +- .../references/capability-checklist.md | 3 +- .../references/drift-signatures.md | 2 +- .../references/vocabulary-hygiene.md | 4 + .../skills/metaobjects-authoring/SKILL.md | 14 +- .../references/physical-names.md | 28 +- .../references/migration.md | 2 +- .../.claude/skills/metaobjects-audit/SKILL.md | 4 +- .../references/capability-checklist.md | 3 +- .../references/drift-signatures.md | 2 +- .../references/vocabulary-hygiene.md | 4 + .../skills/metaobjects-authoring/SKILL.md | 14 +- .../references/physical-names.md | 28 +- .../references/migration.md | 2 +- .../.claude/skills/metaobjects-audit/SKILL.md | 4 +- .../references/capability-checklist.md | 3 +- .../references/drift-signatures.md | 2 +- .../references/vocabulary-hygiene.md | 4 + .../skills/metaobjects-authoring/SKILL.md | 14 +- .../references/physical-names.md | 28 +- .../references/migration.md | 2 +- .../.claude/skills/metaobjects-audit/SKILL.md | 4 +- .../references/capability-checklist.md | 3 +- .../references/drift-signatures.md | 2 +- .../references/vocabulary-hygiene.md | 4 + .../skills/metaobjects-authoring/SKILL.md | 14 +- .../references/physical-names.md | 28 +- .../references/migration.md | 2 +- .../.claude/skills/metaobjects-audit/SKILL.md | 4 +- .../references/capability-checklist.md | 3 +- .../references/drift-signatures.md | 2 +- .../references/vocabulary-hygiene.md | 4 + .../skills/metaobjects-authoring/SKILL.md | 14 +- .../references/physical-names.md | 28 +- .../references/migration.md | 2 +- .../.metaobjects/config.json | 2 +- fixtures/api-contract-conformance/README.md | 5 +- .../duration/README.md | 46 ++ .../duration/meta.json | 12 + .../scenarios/duration-filter-sort.yaml | 80 +++ .../scenarios/duration-write-read.yaml | 132 ++++ .../duration/schema.mysql.sql | 9 + .../duration/schema.postgres.sql | 10 + .../duration/schema.sqlite.sql | 9 + .../duration/seed.json | 1 + .../all-fields-wire-string-emit/case.json | 2 +- .../expected/openapi.json | 106 ++- .../contract/AllFields.create.schema.json | 5 + .../acme/contract/AllFields.schema.json | 9 + .../contract/AllFields.strict.schema.json | 8 + .../contract/AllFields.update.schema.json | 8 + .../all-fields-wire-string-omit/case.json | 2 +- .../expected/openapi.json | 106 ++- .../contract/AllFields.create.schema.json | 5 + .../acme/contract/AllFields.schema.json | 9 + .../contract/AllFields.strict.schema.json | 8 + .../contract/AllFields.update.schema.json | 8 + .../all-fields/expected/openapi.json | 103 ++- .../contract/AllFields.create.schema.json | 4 + .../acme/contract/AllFields.schema.json | 8 + .../contract/AllFields.strict.schema.json | 8 + .../contract/AllFields.update.schema.json | 7 + .../contract-conformance/all-fields/meta.json | 1 + fixtures/contract-conformance/blog/case.json | 2 +- .../contract-conformance/decimal/case.json | 2 +- fixtures/contract-conformance/jsonb/case.json | 2 +- fixtures/contract-conformance/m2m/case.json | 2 +- .../contract-conformance/projection/case.json | 2 +- .../contract-conformance/report/case.json | 2 +- .../secret-fields/case.json | 2 +- fixtures/contract-conformance/tph/case.json | 2 +- .../write-through/case.json | 2 +- fixtures/duration-wire/README.md | 44 ++ fixtures/duration-wire/vectors.json | 105 +++ .../.metaobjects/config.json | 4 + fixtures/validation-conformance/README.md | 33 +- fixtures/validation-conformance/cases.json | 14 +- .../cross-field-cases.json | 238 +++++++ .../meta.cross-field.json | 42 ++ fixtures/validation-conformance/meta.json | 1 + .../runtime-errors.json | 54 ++ fixtures/wire-conformance/README.md | 11 +- fixtures/wire-conformance/config-blocks.json | 19 +- .../WireSettingCliTests.cs | 6 +- .../CrossFieldValidationConformanceTests.cs | 8 +- .../DurationWireTests.cs | 383 +++++++++++ .../EnumLengthCliTests.cs | 60 ++ .../EnumLengthCodegenTests.cs | 55 ++ .../EnumLengthConformanceTests.cs | 101 +++ .../NamesGeneratorTests.cs | 93 +++ .../NamingConformanceTests.cs | 48 +- .../ObjectFieldCodegenTests.cs | 69 ++ .../ReportExportGeneratorTests.cs | 50 +- .../ValidationConformanceTests.cs | 4 +- .../WireConformanceTests.cs | 3 +- .../MetaObjects.Codegen/CSharpNaming.cs | 48 +- .../csharp/MetaObjects.Codegen/CodegenCli.cs | 28 +- .../MetaObjects.Codegen/CodegenDrift.cs | 2 +- .../Generators/DbContextGenerator.cs | 274 +++++++- .../Generators/EntityGenerator.cs | 618 +++++++++++++++++- .../Generators/ExtractorGenerator.cs | 17 +- .../Generators/NamesGenerator.cs | 93 ++- .../Generators/ReportExportGenerator.cs | 53 +- .../Generators/RoutesGenerator.cs | 72 +- .../MetaObjects.Codegen/HelperRuntime.cs | 4 +- .../MetaObjects.Codegen.csproj | 1 + .../Runtime/DurationWire.cs | 258 ++++++++ .../Runtime/EfCoreFilterDispatch.cs | 10 +- .../Api/ApiContractCorpusPaths.cs | 9 + .../Api/ApiContractDecimalConformanceTest.cs | 61 +- .../Api/ApiContractDurationConformanceTest.cs | 61 ++ .../Api/DurationWireGeneratedApiTests.cs | 230 +++++++ .../Api/GeneratedCorpusRunner.cs | 65 ++ .../DurationIntervalEfCoreTests.cs | 332 ++++++++++ .../Generated/AllTypes.g.cs | 3 + .../Generated/AllTypesNames.g.cs | 3 + .../Generated/AppDbContext.g.cs | 130 ++++ .../Generated/DurationWire.g.cs | 258 ++++++++ .../MetaObjects.IntegrationTests.csproj | 9 + .../Runner/EntityRow.cs | 2 +- .../Runner/Normalization.cs | 3 + .../Runner/WriteCoercion.cs | 6 + .../exposed1x/Exposed1xRoundTripTest.kt | 19 + server/java/codegen-kotlin/pom.xml | 9 + .../generator/kotlin/GeneratorRegistry.kt | 9 +- .../generator/kotlin/KotlinCrossFieldRules.kt | 174 ++++- .../kotlin/KotlinDurationWireEmitter.kt | 203 ++++++ .../generator/kotlin/KotlinEntityGenerator.kt | 33 +- .../kotlin/KotlinExposedTableGenerator.kt | 175 ++++- .../generator/kotlin/KotlinGenUtil.kt | 33 +- .../generator/kotlin/KotlinNamesGenerator.kt | 56 +- .../generator/kotlin/KotlinNaming.kt | 4 + .../kotlin/KotlinReportExportGenerator.kt | 44 +- .../kotlin/KotlinSpringControllerGenerator.kt | 38 +- .../kotlin/KotlinStoredProcGenerator.kt | 4 +- .../generator/kotlin/KotlinTypeMapper.kt | 47 +- .../kotlin/KotlinCodegenSnapshotTest.kt | 2 +- .../generator/kotlin/KotlinDecimalWireTest.kt | 6 +- .../kotlin/KotlinDurationWireTest.kt | 242 +++++++ .../KotlinExposedTableCrossPackageEnumTest.kt | 10 +- .../kotlin/KotlinExposedTableGeneratorTest.kt | 145 +++- .../KotlinExposedTableIntBackedEnumTest.kt | 8 +- .../kotlin/KotlinExposedTableNamesTest.kt | 12 +- .../KotlinM2mTraversalCompileRunTest.kt | 7 +- .../kotlin/KotlinNamesGeneratorTest.kt | 112 ++++ .../KotlinProjectionExtendsInheritanceTest.kt | 4 +- .../kotlin/KotlinProjectionTwoHopEnumTest.kt | 8 +- .../kotlin/KotlinReportExportGeneratorTest.kt | 63 +- .../kotlin/KotlinReportRestSurfaceTest.kt | 2 +- .../kotlin/KotlinReportTableGeneratorTest.kt | 6 +- .../generator/kotlin/KotlinTypeMapperTest.kt | 25 +- .../kotlin/ValidationConformanceTest.kt | 8 +- .../entity-with-enum/acme/game/PlayerTable.kt | 2 +- .../entity-with-tph/acme/auth/AuthTable.kt | 2 +- .../HelperRegistryPhysicalNamingTest.java | 5 + server/java/codegen-spring/pom.xml | 3 + .../generator/GeneratorRegistry.java | 10 +- .../spring/SpringControllerGenerator.java | 10 +- .../generator/spring/SpringDtoGenerator.java | 70 +- .../SpringFilterAllowlistGenerator.java | 28 + .../spring/SpringGeneratorSupport.java | 237 ++++++- .../spring/SpringNamesGenerator.java | 132 +++- .../spring/SpringOutputParserGenerator.java | 11 + .../spring/SpringReportExportGenerator.java | 61 +- .../generator/spring/SpringTypeMapper.java | 5 + .../spring/SpringValueObjectGenerator.java | 10 + .../spring/runtime/CanonicalDuration.java | 108 +++ .../CanonicalDurationDeserializer.java | 50 ++ .../runtime/CanonicalDurationSerializer.java | 29 + .../spring/runtime/FilterParser.java | 36 +- .../GeneratedReportExportCompileRunTest.java | 2 +- .../GeneratedScalarExtractLockStepTest.java | 2 +- .../SpringDecimalWireCompileRunTest.java | 2 +- .../SpringDurationWireCompileRunTest.java | 221 +++++++ .../spring/SpringNamesGeneratorTest.java | 168 ++++- .../SpringReportExportDurationCellTest.java | 26 + .../spring/SpringTypeMapperTest.java | 16 + .../SpringWireFormatCompileRunTest.java | 4 +- .../spring/ValidationConformanceTest.java | 7 +- .../spring/runtime/DurationWireTest.java | 123 ++++ .../KotlinCodegenMatchesReferenceTest.kt | 12 +- .../integration/kotlin/Normalization.kt | 2 + .../kotlin/QueryScenarioConformanceTest.kt | 11 + .../integration/kotlin/QueryScenarioRunner.kt | 6 + ...tionGeneratedApiContractConformanceTest.kt | 114 ++++ .../api/tph/TphFullSuiteCompilesTest.kt | 3 +- .../kotlin/tables/AllTypesTable.kt | 5 + .../kotlin/tables/MetaDurationColumnType.kt | 42 ++ .../GeneratedDurationRoundTripTest.kt | 135 ++++ .../integration/QueryScenarioTests.java | 13 +- .../integration/RoundtripWriter.java | 6 + .../integration/api/DurationCorpus.java | 29 + .../api/DurationOmdbApiContractLane.java | 165 +++++ ...onOmdbMySqlApiContractConformanceTest.java | 24 + ...mdbPostgresApiContractConformanceTest.java | 24 + ...nOmdbSqliteApiContractConformanceTest.java | 24 + .../GeneratedDurationControllerHarness.java | 184 ++++++ .../generated/OmdbTimerRepositorySource.java | 41 ++ .../mojo/AbstractMetaDataMojo.java | 4 + .../metaobjects/mojo/MetaDataVerifyMojo.java | 15 +- .../mojo/EjectRuntimeRoundTripTest.java | 9 +- .../metaobjects/mojo/EjectSupportTest.java | 3 +- .../mojo/MetaDataVerifyWireTest.java | 4 +- .../manager/db/codec/JdbcCodecs.java | 58 ++ .../manager/db/driver/PostgresDriver.java | 19 + .../db/codec/JdbcCodecRoundTripTest.java | 64 ++ server/python/src/metaobjects/cli.py | 4 +- .../metaobjects/codegen/generator_registry.py | 4 +- .../codegen/generators/entity_model.py | 312 ++++++++- .../generators/filter_allowlist_generator.py | 3 + .../codegen/generators/names_generator.py | 106 ++- .../codegen/generators/router_generator.py | 73 ++- .../codegen/runtime/decimal_wire.py | 55 +- .../codegen/runtime/duration_wire.py | 243 +++++++ .../codegen/runtime/fr010_field_mapping.py | 2 + .../codegen/runtime/shared_predicates.py | 11 + .../src/metaobjects/codegen/type_map.py | 5 + .../python/src/metaobjects/runtime/dialect.py | 26 +- .../src/metaobjects/runtime/object_manager.py | 211 +++++- .../metaobjects/runtime/validator_runner.py | 7 + server/python/src/metaobjects/wire.py | 16 +- .../tests/codegen/test_config_run_settings.py | 6 +- .../tests/codegen/test_duration_codegen.py | 222 +++++++ .../codegen/test_enum_max_length_model.py | 31 + .../python/tests/codegen/test_m2m_codegen.py | 2 +- .../tests/codegen/test_names_flattened.py | 100 +++ .../codegen/test_validation_conformance.py | 69 +- .../python/tests/integration/normalization.py | 9 +- .../python/tests/integration/report_lane.py | 11 +- .../integration/test_api_contract_duration.py | 93 +++ .../integration/test_duration_postgres.py | 93 +++ .../tests/runtime/test_duration_wire.py | 144 ++++ .../runtime/test_object_manager_duration.py | 252 +++++++ .../tests/runtime/test_validator_runner.py | 27 + .../packages/codegen-ts/src/column-mapper.ts | 59 +- .../codegen-ts/src/contract/field-schema.ts | 35 + .../codegen-ts/src/cube/cube-members.ts | 12 +- .../packages/codegen-ts/src/generator.ts | 8 + .../src/generators/prisma-schema-file.ts | 1 + .../codegen-ts/src/metaobjects-config.ts | 8 + .../packages/codegen-ts/src/names.ts | 57 +- .../src/prisma/build-prisma-schema.ts | 32 +- .../codegen-ts/src/reference/prisma-schema.ts | 1 + .../packages/codegen-ts/src/runner.ts | 4 + .../src/templates/drizzle-schema.ts | 18 +- .../src/templates/duration-wire-helper.ts | 213 ++++++ .../src/templates/entity-ui-descriptor.ts | 6 +- .../codegen-ts/src/templates/field-meta.ts | 5 + .../src/templates/filter-allowlist.ts | 4 + .../src/templates/fr010-field-mapping.ts | 3 + .../src/templates/inferred-types.ts | 3 + .../codegen-ts/src/templates/names-decl.ts | 8 +- .../codegen-ts/src/templates/output-parser.ts | 3 + .../src/templates/projection-decl.ts | 9 +- .../codegen-ts/src/templates/view-decl.ts | 20 +- .../src/templates/zod-validators.ts | 231 ++++++- .../codegen-ts/test/column-mapper.test.ts | 31 +- .../test/contract/contract-behaviours.test.ts | 19 +- .../duration-json-schema-pattern.test.ts | 51 ++ .../test/cube/build-cube-model.test.ts | 9 + .../test/drizzle-duration-column.test.ts | 184 ++++++ .../test/drizzle-enum-max-length.test.ts | 57 ++ .../test/duration-wire-helper.test.ts | 170 +++++ .../test/enum-max-length-ui.test.ts | 26 + .../codegen-ts/test/names-flattened.test.ts | 209 ++++++ .../output-parser-subtype-coverage.test.ts | 7 +- .../test/prisma/prisma-schema.test.ts | 67 ++ .../codegen-ts/test/shared-model-file.test.ts | 2 +- .../test/templates/filter-allowlist.test.ts | 17 + .../test/templates/zod-validators.test.ts | 18 +- .../codegen-ts/test/wire-format-e2e.test.ts | 8 +- .../codegen-ts/test/zod-cross-field.test.ts | 42 ++ .../test/zod-validators-execution.test.ts | 31 +- .../docs-site/src/builders/object-data.ts | 32 +- .../packages/integration-tests/package.json | 1 + .../src/api-contract-decimal-server.ts | 74 ++- .../src/canonical-schema-mysql.ts | 1 + .../src/gen-api-contract-duration-schema.ts | 56 ++ .../packages/integration-tests/src/paths.ts | 8 + .../test/api-contract-duration.test.ts | 71 ++ ...validation-conformance-cross-field.test.ts | 9 +- .../src/drivers/in-memory-driver.ts | 34 +- .../src/drizzle-fastify/filter-allowlist.ts | 2 + .../src/drizzle-fastify/filter-parser.ts | 16 +- .../drizzle-fastify/filter-value-format.ts | 4 + .../packages/runtime-ts/src/duration-wire.ts | 7 + .../packages/runtime-ts/src/n2m-resolver.ts | 13 +- .../packages/runtime-ts/src/object-manager.ts | 24 +- .../packages/runtime-ts/src/query-builder.ts | 38 +- .../runtime-ts/src/relation-resolver.ts | 8 +- .../packages/runtime-ts/src/type-coercer.ts | 89 ++- .../runtime-ts/src/validator-runner.ts | 16 +- .../filter-duration-range.test.ts | 50 ++ .../test/duration-wire-vectors.test.ts | 111 ++++ .../test/object-manager-duration.test.ts | 134 ++++ .../src/templates/drizzle-schema.ts | 18 +- .../src/templates/duration-wire-helper.ts | 213 ++++++ .../src/templates/field-meta.ts | 5 + .../src/templates/filter-allowlist.ts | 4 + .../src/templates/inferred-types.ts | 3 + .../src/templates/projection-decl.ts | 9 +- .../src/templates/view-decl.ts | 20 +- .../src/templates/zod-validators.ts | 231 ++++++- 327 files changed, 14433 insertions(+), 763 deletions(-) create mode 100644 examples/advanced-modeling/codegen/templates/duration-wire-helper.ts create mode 100644 examples/showcase/codegen/templates/duration-wire-helper.ts create mode 100644 fixtures/api-contract-conformance/duration/README.md create mode 100644 fixtures/api-contract-conformance/duration/meta.json create mode 100644 fixtures/api-contract-conformance/duration/scenarios/duration-filter-sort.yaml create mode 100644 fixtures/api-contract-conformance/duration/scenarios/duration-write-read.yaml create mode 100644 fixtures/api-contract-conformance/duration/schema.mysql.sql create mode 100644 fixtures/api-contract-conformance/duration/schema.postgres.sql create mode 100644 fixtures/api-contract-conformance/duration/schema.sqlite.sql create mode 100644 fixtures/api-contract-conformance/duration/seed.json create mode 100644 fixtures/duration-wire/README.md create mode 100644 fixtures/duration-wire/vectors.json create mode 100644 fixtures/validation-conformance/.metaobjects/config.json create mode 100644 server/csharp/MetaObjects.Codegen.Tests/DurationWireTests.cs create mode 100644 server/csharp/MetaObjects.Codegen.Tests/EnumLengthCliTests.cs create mode 100644 server/csharp/MetaObjects.Codegen.Tests/EnumLengthCodegenTests.cs create mode 100644 server/csharp/MetaObjects.Codegen.Tests/EnumLengthConformanceTests.cs create mode 100644 server/csharp/MetaObjects.Codegen/Runtime/DurationWire.cs create mode 100644 server/csharp/MetaObjects.IntegrationTests/Api/ApiContractDurationConformanceTest.cs create mode 100644 server/csharp/MetaObjects.IntegrationTests/Api/DurationWireGeneratedApiTests.cs create mode 100644 server/csharp/MetaObjects.IntegrationTests/Api/GeneratedCorpusRunner.cs create mode 100644 server/csharp/MetaObjects.IntegrationTests/DurationIntervalEfCoreTests.cs create mode 100644 server/csharp/MetaObjects.IntegrationTests/Generated/DurationWire.g.cs create mode 100644 server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinDurationWireEmitter.kt create mode 100644 server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinDurationWireTest.kt create mode 100644 server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/runtime/CanonicalDuration.java create mode 100644 server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/runtime/CanonicalDurationDeserializer.java create mode 100644 server/java/codegen-spring/src/main/java/com/metaobjects/generator/spring/runtime/CanonicalDurationSerializer.java create mode 100644 server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringDurationWireCompileRunTest.java create mode 100644 server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/SpringReportExportDurationCellTest.java create mode 100644 server/java/codegen-spring/src/test/java/com/metaobjects/generator/spring/runtime/DurationWireTest.java create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/api/duration/DurationGeneratedApiContractConformanceTest.kt create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/MetaDurationColumnType.kt create mode 100644 server/java/integration-tests-kotlin/src/test/kotlin/com/metaobjects/integration/kotlin/tables/duration/GeneratedDurationRoundTripTest.kt create mode 100644 server/java/integration-tests/src/test/java/com/metaobjects/integration/api/DurationCorpus.java create mode 100644 server/java/integration-tests/src/test/java/com/metaobjects/integration/api/DurationOmdbApiContractLane.java create mode 100644 server/java/integration-tests/src/test/java/com/metaobjects/integration/api/DurationOmdbMySqlApiContractConformanceTest.java create mode 100644 server/java/integration-tests/src/test/java/com/metaobjects/integration/api/DurationOmdbPostgresApiContractConformanceTest.java create mode 100644 server/java/integration-tests/src/test/java/com/metaobjects/integration/api/DurationOmdbSqliteApiContractConformanceTest.java create mode 100644 server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/GeneratedDurationControllerHarness.java create mode 100644 server/java/integration-tests/src/test/java/com/metaobjects/integration/api/generated/OmdbTimerRepositorySource.java create mode 100644 server/python/src/metaobjects/codegen/runtime/duration_wire.py create mode 100644 server/python/tests/codegen/test_duration_codegen.py create mode 100644 server/python/tests/codegen/test_enum_max_length_model.py create mode 100644 server/python/tests/codegen/test_names_flattened.py create mode 100644 server/python/tests/integration/test_api_contract_duration.py create mode 100644 server/python/tests/integration/test_duration_postgres.py create mode 100644 server/python/tests/runtime/test_duration_wire.py create mode 100644 server/python/tests/runtime/test_object_manager_duration.py create mode 100644 server/typescript/packages/codegen-ts/src/templates/duration-wire-helper.ts create mode 100644 server/typescript/packages/codegen-ts/test/contract/duration-json-schema-pattern.test.ts create mode 100644 server/typescript/packages/codegen-ts/test/drizzle-duration-column.test.ts create mode 100644 server/typescript/packages/codegen-ts/test/drizzle-enum-max-length.test.ts create mode 100644 server/typescript/packages/codegen-ts/test/duration-wire-helper.test.ts create mode 100644 server/typescript/packages/codegen-ts/test/enum-max-length-ui.test.ts create mode 100644 server/typescript/packages/codegen-ts/test/names-flattened.test.ts create mode 100644 server/typescript/packages/integration-tests/src/gen-api-contract-duration-schema.ts create mode 100644 server/typescript/packages/integration-tests/test/api-contract-duration.test.ts create mode 100644 server/typescript/packages/runtime-ts/src/duration-wire.ts create mode 100644 server/typescript/packages/runtime-ts/test/drizzle-fastify/filter-duration-range.test.ts create mode 100644 server/typescript/packages/runtime-ts/test/duration-wire-vectors.test.ts create mode 100644 server/typescript/packages/runtime-ts/test/object-manager-duration.test.ts create mode 100644 server/typescript/packages/test-generators/src/templates/duration-wire-helper.ts diff --git a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-allowlist.ts b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-allowlist.ts index 386b10e1e4..e349d9cf03 100644 --- a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-allowlist.ts +++ b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-allowlist.ts @@ -15,6 +15,7 @@ import { FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_UUID, + FIELD_SUBTYPE_DURATION, type FilterOp, } from "@metaobjectsdev/metadata"; export { FILTER_OPS, OPS_BY_SUBTYPE, opsForSubType, type FilterOp }; @@ -33,6 +34,7 @@ export const FILTER_VALUE_FORMATS = [ FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_UUID, + FIELD_SUBTYPE_DURATION, ] as const; export type FilterValueFormat = (typeof FILTER_VALUE_FORMATS)[number]; diff --git a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-parser.ts b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-parser.ts index c4b3066f5d..644e9a9616 100644 --- a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-parser.ts +++ b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-parser.ts @@ -15,8 +15,11 @@ import { ANY_TEMPORAL_EXPECTED, FORMAT_EXPECTED, PLUS_OFFSET_HINT, looksLikeUnencodedPlusOffset, matchesAnyTemporal, matchesFormat, utcIsoIfZoned, utcIsoInstant, } from "./filter-value-format.js"; -import { FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_TIMESTAMP } from "@metaobjectsdev/metadata"; +import { FIELD_SUBTYPE_DECIMAL, FIELD_SUBTYPE_DURATION, FIELD_SUBTYPE_TIMESTAMP } from "@metaobjectsdev/metadata"; import { canonicalDecimal } from "../decimal-wire.js"; +import { + DURATION_MICROS_MAX, DURATION_MICROS_MIN, durationToStoredMicros, formatPostgresInterval, parseDurationLiteral, +} from "../duration-wire.js"; // biome-ignore lint/suspicious/noExplicitAny: dynamic dispatch over user's Drizzle table type AnyTable = any; @@ -344,6 +347,16 @@ function coerce(value: unknown, rule: FilterFieldRule, col: unknown, field: stri if (dialect === "mysql") return sql`CAST(${canonical} AS DECIMAL(65,30))`; return dialect === "sqlite" ? Number(canonical) : canonical; } + // A duration bound is spelled the way the column stores it: the microsecond integer on SQLite / + // D1 / MySQL, the interval spelling on Postgres (which refuses a signed ISO-8601 duration). Both + // compare and order as the span does. + if (rule.format === FIELD_SUBTYPE_DURATION) { + const micros = parseDurationLiteral(s) as bigint; + // A well-formed span past 64 bits of microseconds is no stored value on any database, so it is + // a bad operand (400), not an error out of the binder. + if (micros > DURATION_MICROS_MAX || micros < DURATION_MICROS_MIN) throw invalidValue(field, op, s, DURATION_IN_RANGE); + return dialect === "sqlite" || dialect === "mysql" ? durationToStoredMicros(micros) : formatPostgresInterval(micros); + } // A timestamp bound is compared in the same UTC spelling the generated schemas store: // on SQLite/D1 the column is TEXT and compares as text. An instant field (`instant`, i.e. // not @localTime) reads a zoneless or date-only bound as UTC, as its write schema does — @@ -364,6 +377,7 @@ function coerce(value: unknown, rule: FilterFieldRule, col: unknown, field: stri /** `expected` for a non-whole bound on an integer-valued field (`integer: true`). */ const INTEGER_EXPECTED = "integer (a whole number)"; +const DURATION_IN_RANGE = "duration within the 64-bit microsecond range"; function coerceAs(value: unknown, subType: string, field: string, op: string, dateValues?: boolean): unknown { if (value === null || value === undefined) return null; diff --git a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-value-format.ts b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-value-format.ts index 5def0974f6..cf8a3f1097 100644 --- a/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-value-format.ts +++ b/examples/advanced-modeling/codegen/runtime/drizzle-fastify/filter-value-format.ts @@ -17,9 +17,11 @@ import { FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_UUID, + FIELD_SUBTYPE_DURATION, } from "@metaobjectsdev/metadata"; import type { FilterValueFormat } from "./filter-allowlist.js"; import { canonicalDecimal } from "../decimal-wire.js"; +import { parseDurationLiteral } from "../duration-wire.js"; const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/; const TIME_RE = /^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,9})?)?$/; @@ -48,6 +50,7 @@ const CHECKS: Readonly boolean>> = { [FIELD_SUBTYPE_TIMESTAMP]: isTimestamp, [FIELD_SUBTYPE_UUID]: (s) => UUID_RE.test(s), [FIELD_SUBTYPE_DECIMAL]: (s) => canonicalDecimal(s) !== undefined, + [FIELD_SUBTYPE_DURATION]: (s) => parseDurationLiteral(s) !== undefined, }; /** The `expected` member of the `invalid_filter_value` envelope, per format. */ @@ -57,6 +60,7 @@ export const FORMAT_EXPECTED: Readonly> = { [FIELD_SUBTYPE_TIMESTAMP]: "timestamp (YYYY-MM-DD[THH:MM[:SS[.fff]]][Z|±HH:MM])", [FIELD_SUBTYPE_UUID]: "uuid (8-4-4-4-12 hex)", [FIELD_SUBTYPE_DECIMAL]: "decimal number (digits, optional fraction, optional exponent)", + [FIELD_SUBTYPE_DURATION]: "duration (ISO 8601, e.g. PT1H30M; no years or months)", }; /** `expected` for a temporal rule that does not say WHICH temporal format it is. */ diff --git a/examples/advanced-modeling/codegen/templates/drizzle-schema.ts b/examples/advanced-modeling/codegen/templates/drizzle-schema.ts index 93e85d56a0..74df06cb0d 100644 --- a/examples/advanced-modeling/codegen/templates/drizzle-schema.ts +++ b/examples/advanced-modeling/codegen/templates/drizzle-schema.ts @@ -18,7 +18,7 @@ import { import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { crossEntitySpecifier, valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts/engine"; import { - mapColumnType, PG_IDENTITY_CAPABLE_FNS, + mapColumnType, PG_IDENTITY_CAPABLE_FNS, DURATION_COLUMN_FN, type ColumnSpec, type EnumIntCustomType, } from "@metaobjectsdev/codegen-ts/engine"; import { tableNameFromEntity } from "@metaobjectsdev/codegen-ts/engine"; @@ -28,6 +28,7 @@ import { } from "@metaobjectsdev/codegen-ts/engine"; import { resolveTableSchema } from "@metaobjectsdev/metadata"; import { renderEnumIntCustomType } from "./enum-int-codec.js"; +import { renderDurationCustomType } from "./duration-wire-helper.js"; import { renderRelationsBlock } from "./relations-block.js"; import { renderDocsFor } from "./jsdoc.js"; import { collectTphSubtypeFields, tphConcreteSubtypes } from "./tph-discriminator.js"; @@ -46,7 +47,8 @@ export function renderDrizzleSchema( obj: MetaObject, ctx: RenderContext, /** - * OUT param: the int-backed-enum codec const names this table DECLARES. A caller that + * OUT param: the local column-codec const names this table DECLARES (int-backed enums, and + * the duration codec). A caller that * emits another artifact referencing the same codecs into the SAME module — entity-file's * write-through replica view — passes a set here and hands it to that emitter, which then * references the consts instead of re-declaring them. A duplicate module-scope `const` is @@ -211,6 +213,8 @@ export function renderDrizzleSchema( // Int-backed field.enum customType helpers, emitted ahead of the table. Keyed by // const name so a shared enum used by two fields of the SAME entity emits once. const enumIntTypes = new Map(); + // The field.duration codec: one declaration serves every duration column of the table. + let usesDurationCodec = false; for (const child of obj.fields()) { // #213 — a derived (origin-bearing) field is read-only, materialized on the // read (view) side, NOT a column on the entity's write table (FR-024 §7). @@ -225,6 +229,7 @@ export function renderDrizzleSchema( if (spec.enumIntCustomType !== undefined) { enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType); } + if (spec.durationCustomType !== undefined) usesDurationCodec = true; const fieldDocs = renderDocsFor(child); const columnLine = renderColumn(spec, columnNameExpr(child, spec.dbName), child, ctx, isPk, pkGeneration, fkInfo, isComposite, pkg, obj.name); columnLines.push(fieldDocs ? code` ${fieldDocs}\n${columnLine}` : columnLine); @@ -244,6 +249,7 @@ export function renderDrizzleSchema( if (spec.enumIntCustomType !== undefined) { enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType); } + if (spec.durationCustomType !== undefined) usesDurationCodec = true; const fieldDocs = renderDocsFor(child); const columnLine = renderColumn( spec, columnNameExpr(child, spec.dbName), child, ctx, false, undefined, fkMap.get(child.name), isComposite, pkg, obj.name, true, @@ -403,9 +409,11 @@ ${joinCode(columnLines, { on: ",\n", trim: false })} .map((t) => renderEnumIntCustomType(t, importModule)); if (declaredEnumIntCodecs !== undefined) { for (const name of enumIntTypes.keys()) declaredEnumIntCodecs.add(name); + if (usesDurationCodec) declaredEnumIntCodecs.add(DURATION_COLUMN_FN); } - const blocks: Code[] = [...enumIntBlocks, tableBlock]; + const durationBlocks = usesDurationCodec ? [renderDurationCustomType(dialect, importModule)] : []; + const blocks: Code[] = [...durationBlocks, ...enumIntBlocks, tableBlock]; if (relationsBlock !== null) blocks.push(relationsBlock); return blocks.length === 1 ? blocks[0]! : joinCode(blocks, { on: "\n" }); } @@ -518,7 +526,9 @@ function renderColumn( const fnSym = spec.enumIntCustomType !== undefined ? spec.enumIntCustomType.fnConstName - : imp(`${spec.fnName}@${spec.importModule}`); + : spec.durationCustomType !== undefined + ? spec.durationCustomType.fnConstName + : imp(`${spec.fnName}@${spec.importModule}`); let baseCall: Code; if (spec.fnOptions !== undefined && Object.keys(spec.fnOptions).length > 0) { diff --git a/examples/advanced-modeling/codegen/templates/duration-wire-helper.ts b/examples/advanced-modeling/codegen/templates/duration-wire-helper.ts new file mode 100644 index 0000000000..81aeaac06e --- /dev/null +++ b/examples/advanced-modeling/codegen/templates/duration-wire-helper.ts @@ -0,0 +1,213 @@ +// The wire rule for a field.duration, as text a generated model module carries as local code. +// Generated model code imports nothing from MetaObjects, so the rule is emitted, not imported; +// metadata/src/core/field/duration-wire.ts holds the same rule and both are held to +// fixtures/duration-wire/vectors.json (test/duration-wire-helper.test.ts evaluates the text +// emitted here against them). +// +// Accepted: optional weeks (exactly 7 days) and days, then hours, minutes and seconds; the seconds +// fraction may have any number of digits. Canonical: `[-]PT[H][M][[.]S]` — hours never +// folded into days, zero components left out, zero is `PT0S`, one leading `-`. A span is counted in +// microseconds, so the fraction keeps six digits and the rest is truncated toward zero. +// +// The emitted code counts a span in microseconds as a `bigint`, so it has no limit of its own: a +// span is as long as the stack it is stored in allows. The only limit here is a column's — the +// 64-bit integer a SQLite, D1 or MySQL column holds (refused past it, never wrapped). Postgres's +// interval has its own range, which Postgres enforces. The emitted code calls `BigInt(...)` and +// writes no bigint literal, so it needs the ES2020 `BigInt` global and nothing newer. + +import { code, imp, type Code } from "@metaobjectsdev/codegen-ts/engine"; +import { DURATION_PATTERN } from "@metaobjectsdev/metadata"; +import type { Dialect } from "@metaobjectsdev/codegen-ts/engine"; +import { DURATION_COLUMN_FN } from "@metaobjectsdev/codegen-ts/engine"; + +/** The accepted ISO-8601 duration form as a JS regex literal — the metadata's own pattern. */ +export const DURATION_REGEX_LITERAL = `/${DURATION_PATTERN}/`; + +/** The Zod expression that accepts a field.duration as sent (and as read back): the accepted form. */ +export const ZOD_DURATION_EXPR = `z.string().regex(${DURATION_REGEX_LITERAL})`; + +export const DURATION_WRITE_HELPER = "durationWrite"; + +/** The Zod expression of a duration WRITE field: the accepted form, rewritten to the canonical one, + * so equal spans are equal strings on the wire and in a jsonb value object. */ +export const ZOD_DURATION_WRITE_EXPR = `${ZOD_DURATION_EXPR}.transform(${DURATION_WRITE_HELPER})`; + +/** The accepted form with its components captured: sign, weeks, days, hours, minutes, seconds, + * fraction. Equivalent to DURATION_PATTERN (the same lookaheads refuse `P`, `PT` and `P1DT`). */ +const ISO_PARTS_SOURCE = String.raw`/^([-+])?P(?!$)(?:(\d+)W)?(?:(\d+)D)?(?:T(?=\d)(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)(?:\.(\d+))?S)?)?$/`; + +/** The default (`IntervalStyle = postgres`) text of a Postgres interval: signed year / mon / day + * parts, then an optionally signed `H:MM:SS[.ffffff]`. */ +const PG_INTERVAL_SOURCE = String.raw`/^(?:([-+]?\d+) years? ?)?(?:([-+]?\d+) mons? ?)?(?:([-+]?\d+) days? ?)?(?:([-+])?(\d+):(\d{2}):(\d{2})(?:\.(\d{1,6}))?)?$/`; + +/** Indent every line of `block` by `n` spaces (blank lines stay blank). */ +function indent(block: string, n: number): string { + const pad = " ".repeat(n); + return block.split("\n").map((l) => (l === "" ? l : pad + l)).join("\n"); +} + +/** Microsecond unit constants, as `BigInt(...)` calls (no bigint literal). */ +const UNIT_LINES = [ + "const ZERO = BigInt(0);", + "const SECOND = BigInt(1000000);", + "const MINUTE = BigInt(60000000);", + "const HOUR = BigInt(3600000000);", + "const DAY = BigInt(86400000000);", + "const WEEK = BigInt(604800000000);", +].join("\n"); + +/** `parseIso(text)`: the microseconds of an accepted ISO-8601 duration, or undefined. */ +const PARSE_ISO_FN = [ + "const parseIso = (text: string): bigint | undefined => {", + ` const p = ${ISO_PARTS_SOURCE}.exec(text);`, + " if (p === null) return undefined;", + " const mag = BigInt(p[2] ?? 0) * WEEK + BigInt(p[3] ?? 0) * DAY + BigInt(p[4] ?? 0) * HOUR", + ' + BigInt(p[5] ?? 0) * MINUTE + BigInt(p[6] ?? 0) * SECOND + BigInt((p[7] ?? "").padEnd(6, "0").slice(0, 6));', + ' return p[1] === "-" ? -mag : mag;', + "};", +].join("\n"); + +/** `isoText(micros)`: the canonical wire form. */ +const ISO_TEXT_FN = [ + "const isoText = (micros: bigint): string => {", + " const neg = micros < ZERO;", + " let rest = neg ? -micros : micros;", + " const h = rest / HOUR;", + " rest %= HOUR;", + " const m = rest / MINUTE;", + " rest %= MINUTE;", + " const s = rest / SECOND;", + " const f = rest % SECOND;", + ' let out = "PT";', + ' if (h > ZERO) out += String(h) + "H";', + ' if (m > ZERO) out += String(m) + "M";', + ' if (s > ZERO || f > ZERO) out += String(s) + (f > ZERO ? "." + String(f).padStart(6, "0").replace(/0+$/, "") : "") + "S";', + ' if (out === "PT") return "PT0S";', + ' return neg ? "-" + out : out;', + "};", +].join("\n"); + +export const DURATION_WRITE_HELPER_DECL = [ + "/** A field.duration on the wire: an ISO-8601 duration (weeks, days, hours, minutes, seconds) rewritten", + " * to its canonical form — hours never folded into days, zero components left out, zero as PT0S, one", + " * leading \"-\" for a negative span. Counted in bigint microseconds, so any length is exact and a", + " * fraction past six digits is truncated toward zero. Generated. */", + `function ${DURATION_WRITE_HELPER}(text: string): string {`, + indent(UNIT_LINES, 2), + indent(PARSE_ISO_FN, 2), + indent(ISO_TEXT_FN, 2), + " const micros = parseIso(text);", + " return micros === undefined ? text : isoText(micros);", + "}", +].join("\n"); + +/** The physical column type a duration column's `customType` declares, per dialect: the + * Postgres-native interval; a 64-bit integer count of microseconds on SQLite / D1 (INTEGER) and + * MySQL (BIGINT) — the persistence corpus's canonical schemas, and what `meta migrate` writes. */ +export function durationColumnDataType(dialect: Dialect): string { + return dialect === "postgres" ? "interval" : dialect === "mysql" ? "bigint" : "integer"; +} + +/** + * The body of the duration column's declaration, around the `customType` reference (which the + * caller supplies as an import). `[before, after]`: `before + customType + after` is the whole + * declaration. Exported so a test can evaluate the exact text a generated module carries. + * + * - `toDriver` binds what the column holds: on Postgres the interval spelling `[-]HH:MM:SS[.f]` + * (Postgres refuses a signed ISO-8601 duration, and returns a value written this way in exactly + * this spelling); on SQLite, D1 and MySQL the microsecond count — a number when it is a safe + * integer, else a bigint — refused past the 64-bit range, which is that column's limit. It takes + * the value as an ISO-8601 duration (what the generated write schema hands it), or as what + * runtime-ts already binds a filter operand in (the Postgres text; the microsecond integer), and + * throws for anything else. + * - `fromDriver` reads what the driver returns — the microsecond integer (a number, a bigint or a + * digit string), ISO-8601 text, Postgres interval text in the default `IntervalStyle`, or a + * node-postgres interval object — as the canonical ISO-8601 form. An interval with a year or + * month part is not a fixed-length span: reading one throws. + */ +export function durationColumnDeclParts(dialect: Dialect): [string, string] { + const postgres = dialect === "postgres"; + const before = [ + "/** A field.duration column. Binds " + (postgres + ? "the Postgres interval spelling [-]HH:MM:SS[.f] (Postgres refuses a signed ISO-8601 duration);" + : "the span as a 64-bit integer count of microseconds (refused past that range);"), + " * reads it back as the canonical ISO-8601 duration, and refuses an interval with a year or month", + " * part. Counted in bigint microseconds, so the column is the only limit. Generated. */", + `const ${DURATION_COLUMN_FN} = (() => {`, + indent(UNIT_LINES, 2), + ` const INT64_MAX = BigInt("9223372036854775807");`, + indent(PARSE_ISO_FN, 2), + indent(ISO_TEXT_FN, 2), + indent([ + "const pgText = (micros: bigint): string => {", + " const neg = micros < ZERO;", + " let rest = neg ? -micros : micros;", + ' const two = (n: bigint): string => String(n).padStart(2, "0");', + " const h = rest / HOUR;", + " rest %= HOUR;", + " const m = rest / MINUTE;", + " rest %= MINUTE;", + ' let out = two(h) + ":" + two(m) + ":" + two(rest / SECOND);', + " const f = rest % SECOND;", + ' if (f > ZERO) out += "." + String(f).padStart(6, "0").replace(/0+$/, "");', + ' return neg ? "-" + out : out;', + "};", + "const stored = (micros: bigint): number | bigint => {", + " if (micros > INT64_MAX || micros < -INT64_MAX - BigInt(1)) {", + ' throw new Error("the span " + isoText(micros) + " does not fit the 64-bit microsecond column a field.duration is stored in");', + " }", + " const n = Number(micros);", + " return Number.isSafeInteger(n) ? n : micros;", + "};", + "const notFixed = (v: unknown): Error =>", + ' new Error("the interval " + JSON.stringify(v) + " has a year or month part, so it is not a fixed-length field.duration");', + "const read = (v: unknown): bigint => {", + ' if (typeof v === "bigint") return v;', + ' if (typeof v === "number" && Number.isInteger(v)) return BigInt(v);', + ' if (typeof v === "string") {', + " if (/^-?\\d+$/.test(v)) return BigInt(v);", + " const iso = parseIso(v);", + " if (iso !== undefined) return iso;", + ` const p = v.length === 0 ? null : ${PG_INTERVAL_SOURCE}.exec(v);`, + ' if (p === null) throw new Error("not a field.duration value: " + JSON.stringify(v));', + " if (Number(p[1] ?? 0) !== 0 || Number(p[2] ?? 0) !== 0) throw notFixed(v);", + " let time = ZERO;", + " if (p[5] !== undefined) {", + ' time = BigInt(p[5]) * HOUR + BigInt(p[6] ?? 0) * MINUTE + BigInt(p[7] ?? 0) * SECOND + BigInt((p[8] ?? "").padEnd(6, "0"));', + ' if (p[4] === "-") time = -time;', + " }", + " return BigInt(p[3] ?? 0) * DAY + time;", + " }", + ' if (v !== null && typeof v === "object") {', + " const o = v as Record;", + " if ((o.years ?? 0) !== 0 || (o.months ?? 0) !== 0) throw notFixed(v);", + " const whole = (n: number | undefined): bigint => BigInt(Math.trunc(n ?? 0));", + " // postgres-interval carries microseconds as a fractional millisecond count.", + " return whole(o.days) * DAY + whole(o.hours) * HOUR + whole(o.minutes) * MINUTE + whole(o.seconds) * SECOND", + " + BigInt(Math.round((o.milliseconds ?? 0) * 1000));", + " }", + ' throw new Error("not a field.duration value: " + JSON.stringify(v));', + "};", + ].join("\n"), 2), + " return ", + ].join("\n"); + const after = [ + `<{ data: string; driverData: ${postgres ? "string" : "number | bigint | string"} }>({`, + ` dataType: () => ${JSON.stringify(durationColumnDataType(dialect))},`, + ` toDriver: (value) => ${postgres ? "pgText" : "stored"}(read(value)),`, + " fromDriver: (value) => isoText(read(value)),", + " });", + "})();", + ].join("\n"); + return [before, after]; +} + +/** A duration column's Drizzle `customType` declaration, emitted ahead of the table or view that + * uses it (once per module — see enum-int-codec.ts for why the order and the once matter). */ +export function renderDurationCustomType(dialect: Dialect, importModule: string): Code { + const customTypeSym = imp(`customType@${importModule}`); + const [before, after] = durationColumnDeclParts(dialect); + return code` +${before}${customTypeSym}${after} +`; +} diff --git a/examples/advanced-modeling/codegen/templates/field-meta.ts b/examples/advanced-modeling/codegen/templates/field-meta.ts index 6182a31306..72549fd876 100644 --- a/examples/advanced-modeling/codegen/templates/field-meta.ts +++ b/examples/advanced-modeling/codegen/templates/field-meta.ts @@ -22,6 +22,7 @@ import { FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, + FIELD_SUBTYPE_DURATION, FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF, FIELD_ATTR_STRING_FORMAT, @@ -43,6 +44,7 @@ import { enumValues } from "@metaobjectsdev/codegen-ts/engine"; import { zodEnumExpr } from "./zod-enum.js"; import { viewForContext } from "@metaobjectsdev/codegen-ts/engine"; import { ZOD_INET_EXPR } from "./net-regex.js"; +import { ZOD_DURATION_EXPR } from "./duration-wire-helper.js"; // --------------------------------------------------------------------------- // inferViewKind @@ -140,6 +142,9 @@ export function zodTypeFor(field: MetaField, timestampMode: "date" | "string" = // (v4 or v6 literal). Zod-version-agnostic regex union (Zod 4 removed // `z.string().ip()`); see net-regex.ts. @lenient → a plain string. return field.attr(FIELD_ATTR_LENIENT) === true ? "z.string()" : ZOD_INET_EXPR; + case FIELD_SUBTYPE_DURATION: + // An ISO-8601 duration string; the column codec reads every row in the canonical form. + return ZOD_DURATION_EXPR; case FIELD_SUBTYPE_BOOLEAN: return "z.boolean()"; case FIELD_SUBTYPE_DATE: diff --git a/examples/advanced-modeling/codegen/templates/filter-allowlist.ts b/examples/advanced-modeling/codegen/templates/filter-allowlist.ts index 4a0cd087fd..ec7c195b7c 100644 --- a/examples/advanced-modeling/codegen/templates/filter-allowlist.ts +++ b/examples/advanced-modeling/codegen/templates/filter-allowlist.ts @@ -13,6 +13,7 @@ import { FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_CURRENCY, FIELD_SUBTYPE_UUID, + FIELD_SUBTYPE_DURATION, FIELD_SUBTYPE_ENUM, FIELD_ATTR_LOCAL_TIME, opsForField, @@ -70,6 +71,9 @@ const VALUE_FORMAT_SUBTYPES = new Set([ FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_DECIMAL, + // An ISO-8601 duration: runtime-ts refuses a value that is not one, and the column codec binds + // the dialect's spelling of it. + FIELD_SUBTYPE_DURATION, ]); /** Subtypes whose values are whole numbers — `field.currency` is integer minor units. */ diff --git a/examples/advanced-modeling/codegen/templates/inferred-types.ts b/examples/advanced-modeling/codegen/templates/inferred-types.ts index 1eec56f318..4b87f2f9bc 100644 --- a/examples/advanced-modeling/codegen/templates/inferred-types.ts +++ b/examples/advanced-modeling/codegen/templates/inferred-types.ts @@ -27,6 +27,7 @@ import { FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, + FIELD_SUBTYPE_DURATION, FIELD_ATTR_REQUIRED, FIELD_ATTR_OBJECT_REF, FIELD_ATTR_VALUE_TYPE, @@ -215,6 +216,8 @@ const SCALAR_TS_BY_SUBTYPE: Record = { // type, same as uuid). Other ports bind to their native URI/IP type. [FIELD_SUBTYPE_URI]: "string", [FIELD_SUBTYPE_INET]: "string", + // field.duration is an ISO-8601 duration string: TS has no native duration type. + [FIELD_SUBTYPE_DURATION]: "string", [FIELD_SUBTYPE_INT]: "number", [FIELD_SUBTYPE_LONG]: "number", [FIELD_SUBTYPE_DOUBLE]: "number", diff --git a/examples/advanced-modeling/codegen/templates/projection-decl.ts b/examples/advanced-modeling/codegen/templates/projection-decl.ts index e9ab1e45d2..a7ccbf0f93 100644 --- a/examples/advanced-modeling/codegen/templates/projection-decl.ts +++ b/examples/advanced-modeling/codegen/templates/projection-decl.ts @@ -21,7 +21,7 @@ import type { ColumnNamingStrategy } from "@metaobjectsdev/codegen-ts/engine"; import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts/engine"; import type { Dialect } from "@metaobjectsdev/codegen-ts/engine"; -import { physicalNameExpr, sourceSchemaExpr, type ObjectNames } from "@metaobjectsdev/codegen-ts/engine"; +import { columnExpr, physicalNameExpr, sourceSchemaExpr, type ObjectNames } from "@metaobjectsdev/codegen-ts/engine"; import { renderFilterAllowlist, renderSortAllowlist } from "./filter-allowlist.js"; import { renderFilterType } from "./filter-type.js"; import { buildUiFieldDescriptor, resourcePath } from "@metaobjectsdev/codegen-ts/engine"; @@ -156,11 +156,8 @@ export function renderProjectionDecl( // A6 — reference the constant whenever the artifact is in the run AND carries this // field. A lookup MISS (a TPH subtype field the base's names artifact never saw) is // normal, not a divergence — fall back to the literal, same as every other §A6 site. - const namesEntry = names?.resolved.fields[f.name]; - const dbColExpr: Code = - names !== undefined && namesEntry !== undefined - ? code`${names.symbol}.fields.${f.name}.column` - : code`${JSON.stringify(dbCol)}`; + // A flattened field has no `column` of its own (its members do), so it takes the literal. + const dbColExpr: Code = columnExpr(names, f.name, dbCol); // #356 — a projection's descriptor is the same shape the entity descriptor emits, // and now literally the SAME derivation: `buildUiFieldDescriptor` is what // `renderEntityConstants` reads, so the view kind, the label and the currency meta diff --git a/examples/advanced-modeling/codegen/templates/view-decl.ts b/examples/advanced-modeling/codegen/templates/view-decl.ts index da4a523058..eec5e9ed44 100644 --- a/examples/advanced-modeling/codegen/templates/view-decl.ts +++ b/examples/advanced-modeling/codegen/templates/view-decl.ts @@ -14,8 +14,9 @@ import { type MetaField, FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF, } from "@metaobjectsdev/metadata"; import type { ColumnNamingStrategy } from "@metaobjectsdev/codegen-ts/engine"; -import { mapColumnType, type EnumIntCustomType } from "@metaobjectsdev/codegen-ts/engine"; +import { mapColumnType, DURATION_COLUMN_FN, type EnumIntCustomType } from "@metaobjectsdev/codegen-ts/engine"; import { renderEnumIntCustomType } from "./enum-int-codec.js"; +import { renderDurationCustomType } from "./duration-wire-helper.js"; import { zodTypeFor } from "./field-meta.js"; import { columnExpr, type ObjectNames } from "@metaobjectsdev/codegen-ts/engine"; @@ -25,7 +26,8 @@ export interface ViewDeclOpts { /** Drives the timestamp column TS type (Date vs string) in the view declaration. */ readonly timestampMode: "date" | "string"; /** - * Int-backed-enum codec const names ALREADY declared in the module this view lands in. + * Local column-codec const names (int-backed enums, the duration codec) ALREADY declared in + * the module this view lands in. * A write-through entity's table and its replica view share one file, and both reference * the same codecs — so the view references these by name and declares only what the table * did not (a derived field's codec, which the write table omits). Absent = the view owns @@ -88,6 +90,8 @@ function viewColumnLine( opts: ViewDeclOpts, /** Codecs this view needs, collected for the caller to DECLARE — see below. */ enumIntTypes: Map, + /** The duration codec's name, when a column needs it — declared by the caller, as above. */ + usesDuration: Set, ): Code { const { dialect, columnNamingStrategy, timestampMode } = opts; const spec = mapColumnType(f, dialect, columnNamingStrategy, timestampMode); @@ -99,10 +103,14 @@ function viewColumnLine( if (spec.enumIntCustomType !== undefined) { enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType); } + // The field.duration codec is a local const too; collected under its name, declared once. + if (spec.durationCustomType !== undefined) usesDuration.add(spec.durationCustomType.fnConstName); const colSym = spec.enumIntCustomType !== undefined ? spec.enumIntCustomType.fnConstName - : imp(`${spec.fnName}@${spec.importModule}`); + : spec.durationCustomType !== undefined + ? spec.durationCustomType.fnConstName + : imp(`${spec.fnName}@${spec.importModule}`); const optsArg = spec.fnOptions && Object.keys(spec.fnOptions).length > 0 ? `, ${JSON.stringify(spec.fnOptions)}` @@ -164,7 +172,8 @@ export function renderExistingViewDecl( const { viewFn, core: viewModule } = dialectModule(opts.dialect); const viewSym = imp(`${viewFn}@${viewModule}`); const enumIntTypes = new Map(); - const viewColumnLines = fields.map((f) => viewColumnLine(f, opts, enumIntTypes)); + const usesDuration = new Set(); + const viewColumnLines = fields.map((f) => viewColumnLine(f, opts, enumIntTypes, usesDuration)); const viewNameExpr = typeof viewName === "string" ? code`${JSON.stringify(viewName)}` : viewName; // @schema — a view lands in a schema exactly as a table does, and migrate qualifies the @@ -188,6 +197,9 @@ export function renderExistingViewDecl( .filter((t) => alreadyDeclared === undefined || !alreadyDeclared.has(t.fnConstName)) .sort((a, b) => a.fnConstName.localeCompare(b.fnConstName)) .map((t) => renderEnumIntCustomType(t, viewModule)); + if (usesDuration.has(DURATION_COLUMN_FN) && (alreadyDeclared === undefined || !alreadyDeclared.has(DURATION_COLUMN_FN))) { + enumIntBlocks.unshift(renderDurationCustomType(opts.dialect, viewModule)); + } const viewBlock = code` // View declaration — Drizzle uses this for typed SELECT queries. diff --git a/examples/advanced-modeling/codegen/templates/zod-validators.ts b/examples/advanced-modeling/codegen/templates/zod-validators.ts index 917a215da5..195fddfacb 100644 --- a/examples/advanced-modeling/codegen/templates/zod-validators.ts +++ b/examples/advanced-modeling/codegen/templates/zod-validators.ts @@ -17,6 +17,7 @@ import { 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_DURATION, VALIDATOR_SUBTYPE_REQUIRED, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX, VALIDATOR_SUBTYPE_NUMERIC, VALIDATOR_SUBTYPE_ARRAY, IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, @@ -28,7 +29,9 @@ import { AUTO_SET_ON_CREATE, AUTO_SET_ON_UPDATE, VALIDATOR_ATTR_MAX, VALIDATOR_ATTR_MIN, VALIDATOR_ATTR_PATTERN, VALIDATOR_SUBTYPE_COMPARISON, VALIDATOR_SUBTYPE_REQUIRED_WHEN, - VALIDATOR_SUBTYPE_PRESENT_IFF, VALIDATOR_SUBTYPE_AT_LEAST_ONE, + VALIDATOR_SUBTYPE_PRESENT_IFF, VALIDATOR_SUBTYPE_AT_LEAST_ONE, VALIDATOR_SUBTYPE_EXACTLY_ONE, + VALIDATOR_ATTR_CONDITION, EXPR_OP_IS_NOT_NULL, EXPR_OP_NOT, EXPR_FN_COALESCE, + FILTER_OP_IS_NULL, FILTER_COMPOSE_AND, FILTER_COMPOSE_OR, parseDurationLiteral, VALIDATOR_ATTR_LEFT, VALIDATOR_ATTR_OP, VALIDATOR_ATTR_RIGHT, VALIDATOR_ATTR_FIELD, VALIDATOR_ATTR_WHEN, VALIDATOR_ATTR_EQUALS, VALIDATOR_ATTR_FIELDS, GENERATION_INCREMENT, GENERATION_UUID, @@ -48,6 +51,7 @@ import { } from "./date-time-regex.js"; import { renderDocsFor } from "./jsdoc.js"; import { DECIMAL_WRITE_HELPER_DECL, zodDecimalWriteExpr } from "./decimal-wire-helper.js"; +import { DURATION_WRITE_HELPER_DECL, ZOD_DURATION_EXPR, ZOD_DURATION_WRITE_EXPR } from "./duration-wire-helper.js"; import { sharedEnumForField } from "@metaobjectsdev/codegen-ts/engine"; import { sharedEnumImportSpecifier } from "@metaobjectsdev/codegen-ts/engine"; import { sharedEnumZodConstName } from "./enums-file.js"; @@ -86,6 +90,13 @@ function decimalHelperSince(since: number): string { return decimalWriteUses > since ? `${DECIMAL_WRITE_HELPER_DECL}\n\n` : ""; } +// The same device for the duration write rule (duration-wire-helper.ts). +let durationWriteUses = 0; + +function durationHelperSince(since: number): string { + return durationWriteUses > since ? `${DURATION_WRITE_HELPER_DECL}\n\n` : ""; +} + /** True when this object declares at least one @autoSet timestamp field * (onCreate or onUpdate). Drives whether codegen emits the #203 @@ -260,13 +271,16 @@ function isInsertRequiredPk(field: MetaField, assignedPk: Set): boolean } // ── Entity-scoped cross-field validators on the full (insert) record ────────── -// validator.comparison / requiredWhen / presentIff / atLeastOne are enforced on the -// INSERT-shaped schemas only, as one `.superRefine`. A PATCH body cannot see the merged +// validator.comparison / requiredWhen / presentIff / atLeastOne / exactlyOne are enforced on +// the INSERT-shaped schemas only, as one `.superRefine`. A PATCH body cannot see the merged // row, so the Update schema is deliberately left alone (the DB CHECK stays the guard // there). Semantics mirror the migrate-ts CHECK derivation: absent / null / undefined is // SQL NULL, "" is NOT null; a comparison with a NULL side passes; requiredWhen is violated // only when the gate EQUALS the literal and the target is null; presentIff requires -// (target != null) === (gate equals literal), a null gate being "not equal". +// (target != null) === (gate equals literal), a null gate being "not equal"; exactlyOne +// requires exactly one non-null field. A rule with an @condition is checked only when the +// condition is TRUE: the CHECK is NOT (condition) OR (rule), so a condition that is false or +// NULL (three-valued, as SQL evaluates it) lets the row through. const CROSS_FIELD_OPS: Record = { gt: ">", gte: ">=", lt: "<", lte: "<=", ne: "!==", eq: "===", @@ -278,7 +292,7 @@ const NUMERIC_SUBTYPES: ReadonlySet = new Set([ ]); const ORDERED_SUBTYPES: ReadonlySet = new Set([ - FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, + FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_DURATION, ]); /** Subtypes with equality but no ordering: a pair of the same one may be tested with eq / ne. */ @@ -287,7 +301,7 @@ const EQUALITY_SUBTYPES: ReadonlySet = new Set([ ]); /** 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 + * numerics compare with each other, strings, temporals and durations 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. */ @@ -329,10 +343,47 @@ function crossFieldLiteral(raw: unknown, when: MetaField): { lit: string; numeri * subtype also exports `InsertBaseSchema` (unrefined) and `InsertBodySchema` * (refined, pinned discriminator omitted). */ export function hasInsertRefinements(obj: MetaObject): boolean { - return crossFieldRefinementBody(obj).length > 0; + return crossFieldRefinement(obj).blocks.length > 0; } -function crossFieldRefinementBody(obj: MetaObject): string[] { +/** + * How a value is compared, from the field it is or is compared with: numerically, as a + * duration's microseconds (the canonical string does not order), or as written (strings, + * dates and times in their ISO form, booleans, enum members). + */ +type CompareKind = "number" | "duration" | "plain"; + +function compareKind(f: MetaField | undefined): CompareKind { + if (f === undefined) return "plain"; + if (NUMERIC_SUBTYPES.has(f.subType)) return "number"; + if (f.subType === FIELD_SUBTYPE_DURATION) return "duration"; + return "plain"; +} + +/** The local helpers a refinement body uses, declared once at its top when needed. */ +const HELPER_CMP = ` const cmp = (a: unknown, b: unknown): number | null => { + if (a == null || b == null) return null; + if (typeof a === "number" || typeof b === "number") return Number(a) - Number(b); + if (a instanceof Date || b instanceof Date) return new Date(a as string).getTime() - new Date(b as string).getTime(); + return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0; + };`; +const HELPER_TRI = ` const all = (...v: Array): boolean | null => v.includes(false) ? false : v.includes(null) ? null : true; + const any = (...v: Array): boolean | null => v.includes(true) ? true : v.includes(null) ? null : false; + const not = (v: boolean | null): boolean | null => v === null ? null : !v;`; +// An ISO-8601 duration (weeks, days, hours, minutes, seconds) as microseconds, or null when the +// text is not one (the accepted form: `P`, `PT` and `P1DT` are not durations). +const HELPER_MICROS = ` const micros = (d: unknown): number | null => { + if (d == null) return null; + const m = /^([-+])?P(?!$)(?:(\\d+)W)?(?:(\\d+)D)?(?:T(?=\\d)(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)(?:\\.(\\d+))?S)?)?$/.exec(String(d)); + if (m === null) return null; + const hours = (Number(m[2] ?? 0) * 7 + Number(m[3] ?? 0)) * 24 + Number(m[4] ?? 0); + const us = ((hours * 60 + Number(m[5] ?? 0)) * 60 + Number(m[6] ?? 0)) * 1e6 + Number((m[7] ?? "").padEnd(6, "0").slice(0, 6)); + return m[1] === "-" ? -us : us; + };`; + +interface RefinementBuild { blocks: string[]; helpers: Set } + +function crossFieldRefinement(obj: MetaObject): RefinementBuild { const inSchema = insertSchemaFieldNameSet(obj); const byName = new Map(obj.fields().map((f) => [f.name, f] as const)); // A reference must name a field that exists AND is in the insert schema; otherwise skip @@ -350,9 +401,116 @@ function crossFieldRefinementBody(obj: MetaObject): string[] { : `row[${JSON.stringify(f.name)}]`; const issue = (path: string, msg: string) => `ctx.addIssue({ code: "custom", path: [${path}], message: ${JSON.stringify(msg)} });`; + // The helpers the rule being built uses; merged into `helpers` only when the rule is kept, + // so a rule left to the database declares nothing that nothing reads. + const helpers = new Set(); + let ruleHelpers = new Set(); + /** A value compared as `kind`. */ + const asKind = (expr: string, kind: CompareKind): string => { + if (kind === "number") return `(${expr} == null ? null : Number(${expr}))`; + if (kind === "duration") { ruleHelpers.add(HELPER_MICROS); return `micros(${expr})`; } + return expr; + }; + + /** The field a node compares as: itself, or a coalesce's first field. */ + const nodeField = (node: unknown): MetaField | undefined => { + const n = (node ?? {}) as Record; + if (typeof n["field"] === "string") return byName.get(n["field"]); + if (n["fn"] === EXPR_FN_COALESCE && Array.isArray(n["args"])) { + for (const a of n["args"] as unknown[]) { const f = nodeField(a); if (f !== undefined) return f; } + } + return undefined; + }; + /** Every field a value node reads: the field itself, or each field among a coalesce's arguments. */ + const nodeFields = (node: unknown): MetaField[] => { + const n = (node ?? {}) as Record; + if (typeof n["field"] === "string") { const f = byName.get(n["field"]); return f === undefined ? [] : [f]; } + if (n["fn"] === EXPR_FN_COALESCE && Array.isArray(n["args"])) return (n["args"] as unknown[]).flatMap(nodeFields); + return []; + }; + /** A comparison inside a condition follows the validator rule: every pair of fields it reads + * must be comparable, or the whole rule is left to the database. */ + const conditionComparable = (left: unknown, right: unknown, opName: string): boolean => { + const fields = [...nodeFields(left), ...nodeFields(right)]; + return fields.every((a, i) => fields.slice(i + 1).every((b) => crossComparable(a, b, opName))); + }; + /** A value node (field, literal, coalesce) as a TS expression compared as `kind`, or undefined. */ + const valueTs = (node: unknown, kind: CompareKind): string | undefined => { + const n = (node ?? {}) as Record; + if (typeof n["field"] === "string") { + const f = ref(n["field"]); + return f === undefined ? undefined : asKind(acc(f), kind); + } + if ("value" in n) { + const value = n["value"]; + if (kind === "duration" && typeof value === "string") { + const us = parseDurationLiteral(value); + return us === undefined ? undefined : String(Number(us)); + } + return JSON.stringify(value); + } + if (n["fn"] === EXPR_FN_COALESCE && Array.isArray(n["args"])) { + const args = (n["args"] as unknown[]).map((a) => valueTs(a, kind)); + if (args.some((a) => a === undefined)) return undefined; + return `([${args.join(", ")}].find((x) => x != null) ?? null)`; + } + return undefined; + }; + /** A condition node as a TS expression of type `boolean | null`, or undefined when it names + * a field the body does not carry (the rule is then left to the database). */ + const condTs = (node: unknown): string | undefined => { + const n = (node ?? {}) as Record; + const op = n["op"]; + if (typeof op === "string" && CROSS_FIELD_OPS[op] !== undefined) { + if (!conditionComparable(n["left"], n["right"], op)) return undefined; + const kind = compareKind(nodeField(n["left"]) ?? nodeField(n["right"])); + const l = valueTs(n["left"], kind); + const r = valueTs(n["right"], kind); + if (l === undefined || r === undefined) return undefined; + ruleHelpers.add(HELPER_CMP); + return `((c) => c === null ? null : c ${CROSS_FIELD_OPS[op]} 0)(cmp(${l}, ${r}))`; + } + if (op === FILTER_OP_IS_NULL || op === EXPR_OP_IS_NOT_NULL) { + const a = valueTs(n["arg"], "plain"); + if (a === undefined) return undefined; + return op === FILTER_OP_IS_NULL ? `(${a} == null)` : `(${a} != null)`; + } + if (op === EXPR_OP_NOT) { + const a = condTs(n["arg"]); + if (a === undefined) return undefined; + ruleHelpers.add(HELPER_TRI); + return `not(${a})`; + } + if ((op === FILTER_COMPOSE_AND || op === FILTER_COMPOSE_OR) && Array.isArray(n["args"])) { + const args = (n["args"] as unknown[]).map(condTs); + if (args.some((a) => a === undefined)) return undefined; + ruleHelpers.add(HELPER_TRI); + return `${op === FILTER_COMPOSE_AND ? "all" : "any"}(${args.join(", ")})`; + } + return undefined; + }; + const blocks: string[] = []; + /** Add a rule's block, guarded by its @condition when it has one. */ + const push = (v: { attr(name: string): unknown }, block: string): void => { + const condition = v.attr(VALIDATOR_ATTR_CONDITION); + const guard = condition === undefined ? undefined : condTs(condition); + if (condition !== undefined && guard === undefined) return; + for (const h of ruleHelpers) helpers.add(h); + if (guard === undefined) { blocks.push(block); return; } + const inner = block.split("\n").map((l) => (l === "" ? l : ` ${l}`)).join("\n"); + blocks.push(` if (${guard} === true) {\n${inner}\n }`); + }; + /** The @fields of an atLeastOne / exactlyOne, each in the body, or undefined. */ + const fieldList = (raw: unknown): MetaField[] | undefined => { + const names = Array.isArray(raw) ? raw : (typeof raw === "string" ? [raw] : []); + const refs = names.map(ref); + if (refs.length === 0 || refs.some((r) => r === undefined)) return undefined; + return refs as MetaField[]; + }; // ADR-0039: entity.validators() is the resolving accessor (never ownValidators). for (const v of obj.validators()) { + ruleHelpers = new Set(); switch (v.subType) { case VALIDATOR_SUBTYPE_COMPARISON: { const left = ref(v.attr(VALIDATOR_ATTR_LEFT)); @@ -361,12 +519,15 @@ function crossFieldRefinementBody(obj: MetaObject): string[] { 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 + const kind = compareKind(left); + const c = kind === "number" || kind === "duration" ? "Number(l) - Number(r)" : "l instanceof Date && r instanceof Date ? l.getTime() - r.getTime() : String(l) < String(r) ? -1 : String(l) > String(r) ? 1 : 0"; - blocks.push( - ` {\n const l: unknown = ${acc(left)};\n const r: unknown = ${acc(right)};\n` + + // A duration side is read as its microseconds; text that is not a duration reads as + // null, so the rule is not checked (the field's own schema refuses that text). + const readKind: CompareKind = kind === "duration" ? "duration" : "plain"; + push(v, + ` {\n const l: unknown = ${asKind(acc(left), readKind)};\n const r: unknown = ${asKind(acc(right), readKind)};\n` + ` if (l != null && r != null) {\n const c = ${c};\n` + ` if (!(c ${op} 0)) ${issue(JSON.stringify(left.name), `${left.name} must be ${opName} ${right.name}`)}\n }\n }`, ); @@ -382,35 +543,41 @@ function crossFieldRefinementBody(obj: MetaObject): string[] { const path = JSON.stringify(target.name); const decl = ` const g: unknown = ${acc(when)};\n const t: unknown = ${acc(target)};\n const open = ${gate};\n`; if (v.subType === VALIDATOR_SUBTYPE_REQUIRED_WHEN) { - blocks.push(` {\n${decl} if (open && t == null) ${issue(path, `${target.name} is required when ${when.name} is ${String(v.attr(VALIDATOR_ATTR_EQUALS))}`)}\n }`); + push(v, ` {\n${decl} if (open && t == null) ${issue(path, `${target.name} is required when ${when.name} is ${String(v.attr(VALIDATOR_ATTR_EQUALS))}`)}\n }`); } else { - blocks.push(` {\n${decl} if ((t != null) !== open) ${issue(path, `${target.name} must be present if and only if ${when.name} is ${String(v.attr(VALIDATOR_ATTR_EQUALS))}`)}\n }`); + push(v, ` {\n${decl} if ((t != null) !== open) ${issue(path, `${target.name} must be present if and only if ${when.name} is ${String(v.attr(VALIDATOR_ATTR_EQUALS))}`)}\n }`); } break; } case VALIDATOR_SUBTYPE_AT_LEAST_ONE: { - const raw = v.attr(VALIDATOR_ATTR_FIELDS); - const names = Array.isArray(raw) ? raw : (typeof raw === "string" ? [raw] : []); - const refs = names.map(ref); - if (refs.length === 0 || refs.some((r) => r === undefined)) break; - const fields = refs as MetaField[]; + const fields = fieldList(v.attr(VALIDATOR_ATTR_FIELDS)); + if (fields === undefined) break; const anyPresent = fields.map((f) => `${acc(f)} != null`).join(" || "); - blocks.push(` if (!(${anyPresent})) ${issue(JSON.stringify(fields[0]!.name), `at least one of ${fields.map((f) => f.name).join(", ")} is required`)}`); + push(v, ` if (!(${anyPresent})) ${issue(JSON.stringify(fields[0]!.name), `at least one of ${fields.map((f) => f.name).join(", ")} is required`)}`); + break; + } + case VALIDATOR_SUBTYPE_EXACTLY_ONE: { + const fields = fieldList(v.attr(VALIDATOR_ATTR_FIELDS)); + if (fields === undefined) break; + const present = fields.map((f) => `${acc(f)}`).join(", "); + push(v, ` if ([${present}].filter((x) => x != null).length !== 1) ${issue(JSON.stringify(fields[0]!.name), `exactly one of ${fields.map((f) => f.name).join(", ")} is required`)}`); break; } default: break; } } - return blocks; + return { blocks, helpers }; } /** `.superRefine(...)` suffix for an insert-shaped schema, or "" when the object has no * enforceable cross-field validator (output is then byte-identical to before). */ function insertRefinementSuffix(obj: MetaObject): string { - const blocks = crossFieldRefinementBody(obj); + const { blocks, helpers } = crossFieldRefinement(obj); if (blocks.length === 0) return ""; - return `.superRefine((row, ctx) => {\n${blocks.join("\n")}\n})`; + // Declared in a fixed order, so the output does not depend on which rule needed one first. + const decls = [HELPER_CMP, HELPER_TRI, HELPER_MICROS].filter((h) => helpers.has(h)); + return `.superRefine((row, ctx) => {\n${[...decls, ...blocks].join("\n")}\n})`; } /** @@ -426,6 +593,7 @@ export function renderInsertSchemaOnly(obj: MetaObject, ctx?: RenderContext): Co const z = imp("z@zod"); const utcUsesBefore = utcTimestampUses; const decimalUsesBefore = decimalWriteUses; + const durationUsesBefore = durationWriteUses; const autoGenPkFields = autoGenPkFieldNames(obj); const assignedPkFields = assignedPkFieldNames(obj); const tphPin = tphDiscriminatorPin(obj); @@ -477,7 +645,7 @@ export function renderInsertSchemaOnly(obj: MetaObject, ctx?: RenderContext): Co const docsPrefix = docs ? `${docs}\n` : ""; return code` -${utcTimestampHelperSince(utcUsesBefore)}${decimalHelperSince(decimalUsesBefore)}${docsPrefix}export const ${insertSchemaName} = ${z}.object({ +${utcTimestampHelperSince(utcUsesBefore)}${decimalHelperSince(decimalUsesBefore)}${durationHelperSince(durationUsesBefore)}${docsPrefix}export const ${insertSchemaName} = ${z}.object({ ${joinCode(insertFieldLines, { on: ",\n" })} })${insertRefinementSuffix(obj)}; `; @@ -586,6 +754,7 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code const z = imp("z@zod"); const utcUsesBefore = utcTimestampUses; const decimalUsesBefore = decimalWriteUses; + const durationUsesBefore = durationWriteUses; const autoGenPkFields = autoGenPkFieldNames(obj); const pkFields = primaryKeyFieldNames(obj); const assignedPkFields = assignedPkFieldNames(obj); @@ -728,7 +897,7 @@ export type ${objName}CreatePreserving = ${z}.input DURATION_MICROS_MAX || micros < DURATION_MICROS_MIN) throw invalidValue(field, op, s, DURATION_IN_RANGE); + return dialect === "sqlite" || dialect === "mysql" ? durationToStoredMicros(micros) : formatPostgresInterval(micros); + } // A timestamp bound is compared in the same UTC spelling the generated schemas store: // on SQLite/D1 the column is TEXT and compares as text. An instant field (`instant`, i.e. // not @localTime) reads a zoneless or date-only bound as UTC, as its write schema does — @@ -364,6 +377,7 @@ function coerce(value: unknown, rule: FilterFieldRule, col: unknown, field: stri /** `expected` for a non-whole bound on an integer-valued field (`integer: true`). */ const INTEGER_EXPECTED = "integer (a whole number)"; +const DURATION_IN_RANGE = "duration within the 64-bit microsecond range"; function coerceAs(value: unknown, subType: string, field: string, op: string, dateValues?: boolean): unknown { if (value === null || value === undefined) return null; diff --git a/examples/showcase/codegen/runtime/drizzle-fastify/filter-value-format.ts b/examples/showcase/codegen/runtime/drizzle-fastify/filter-value-format.ts index 5def0974f6..cf8a3f1097 100644 --- a/examples/showcase/codegen/runtime/drizzle-fastify/filter-value-format.ts +++ b/examples/showcase/codegen/runtime/drizzle-fastify/filter-value-format.ts @@ -17,9 +17,11 @@ import { FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_UUID, + FIELD_SUBTYPE_DURATION, } from "@metaobjectsdev/metadata"; import type { FilterValueFormat } from "./filter-allowlist.js"; import { canonicalDecimal } from "../decimal-wire.js"; +import { parseDurationLiteral } from "../duration-wire.js"; const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/; const TIME_RE = /^(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,9})?)?$/; @@ -48,6 +50,7 @@ const CHECKS: Readonly boolean>> = { [FIELD_SUBTYPE_TIMESTAMP]: isTimestamp, [FIELD_SUBTYPE_UUID]: (s) => UUID_RE.test(s), [FIELD_SUBTYPE_DECIMAL]: (s) => canonicalDecimal(s) !== undefined, + [FIELD_SUBTYPE_DURATION]: (s) => parseDurationLiteral(s) !== undefined, }; /** The `expected` member of the `invalid_filter_value` envelope, per format. */ @@ -57,6 +60,7 @@ export const FORMAT_EXPECTED: Readonly> = { [FIELD_SUBTYPE_TIMESTAMP]: "timestamp (YYYY-MM-DD[THH:MM[:SS[.fff]]][Z|±HH:MM])", [FIELD_SUBTYPE_UUID]: "uuid (8-4-4-4-12 hex)", [FIELD_SUBTYPE_DECIMAL]: "decimal number (digits, optional fraction, optional exponent)", + [FIELD_SUBTYPE_DURATION]: "duration (ISO 8601, e.g. PT1H30M; no years or months)", }; /** `expected` for a temporal rule that does not say WHICH temporal format it is. */ diff --git a/examples/showcase/codegen/templates/drizzle-schema.ts b/examples/showcase/codegen/templates/drizzle-schema.ts index 93e85d56a0..74df06cb0d 100644 --- a/examples/showcase/codegen/templates/drizzle-schema.ts +++ b/examples/showcase/codegen/templates/drizzle-schema.ts @@ -18,7 +18,7 @@ import { import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { crossEntitySpecifier, valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts/engine"; import { - mapColumnType, PG_IDENTITY_CAPABLE_FNS, + mapColumnType, PG_IDENTITY_CAPABLE_FNS, DURATION_COLUMN_FN, type ColumnSpec, type EnumIntCustomType, } from "@metaobjectsdev/codegen-ts/engine"; import { tableNameFromEntity } from "@metaobjectsdev/codegen-ts/engine"; @@ -28,6 +28,7 @@ import { } from "@metaobjectsdev/codegen-ts/engine"; import { resolveTableSchema } from "@metaobjectsdev/metadata"; import { renderEnumIntCustomType } from "./enum-int-codec.js"; +import { renderDurationCustomType } from "./duration-wire-helper.js"; import { renderRelationsBlock } from "./relations-block.js"; import { renderDocsFor } from "./jsdoc.js"; import { collectTphSubtypeFields, tphConcreteSubtypes } from "./tph-discriminator.js"; @@ -46,7 +47,8 @@ export function renderDrizzleSchema( obj: MetaObject, ctx: RenderContext, /** - * OUT param: the int-backed-enum codec const names this table DECLARES. A caller that + * OUT param: the local column-codec const names this table DECLARES (int-backed enums, and + * the duration codec). A caller that * emits another artifact referencing the same codecs into the SAME module — entity-file's * write-through replica view — passes a set here and hands it to that emitter, which then * references the consts instead of re-declaring them. A duplicate module-scope `const` is @@ -211,6 +213,8 @@ export function renderDrizzleSchema( // Int-backed field.enum customType helpers, emitted ahead of the table. Keyed by // const name so a shared enum used by two fields of the SAME entity emits once. const enumIntTypes = new Map(); + // The field.duration codec: one declaration serves every duration column of the table. + let usesDurationCodec = false; for (const child of obj.fields()) { // #213 — a derived (origin-bearing) field is read-only, materialized on the // read (view) side, NOT a column on the entity's write table (FR-024 §7). @@ -225,6 +229,7 @@ export function renderDrizzleSchema( if (spec.enumIntCustomType !== undefined) { enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType); } + if (spec.durationCustomType !== undefined) usesDurationCodec = true; const fieldDocs = renderDocsFor(child); const columnLine = renderColumn(spec, columnNameExpr(child, spec.dbName), child, ctx, isPk, pkGeneration, fkInfo, isComposite, pkg, obj.name); columnLines.push(fieldDocs ? code` ${fieldDocs}\n${columnLine}` : columnLine); @@ -244,6 +249,7 @@ export function renderDrizzleSchema( if (spec.enumIntCustomType !== undefined) { enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType); } + if (spec.durationCustomType !== undefined) usesDurationCodec = true; const fieldDocs = renderDocsFor(child); const columnLine = renderColumn( spec, columnNameExpr(child, spec.dbName), child, ctx, false, undefined, fkMap.get(child.name), isComposite, pkg, obj.name, true, @@ -403,9 +409,11 @@ ${joinCode(columnLines, { on: ",\n", trim: false })} .map((t) => renderEnumIntCustomType(t, importModule)); if (declaredEnumIntCodecs !== undefined) { for (const name of enumIntTypes.keys()) declaredEnumIntCodecs.add(name); + if (usesDurationCodec) declaredEnumIntCodecs.add(DURATION_COLUMN_FN); } - const blocks: Code[] = [...enumIntBlocks, tableBlock]; + const durationBlocks = usesDurationCodec ? [renderDurationCustomType(dialect, importModule)] : []; + const blocks: Code[] = [...durationBlocks, ...enumIntBlocks, tableBlock]; if (relationsBlock !== null) blocks.push(relationsBlock); return blocks.length === 1 ? blocks[0]! : joinCode(blocks, { on: "\n" }); } @@ -518,7 +526,9 @@ function renderColumn( const fnSym = spec.enumIntCustomType !== undefined ? spec.enumIntCustomType.fnConstName - : imp(`${spec.fnName}@${spec.importModule}`); + : spec.durationCustomType !== undefined + ? spec.durationCustomType.fnConstName + : imp(`${spec.fnName}@${spec.importModule}`); let baseCall: Code; if (spec.fnOptions !== undefined && Object.keys(spec.fnOptions).length > 0) { diff --git a/examples/showcase/codegen/templates/duration-wire-helper.ts b/examples/showcase/codegen/templates/duration-wire-helper.ts new file mode 100644 index 0000000000..81aeaac06e --- /dev/null +++ b/examples/showcase/codegen/templates/duration-wire-helper.ts @@ -0,0 +1,213 @@ +// The wire rule for a field.duration, as text a generated model module carries as local code. +// Generated model code imports nothing from MetaObjects, so the rule is emitted, not imported; +// metadata/src/core/field/duration-wire.ts holds the same rule and both are held to +// fixtures/duration-wire/vectors.json (test/duration-wire-helper.test.ts evaluates the text +// emitted here against them). +// +// Accepted: optional weeks (exactly 7 days) and days, then hours, minutes and seconds; the seconds +// fraction may have any number of digits. Canonical: `[-]PT[H][M][[.]S]` — hours never +// folded into days, zero components left out, zero is `PT0S`, one leading `-`. A span is counted in +// microseconds, so the fraction keeps six digits and the rest is truncated toward zero. +// +// The emitted code counts a span in microseconds as a `bigint`, so it has no limit of its own: a +// span is as long as the stack it is stored in allows. The only limit here is a column's — the +// 64-bit integer a SQLite, D1 or MySQL column holds (refused past it, never wrapped). Postgres's +// interval has its own range, which Postgres enforces. The emitted code calls `BigInt(...)` and +// writes no bigint literal, so it needs the ES2020 `BigInt` global and nothing newer. + +import { code, imp, type Code } from "@metaobjectsdev/codegen-ts/engine"; +import { DURATION_PATTERN } from "@metaobjectsdev/metadata"; +import type { Dialect } from "@metaobjectsdev/codegen-ts/engine"; +import { DURATION_COLUMN_FN } from "@metaobjectsdev/codegen-ts/engine"; + +/** The accepted ISO-8601 duration form as a JS regex literal — the metadata's own pattern. */ +export const DURATION_REGEX_LITERAL = `/${DURATION_PATTERN}/`; + +/** The Zod expression that accepts a field.duration as sent (and as read back): the accepted form. */ +export const ZOD_DURATION_EXPR = `z.string().regex(${DURATION_REGEX_LITERAL})`; + +export const DURATION_WRITE_HELPER = "durationWrite"; + +/** The Zod expression of a duration WRITE field: the accepted form, rewritten to the canonical one, + * so equal spans are equal strings on the wire and in a jsonb value object. */ +export const ZOD_DURATION_WRITE_EXPR = `${ZOD_DURATION_EXPR}.transform(${DURATION_WRITE_HELPER})`; + +/** The accepted form with its components captured: sign, weeks, days, hours, minutes, seconds, + * fraction. Equivalent to DURATION_PATTERN (the same lookaheads refuse `P`, `PT` and `P1DT`). */ +const ISO_PARTS_SOURCE = String.raw`/^([-+])?P(?!$)(?:(\d+)W)?(?:(\d+)D)?(?:T(?=\d)(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)(?:\.(\d+))?S)?)?$/`; + +/** The default (`IntervalStyle = postgres`) text of a Postgres interval: signed year / mon / day + * parts, then an optionally signed `H:MM:SS[.ffffff]`. */ +const PG_INTERVAL_SOURCE = String.raw`/^(?:([-+]?\d+) years? ?)?(?:([-+]?\d+) mons? ?)?(?:([-+]?\d+) days? ?)?(?:([-+])?(\d+):(\d{2}):(\d{2})(?:\.(\d{1,6}))?)?$/`; + +/** Indent every line of `block` by `n` spaces (blank lines stay blank). */ +function indent(block: string, n: number): string { + const pad = " ".repeat(n); + return block.split("\n").map((l) => (l === "" ? l : pad + l)).join("\n"); +} + +/** Microsecond unit constants, as `BigInt(...)` calls (no bigint literal). */ +const UNIT_LINES = [ + "const ZERO = BigInt(0);", + "const SECOND = BigInt(1000000);", + "const MINUTE = BigInt(60000000);", + "const HOUR = BigInt(3600000000);", + "const DAY = BigInt(86400000000);", + "const WEEK = BigInt(604800000000);", +].join("\n"); + +/** `parseIso(text)`: the microseconds of an accepted ISO-8601 duration, or undefined. */ +const PARSE_ISO_FN = [ + "const parseIso = (text: string): bigint | undefined => {", + ` const p = ${ISO_PARTS_SOURCE}.exec(text);`, + " if (p === null) return undefined;", + " const mag = BigInt(p[2] ?? 0) * WEEK + BigInt(p[3] ?? 0) * DAY + BigInt(p[4] ?? 0) * HOUR", + ' + BigInt(p[5] ?? 0) * MINUTE + BigInt(p[6] ?? 0) * SECOND + BigInt((p[7] ?? "").padEnd(6, "0").slice(0, 6));', + ' return p[1] === "-" ? -mag : mag;', + "};", +].join("\n"); + +/** `isoText(micros)`: the canonical wire form. */ +const ISO_TEXT_FN = [ + "const isoText = (micros: bigint): string => {", + " const neg = micros < ZERO;", + " let rest = neg ? -micros : micros;", + " const h = rest / HOUR;", + " rest %= HOUR;", + " const m = rest / MINUTE;", + " rest %= MINUTE;", + " const s = rest / SECOND;", + " const f = rest % SECOND;", + ' let out = "PT";', + ' if (h > ZERO) out += String(h) + "H";', + ' if (m > ZERO) out += String(m) + "M";', + ' if (s > ZERO || f > ZERO) out += String(s) + (f > ZERO ? "." + String(f).padStart(6, "0").replace(/0+$/, "") : "") + "S";', + ' if (out === "PT") return "PT0S";', + ' return neg ? "-" + out : out;', + "};", +].join("\n"); + +export const DURATION_WRITE_HELPER_DECL = [ + "/** A field.duration on the wire: an ISO-8601 duration (weeks, days, hours, minutes, seconds) rewritten", + " * to its canonical form — hours never folded into days, zero components left out, zero as PT0S, one", + " * leading \"-\" for a negative span. Counted in bigint microseconds, so any length is exact and a", + " * fraction past six digits is truncated toward zero. Generated. */", + `function ${DURATION_WRITE_HELPER}(text: string): string {`, + indent(UNIT_LINES, 2), + indent(PARSE_ISO_FN, 2), + indent(ISO_TEXT_FN, 2), + " const micros = parseIso(text);", + " return micros === undefined ? text : isoText(micros);", + "}", +].join("\n"); + +/** The physical column type a duration column's `customType` declares, per dialect: the + * Postgres-native interval; a 64-bit integer count of microseconds on SQLite / D1 (INTEGER) and + * MySQL (BIGINT) — the persistence corpus's canonical schemas, and what `meta migrate` writes. */ +export function durationColumnDataType(dialect: Dialect): string { + return dialect === "postgres" ? "interval" : dialect === "mysql" ? "bigint" : "integer"; +} + +/** + * The body of the duration column's declaration, around the `customType` reference (which the + * caller supplies as an import). `[before, after]`: `before + customType + after` is the whole + * declaration. Exported so a test can evaluate the exact text a generated module carries. + * + * - `toDriver` binds what the column holds: on Postgres the interval spelling `[-]HH:MM:SS[.f]` + * (Postgres refuses a signed ISO-8601 duration, and returns a value written this way in exactly + * this spelling); on SQLite, D1 and MySQL the microsecond count — a number when it is a safe + * integer, else a bigint — refused past the 64-bit range, which is that column's limit. It takes + * the value as an ISO-8601 duration (what the generated write schema hands it), or as what + * runtime-ts already binds a filter operand in (the Postgres text; the microsecond integer), and + * throws for anything else. + * - `fromDriver` reads what the driver returns — the microsecond integer (a number, a bigint or a + * digit string), ISO-8601 text, Postgres interval text in the default `IntervalStyle`, or a + * node-postgres interval object — as the canonical ISO-8601 form. An interval with a year or + * month part is not a fixed-length span: reading one throws. + */ +export function durationColumnDeclParts(dialect: Dialect): [string, string] { + const postgres = dialect === "postgres"; + const before = [ + "/** A field.duration column. Binds " + (postgres + ? "the Postgres interval spelling [-]HH:MM:SS[.f] (Postgres refuses a signed ISO-8601 duration);" + : "the span as a 64-bit integer count of microseconds (refused past that range);"), + " * reads it back as the canonical ISO-8601 duration, and refuses an interval with a year or month", + " * part. Counted in bigint microseconds, so the column is the only limit. Generated. */", + `const ${DURATION_COLUMN_FN} = (() => {`, + indent(UNIT_LINES, 2), + ` const INT64_MAX = BigInt("9223372036854775807");`, + indent(PARSE_ISO_FN, 2), + indent(ISO_TEXT_FN, 2), + indent([ + "const pgText = (micros: bigint): string => {", + " const neg = micros < ZERO;", + " let rest = neg ? -micros : micros;", + ' const two = (n: bigint): string => String(n).padStart(2, "0");', + " const h = rest / HOUR;", + " rest %= HOUR;", + " const m = rest / MINUTE;", + " rest %= MINUTE;", + ' let out = two(h) + ":" + two(m) + ":" + two(rest / SECOND);', + " const f = rest % SECOND;", + ' if (f > ZERO) out += "." + String(f).padStart(6, "0").replace(/0+$/, "");', + ' return neg ? "-" + out : out;', + "};", + "const stored = (micros: bigint): number | bigint => {", + " if (micros > INT64_MAX || micros < -INT64_MAX - BigInt(1)) {", + ' throw new Error("the span " + isoText(micros) + " does not fit the 64-bit microsecond column a field.duration is stored in");', + " }", + " const n = Number(micros);", + " return Number.isSafeInteger(n) ? n : micros;", + "};", + "const notFixed = (v: unknown): Error =>", + ' new Error("the interval " + JSON.stringify(v) + " has a year or month part, so it is not a fixed-length field.duration");', + "const read = (v: unknown): bigint => {", + ' if (typeof v === "bigint") return v;', + ' if (typeof v === "number" && Number.isInteger(v)) return BigInt(v);', + ' if (typeof v === "string") {', + " if (/^-?\\d+$/.test(v)) return BigInt(v);", + " const iso = parseIso(v);", + " if (iso !== undefined) return iso;", + ` const p = v.length === 0 ? null : ${PG_INTERVAL_SOURCE}.exec(v);`, + ' if (p === null) throw new Error("not a field.duration value: " + JSON.stringify(v));', + " if (Number(p[1] ?? 0) !== 0 || Number(p[2] ?? 0) !== 0) throw notFixed(v);", + " let time = ZERO;", + " if (p[5] !== undefined) {", + ' time = BigInt(p[5]) * HOUR + BigInt(p[6] ?? 0) * MINUTE + BigInt(p[7] ?? 0) * SECOND + BigInt((p[8] ?? "").padEnd(6, "0"));', + ' if (p[4] === "-") time = -time;', + " }", + " return BigInt(p[3] ?? 0) * DAY + time;", + " }", + ' if (v !== null && typeof v === "object") {', + " const o = v as Record;", + " if ((o.years ?? 0) !== 0 || (o.months ?? 0) !== 0) throw notFixed(v);", + " const whole = (n: number | undefined): bigint => BigInt(Math.trunc(n ?? 0));", + " // postgres-interval carries microseconds as a fractional millisecond count.", + " return whole(o.days) * DAY + whole(o.hours) * HOUR + whole(o.minutes) * MINUTE + whole(o.seconds) * SECOND", + " + BigInt(Math.round((o.milliseconds ?? 0) * 1000));", + " }", + ' throw new Error("not a field.duration value: " + JSON.stringify(v));', + "};", + ].join("\n"), 2), + " return ", + ].join("\n"); + const after = [ + `<{ data: string; driverData: ${postgres ? "string" : "number | bigint | string"} }>({`, + ` dataType: () => ${JSON.stringify(durationColumnDataType(dialect))},`, + ` toDriver: (value) => ${postgres ? "pgText" : "stored"}(read(value)),`, + " fromDriver: (value) => isoText(read(value)),", + " });", + "})();", + ].join("\n"); + return [before, after]; +} + +/** A duration column's Drizzle `customType` declaration, emitted ahead of the table or view that + * uses it (once per module — see enum-int-codec.ts for why the order and the once matter). */ +export function renderDurationCustomType(dialect: Dialect, importModule: string): Code { + const customTypeSym = imp(`customType@${importModule}`); + const [before, after] = durationColumnDeclParts(dialect); + return code` +${before}${customTypeSym}${after} +`; +} diff --git a/examples/showcase/codegen/templates/field-meta.ts b/examples/showcase/codegen/templates/field-meta.ts index 6182a31306..72549fd876 100644 --- a/examples/showcase/codegen/templates/field-meta.ts +++ b/examples/showcase/codegen/templates/field-meta.ts @@ -22,6 +22,7 @@ import { FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, + FIELD_SUBTYPE_DURATION, FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF, FIELD_ATTR_STRING_FORMAT, @@ -43,6 +44,7 @@ import { enumValues } from "@metaobjectsdev/codegen-ts/engine"; import { zodEnumExpr } from "./zod-enum.js"; import { viewForContext } from "@metaobjectsdev/codegen-ts/engine"; import { ZOD_INET_EXPR } from "./net-regex.js"; +import { ZOD_DURATION_EXPR } from "./duration-wire-helper.js"; // --------------------------------------------------------------------------- // inferViewKind @@ -140,6 +142,9 @@ export function zodTypeFor(field: MetaField, timestampMode: "date" | "string" = // (v4 or v6 literal). Zod-version-agnostic regex union (Zod 4 removed // `z.string().ip()`); see net-regex.ts. @lenient → a plain string. return field.attr(FIELD_ATTR_LENIENT) === true ? "z.string()" : ZOD_INET_EXPR; + case FIELD_SUBTYPE_DURATION: + // An ISO-8601 duration string; the column codec reads every row in the canonical form. + return ZOD_DURATION_EXPR; case FIELD_SUBTYPE_BOOLEAN: return "z.boolean()"; case FIELD_SUBTYPE_DATE: diff --git a/examples/showcase/codegen/templates/filter-allowlist.ts b/examples/showcase/codegen/templates/filter-allowlist.ts index 4a0cd087fd..ec7c195b7c 100644 --- a/examples/showcase/codegen/templates/filter-allowlist.ts +++ b/examples/showcase/codegen/templates/filter-allowlist.ts @@ -13,6 +13,7 @@ import { FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_CURRENCY, FIELD_SUBTYPE_UUID, + FIELD_SUBTYPE_DURATION, FIELD_SUBTYPE_ENUM, FIELD_ATTR_LOCAL_TIME, opsForField, @@ -70,6 +71,9 @@ const VALUE_FORMAT_SUBTYPES = new Set([ FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_DECIMAL, + // An ISO-8601 duration: runtime-ts refuses a value that is not one, and the column codec binds + // the dialect's spelling of it. + FIELD_SUBTYPE_DURATION, ]); /** Subtypes whose values are whole numbers — `field.currency` is integer minor units. */ diff --git a/examples/showcase/codegen/templates/inferred-types.ts b/examples/showcase/codegen/templates/inferred-types.ts index 1eec56f318..4b87f2f9bc 100644 --- a/examples/showcase/codegen/templates/inferred-types.ts +++ b/examples/showcase/codegen/templates/inferred-types.ts @@ -27,6 +27,7 @@ import { FIELD_SUBTYPE_UUID, FIELD_SUBTYPE_URI, FIELD_SUBTYPE_INET, + FIELD_SUBTYPE_DURATION, FIELD_ATTR_REQUIRED, FIELD_ATTR_OBJECT_REF, FIELD_ATTR_VALUE_TYPE, @@ -215,6 +216,8 @@ const SCALAR_TS_BY_SUBTYPE: Record = { // type, same as uuid). Other ports bind to their native URI/IP type. [FIELD_SUBTYPE_URI]: "string", [FIELD_SUBTYPE_INET]: "string", + // field.duration is an ISO-8601 duration string: TS has no native duration type. + [FIELD_SUBTYPE_DURATION]: "string", [FIELD_SUBTYPE_INT]: "number", [FIELD_SUBTYPE_LONG]: "number", [FIELD_SUBTYPE_DOUBLE]: "number", diff --git a/examples/showcase/codegen/templates/projection-decl.ts b/examples/showcase/codegen/templates/projection-decl.ts index e9ab1e45d2..a7ccbf0f93 100644 --- a/examples/showcase/codegen/templates/projection-decl.ts +++ b/examples/showcase/codegen/templates/projection-decl.ts @@ -21,7 +21,7 @@ import type { ColumnNamingStrategy } from "@metaobjectsdev/codegen-ts/engine"; import type { RenderContext } from "@metaobjectsdev/codegen-ts/engine"; import { valueObjectModuleSpecifier } from "@metaobjectsdev/codegen-ts/engine"; import type { Dialect } from "@metaobjectsdev/codegen-ts/engine"; -import { physicalNameExpr, sourceSchemaExpr, type ObjectNames } from "@metaobjectsdev/codegen-ts/engine"; +import { columnExpr, physicalNameExpr, sourceSchemaExpr, type ObjectNames } from "@metaobjectsdev/codegen-ts/engine"; import { renderFilterAllowlist, renderSortAllowlist } from "./filter-allowlist.js"; import { renderFilterType } from "./filter-type.js"; import { buildUiFieldDescriptor, resourcePath } from "@metaobjectsdev/codegen-ts/engine"; @@ -156,11 +156,8 @@ export function renderProjectionDecl( // A6 — reference the constant whenever the artifact is in the run AND carries this // field. A lookup MISS (a TPH subtype field the base's names artifact never saw) is // normal, not a divergence — fall back to the literal, same as every other §A6 site. - const namesEntry = names?.resolved.fields[f.name]; - const dbColExpr: Code = - names !== undefined && namesEntry !== undefined - ? code`${names.symbol}.fields.${f.name}.column` - : code`${JSON.stringify(dbCol)}`; + // A flattened field has no `column` of its own (its members do), so it takes the literal. + const dbColExpr: Code = columnExpr(names, f.name, dbCol); // #356 — a projection's descriptor is the same shape the entity descriptor emits, // and now literally the SAME derivation: `buildUiFieldDescriptor` is what // `renderEntityConstants` reads, so the view kind, the label and the currency meta diff --git a/examples/showcase/codegen/templates/view-decl.ts b/examples/showcase/codegen/templates/view-decl.ts index da4a523058..eec5e9ed44 100644 --- a/examples/showcase/codegen/templates/view-decl.ts +++ b/examples/showcase/codegen/templates/view-decl.ts @@ -14,8 +14,9 @@ import { type MetaField, FIELD_SUBTYPE_OBJECT, FIELD_ATTR_OBJECT_REF, } from "@metaobjectsdev/metadata"; import type { ColumnNamingStrategy } from "@metaobjectsdev/codegen-ts/engine"; -import { mapColumnType, type EnumIntCustomType } from "@metaobjectsdev/codegen-ts/engine"; +import { mapColumnType, DURATION_COLUMN_FN, type EnumIntCustomType } from "@metaobjectsdev/codegen-ts/engine"; import { renderEnumIntCustomType } from "./enum-int-codec.js"; +import { renderDurationCustomType } from "./duration-wire-helper.js"; import { zodTypeFor } from "./field-meta.js"; import { columnExpr, type ObjectNames } from "@metaobjectsdev/codegen-ts/engine"; @@ -25,7 +26,8 @@ export interface ViewDeclOpts { /** Drives the timestamp column TS type (Date vs string) in the view declaration. */ readonly timestampMode: "date" | "string"; /** - * Int-backed-enum codec const names ALREADY declared in the module this view lands in. + * Local column-codec const names (int-backed enums, the duration codec) ALREADY declared in + * the module this view lands in. * A write-through entity's table and its replica view share one file, and both reference * the same codecs — so the view references these by name and declares only what the table * did not (a derived field's codec, which the write table omits). Absent = the view owns @@ -88,6 +90,8 @@ function viewColumnLine( opts: ViewDeclOpts, /** Codecs this view needs, collected for the caller to DECLARE — see below. */ enumIntTypes: Map, + /** The duration codec's name, when a column needs it — declared by the caller, as above. */ + usesDuration: Set, ): Code { const { dialect, columnNamingStrategy, timestampMode } = opts; const spec = mapColumnType(f, dialect, columnNamingStrategy, timestampMode); @@ -99,10 +103,14 @@ function viewColumnLine( if (spec.enumIntCustomType !== undefined) { enumIntTypes.set(spec.enumIntCustomType.fnConstName, spec.enumIntCustomType); } + // The field.duration codec is a local const too; collected under its name, declared once. + if (spec.durationCustomType !== undefined) usesDuration.add(spec.durationCustomType.fnConstName); const colSym = spec.enumIntCustomType !== undefined ? spec.enumIntCustomType.fnConstName - : imp(`${spec.fnName}@${spec.importModule}`); + : spec.durationCustomType !== undefined + ? spec.durationCustomType.fnConstName + : imp(`${spec.fnName}@${spec.importModule}`); const optsArg = spec.fnOptions && Object.keys(spec.fnOptions).length > 0 ? `, ${JSON.stringify(spec.fnOptions)}` @@ -164,7 +172,8 @@ export function renderExistingViewDecl( const { viewFn, core: viewModule } = dialectModule(opts.dialect); const viewSym = imp(`${viewFn}@${viewModule}`); const enumIntTypes = new Map(); - const viewColumnLines = fields.map((f) => viewColumnLine(f, opts, enumIntTypes)); + const usesDuration = new Set(); + const viewColumnLines = fields.map((f) => viewColumnLine(f, opts, enumIntTypes, usesDuration)); const viewNameExpr = typeof viewName === "string" ? code`${JSON.stringify(viewName)}` : viewName; // @schema — a view lands in a schema exactly as a table does, and migrate qualifies the @@ -188,6 +197,9 @@ export function renderExistingViewDecl( .filter((t) => alreadyDeclared === undefined || !alreadyDeclared.has(t.fnConstName)) .sort((a, b) => a.fnConstName.localeCompare(b.fnConstName)) .map((t) => renderEnumIntCustomType(t, viewModule)); + if (usesDuration.has(DURATION_COLUMN_FN) && (alreadyDeclared === undefined || !alreadyDeclared.has(DURATION_COLUMN_FN))) { + enumIntBlocks.unshift(renderDurationCustomType(opts.dialect, viewModule)); + } const viewBlock = code` // View declaration — Drizzle uses this for typed SELECT queries. diff --git a/examples/showcase/codegen/templates/zod-validators.ts b/examples/showcase/codegen/templates/zod-validators.ts index 917a215da5..195fddfacb 100644 --- a/examples/showcase/codegen/templates/zod-validators.ts +++ b/examples/showcase/codegen/templates/zod-validators.ts @@ -17,6 +17,7 @@ import { 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_DURATION, VALIDATOR_SUBTYPE_REQUIRED, VALIDATOR_SUBTYPE_LENGTH, VALIDATOR_SUBTYPE_REGEX, VALIDATOR_SUBTYPE_NUMERIC, VALIDATOR_SUBTYPE_ARRAY, IDENTITY_ATTR_FIELDS, IDENTITY_ATTR_GENERATION, @@ -28,7 +29,9 @@ import { AUTO_SET_ON_CREATE, AUTO_SET_ON_UPDATE, VALIDATOR_ATTR_MAX, VALIDATOR_ATTR_MIN, VALIDATOR_ATTR_PATTERN, VALIDATOR_SUBTYPE_COMPARISON, VALIDATOR_SUBTYPE_REQUIRED_WHEN, - VALIDATOR_SUBTYPE_PRESENT_IFF, VALIDATOR_SUBTYPE_AT_LEAST_ONE, + VALIDATOR_SUBTYPE_PRESENT_IFF, VALIDATOR_SUBTYPE_AT_LEAST_ONE, VALIDATOR_SUBTYPE_EXACTLY_ONE, + VALIDATOR_ATTR_CONDITION, EXPR_OP_IS_NOT_NULL, EXPR_OP_NOT, EXPR_FN_COALESCE, + FILTER_OP_IS_NULL, FILTER_COMPOSE_AND, FILTER_COMPOSE_OR, parseDurationLiteral, VALIDATOR_ATTR_LEFT, VALIDATOR_ATTR_OP, VALIDATOR_ATTR_RIGHT, VALIDATOR_ATTR_FIELD, VALIDATOR_ATTR_WHEN, VALIDATOR_ATTR_EQUALS, VALIDATOR_ATTR_FIELDS, GENERATION_INCREMENT, GENERATION_UUID, @@ -48,6 +51,7 @@ import { } from "./date-time-regex.js"; import { renderDocsFor } from "./jsdoc.js"; import { DECIMAL_WRITE_HELPER_DECL, zodDecimalWriteExpr } from "./decimal-wire-helper.js"; +import { DURATION_WRITE_HELPER_DECL, ZOD_DURATION_EXPR, ZOD_DURATION_WRITE_EXPR } from "./duration-wire-helper.js"; import { sharedEnumForField } from "@metaobjectsdev/codegen-ts/engine"; import { sharedEnumImportSpecifier } from "@metaobjectsdev/codegen-ts/engine"; import { sharedEnumZodConstName } from "./enums-file.js"; @@ -86,6 +90,13 @@ function decimalHelperSince(since: number): string { return decimalWriteUses > since ? `${DECIMAL_WRITE_HELPER_DECL}\n\n` : ""; } +// The same device for the duration write rule (duration-wire-helper.ts). +let durationWriteUses = 0; + +function durationHelperSince(since: number): string { + return durationWriteUses > since ? `${DURATION_WRITE_HELPER_DECL}\n\n` : ""; +} + /** True when this object declares at least one @autoSet timestamp field * (onCreate or onUpdate). Drives whether codegen emits the #203 @@ -260,13 +271,16 @@ function isInsertRequiredPk(field: MetaField, assignedPk: Set): boolean } // ── Entity-scoped cross-field validators on the full (insert) record ────────── -// validator.comparison / requiredWhen / presentIff / atLeastOne are enforced on the -// INSERT-shaped schemas only, as one `.superRefine`. A PATCH body cannot see the merged +// validator.comparison / requiredWhen / presentIff / atLeastOne / exactlyOne are enforced on +// the INSERT-shaped schemas only, as one `.superRefine`. A PATCH body cannot see the merged // row, so the Update schema is deliberately left alone (the DB CHECK stays the guard // there). Semantics mirror the migrate-ts CHECK derivation: absent / null / undefined is // SQL NULL, "" is NOT null; a comparison with a NULL side passes; requiredWhen is violated // only when the gate EQUALS the literal and the target is null; presentIff requires -// (target != null) === (gate equals literal), a null gate being "not equal". +// (target != null) === (gate equals literal), a null gate being "not equal"; exactlyOne +// requires exactly one non-null field. A rule with an @condition is checked only when the +// condition is TRUE: the CHECK is NOT (condition) OR (rule), so a condition that is false or +// NULL (three-valued, as SQL evaluates it) lets the row through. const CROSS_FIELD_OPS: Record = { gt: ">", gte: ">=", lt: "<", lte: "<=", ne: "!==", eq: "===", @@ -278,7 +292,7 @@ const NUMERIC_SUBTYPES: ReadonlySet = new Set([ ]); const ORDERED_SUBTYPES: ReadonlySet = new Set([ - FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, + FIELD_SUBTYPE_STRING, FIELD_SUBTYPE_DATE, FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_DURATION, ]); /** Subtypes with equality but no ordering: a pair of the same one may be tested with eq / ne. */ @@ -287,7 +301,7 @@ const EQUALITY_SUBTYPES: ReadonlySet = new Set([ ]); /** 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 + * numerics compare with each other, strings, temporals and durations 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. */ @@ -329,10 +343,47 @@ function crossFieldLiteral(raw: unknown, when: MetaField): { lit: string; numeri * subtype also exports `InsertBaseSchema` (unrefined) and `InsertBodySchema` * (refined, pinned discriminator omitted). */ export function hasInsertRefinements(obj: MetaObject): boolean { - return crossFieldRefinementBody(obj).length > 0; + return crossFieldRefinement(obj).blocks.length > 0; } -function crossFieldRefinementBody(obj: MetaObject): string[] { +/** + * How a value is compared, from the field it is or is compared with: numerically, as a + * duration's microseconds (the canonical string does not order), or as written (strings, + * dates and times in their ISO form, booleans, enum members). + */ +type CompareKind = "number" | "duration" | "plain"; + +function compareKind(f: MetaField | undefined): CompareKind { + if (f === undefined) return "plain"; + if (NUMERIC_SUBTYPES.has(f.subType)) return "number"; + if (f.subType === FIELD_SUBTYPE_DURATION) return "duration"; + return "plain"; +} + +/** The local helpers a refinement body uses, declared once at its top when needed. */ +const HELPER_CMP = ` const cmp = (a: unknown, b: unknown): number | null => { + if (a == null || b == null) return null; + if (typeof a === "number" || typeof b === "number") return Number(a) - Number(b); + if (a instanceof Date || b instanceof Date) return new Date(a as string).getTime() - new Date(b as string).getTime(); + return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0; + };`; +const HELPER_TRI = ` const all = (...v: Array): boolean | null => v.includes(false) ? false : v.includes(null) ? null : true; + const any = (...v: Array): boolean | null => v.includes(true) ? true : v.includes(null) ? null : false; + const not = (v: boolean | null): boolean | null => v === null ? null : !v;`; +// An ISO-8601 duration (weeks, days, hours, minutes, seconds) as microseconds, or null when the +// text is not one (the accepted form: `P`, `PT` and `P1DT` are not durations). +const HELPER_MICROS = ` const micros = (d: unknown): number | null => { + if (d == null) return null; + const m = /^([-+])?P(?!$)(?:(\\d+)W)?(?:(\\d+)D)?(?:T(?=\\d)(?:(\\d+)H)?(?:(\\d+)M)?(?:(\\d+)(?:\\.(\\d+))?S)?)?$/.exec(String(d)); + if (m === null) return null; + const hours = (Number(m[2] ?? 0) * 7 + Number(m[3] ?? 0)) * 24 + Number(m[4] ?? 0); + const us = ((hours * 60 + Number(m[5] ?? 0)) * 60 + Number(m[6] ?? 0)) * 1e6 + Number((m[7] ?? "").padEnd(6, "0").slice(0, 6)); + return m[1] === "-" ? -us : us; + };`; + +interface RefinementBuild { blocks: string[]; helpers: Set } + +function crossFieldRefinement(obj: MetaObject): RefinementBuild { const inSchema = insertSchemaFieldNameSet(obj); const byName = new Map(obj.fields().map((f) => [f.name, f] as const)); // A reference must name a field that exists AND is in the insert schema; otherwise skip @@ -350,9 +401,116 @@ function crossFieldRefinementBody(obj: MetaObject): string[] { : `row[${JSON.stringify(f.name)}]`; const issue = (path: string, msg: string) => `ctx.addIssue({ code: "custom", path: [${path}], message: ${JSON.stringify(msg)} });`; + // The helpers the rule being built uses; merged into `helpers` only when the rule is kept, + // so a rule left to the database declares nothing that nothing reads. + const helpers = new Set(); + let ruleHelpers = new Set(); + /** A value compared as `kind`. */ + const asKind = (expr: string, kind: CompareKind): string => { + if (kind === "number") return `(${expr} == null ? null : Number(${expr}))`; + if (kind === "duration") { ruleHelpers.add(HELPER_MICROS); return `micros(${expr})`; } + return expr; + }; + + /** The field a node compares as: itself, or a coalesce's first field. */ + const nodeField = (node: unknown): MetaField | undefined => { + const n = (node ?? {}) as Record; + if (typeof n["field"] === "string") return byName.get(n["field"]); + if (n["fn"] === EXPR_FN_COALESCE && Array.isArray(n["args"])) { + for (const a of n["args"] as unknown[]) { const f = nodeField(a); if (f !== undefined) return f; } + } + return undefined; + }; + /** Every field a value node reads: the field itself, or each field among a coalesce's arguments. */ + const nodeFields = (node: unknown): MetaField[] => { + const n = (node ?? {}) as Record; + if (typeof n["field"] === "string") { const f = byName.get(n["field"]); return f === undefined ? [] : [f]; } + if (n["fn"] === EXPR_FN_COALESCE && Array.isArray(n["args"])) return (n["args"] as unknown[]).flatMap(nodeFields); + return []; + }; + /** A comparison inside a condition follows the validator rule: every pair of fields it reads + * must be comparable, or the whole rule is left to the database. */ + const conditionComparable = (left: unknown, right: unknown, opName: string): boolean => { + const fields = [...nodeFields(left), ...nodeFields(right)]; + return fields.every((a, i) => fields.slice(i + 1).every((b) => crossComparable(a, b, opName))); + }; + /** A value node (field, literal, coalesce) as a TS expression compared as `kind`, or undefined. */ + const valueTs = (node: unknown, kind: CompareKind): string | undefined => { + const n = (node ?? {}) as Record; + if (typeof n["field"] === "string") { + const f = ref(n["field"]); + return f === undefined ? undefined : asKind(acc(f), kind); + } + if ("value" in n) { + const value = n["value"]; + if (kind === "duration" && typeof value === "string") { + const us = parseDurationLiteral(value); + return us === undefined ? undefined : String(Number(us)); + } + return JSON.stringify(value); + } + if (n["fn"] === EXPR_FN_COALESCE && Array.isArray(n["args"])) { + const args = (n["args"] as unknown[]).map((a) => valueTs(a, kind)); + if (args.some((a) => a === undefined)) return undefined; + return `([${args.join(", ")}].find((x) => x != null) ?? null)`; + } + return undefined; + }; + /** A condition node as a TS expression of type `boolean | null`, or undefined when it names + * a field the body does not carry (the rule is then left to the database). */ + const condTs = (node: unknown): string | undefined => { + const n = (node ?? {}) as Record; + const op = n["op"]; + if (typeof op === "string" && CROSS_FIELD_OPS[op] !== undefined) { + if (!conditionComparable(n["left"], n["right"], op)) return undefined; + const kind = compareKind(nodeField(n["left"]) ?? nodeField(n["right"])); + const l = valueTs(n["left"], kind); + const r = valueTs(n["right"], kind); + if (l === undefined || r === undefined) return undefined; + ruleHelpers.add(HELPER_CMP); + return `((c) => c === null ? null : c ${CROSS_FIELD_OPS[op]} 0)(cmp(${l}, ${r}))`; + } + if (op === FILTER_OP_IS_NULL || op === EXPR_OP_IS_NOT_NULL) { + const a = valueTs(n["arg"], "plain"); + if (a === undefined) return undefined; + return op === FILTER_OP_IS_NULL ? `(${a} == null)` : `(${a} != null)`; + } + if (op === EXPR_OP_NOT) { + const a = condTs(n["arg"]); + if (a === undefined) return undefined; + ruleHelpers.add(HELPER_TRI); + return `not(${a})`; + } + if ((op === FILTER_COMPOSE_AND || op === FILTER_COMPOSE_OR) && Array.isArray(n["args"])) { + const args = (n["args"] as unknown[]).map(condTs); + if (args.some((a) => a === undefined)) return undefined; + ruleHelpers.add(HELPER_TRI); + return `${op === FILTER_COMPOSE_AND ? "all" : "any"}(${args.join(", ")})`; + } + return undefined; + }; + const blocks: string[] = []; + /** Add a rule's block, guarded by its @condition when it has one. */ + const push = (v: { attr(name: string): unknown }, block: string): void => { + const condition = v.attr(VALIDATOR_ATTR_CONDITION); + const guard = condition === undefined ? undefined : condTs(condition); + if (condition !== undefined && guard === undefined) return; + for (const h of ruleHelpers) helpers.add(h); + if (guard === undefined) { blocks.push(block); return; } + const inner = block.split("\n").map((l) => (l === "" ? l : ` ${l}`)).join("\n"); + blocks.push(` if (${guard} === true) {\n${inner}\n }`); + }; + /** The @fields of an atLeastOne / exactlyOne, each in the body, or undefined. */ + const fieldList = (raw: unknown): MetaField[] | undefined => { + const names = Array.isArray(raw) ? raw : (typeof raw === "string" ? [raw] : []); + const refs = names.map(ref); + if (refs.length === 0 || refs.some((r) => r === undefined)) return undefined; + return refs as MetaField[]; + }; // ADR-0039: entity.validators() is the resolving accessor (never ownValidators). for (const v of obj.validators()) { + ruleHelpers = new Set(); switch (v.subType) { case VALIDATOR_SUBTYPE_COMPARISON: { const left = ref(v.attr(VALIDATOR_ATTR_LEFT)); @@ -361,12 +519,15 @@ function crossFieldRefinementBody(obj: MetaObject): string[] { 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 + const kind = compareKind(left); + const c = kind === "number" || kind === "duration" ? "Number(l) - Number(r)" : "l instanceof Date && r instanceof Date ? l.getTime() - r.getTime() : String(l) < String(r) ? -1 : String(l) > String(r) ? 1 : 0"; - blocks.push( - ` {\n const l: unknown = ${acc(left)};\n const r: unknown = ${acc(right)};\n` + + // A duration side is read as its microseconds; text that is not a duration reads as + // null, so the rule is not checked (the field's own schema refuses that text). + const readKind: CompareKind = kind === "duration" ? "duration" : "plain"; + push(v, + ` {\n const l: unknown = ${asKind(acc(left), readKind)};\n const r: unknown = ${asKind(acc(right), readKind)};\n` + ` if (l != null && r != null) {\n const c = ${c};\n` + ` if (!(c ${op} 0)) ${issue(JSON.stringify(left.name), `${left.name} must be ${opName} ${right.name}`)}\n }\n }`, ); @@ -382,35 +543,41 @@ function crossFieldRefinementBody(obj: MetaObject): string[] { const path = JSON.stringify(target.name); const decl = ` const g: unknown = ${acc(when)};\n const t: unknown = ${acc(target)};\n const open = ${gate};\n`; if (v.subType === VALIDATOR_SUBTYPE_REQUIRED_WHEN) { - blocks.push(` {\n${decl} if (open && t == null) ${issue(path, `${target.name} is required when ${when.name} is ${String(v.attr(VALIDATOR_ATTR_EQUALS))}`)}\n }`); + push(v, ` {\n${decl} if (open && t == null) ${issue(path, `${target.name} is required when ${when.name} is ${String(v.attr(VALIDATOR_ATTR_EQUALS))}`)}\n }`); } else { - blocks.push(` {\n${decl} if ((t != null) !== open) ${issue(path, `${target.name} must be present if and only if ${when.name} is ${String(v.attr(VALIDATOR_ATTR_EQUALS))}`)}\n }`); + push(v, ` {\n${decl} if ((t != null) !== open) ${issue(path, `${target.name} must be present if and only if ${when.name} is ${String(v.attr(VALIDATOR_ATTR_EQUALS))}`)}\n }`); } break; } case VALIDATOR_SUBTYPE_AT_LEAST_ONE: { - const raw = v.attr(VALIDATOR_ATTR_FIELDS); - const names = Array.isArray(raw) ? raw : (typeof raw === "string" ? [raw] : []); - const refs = names.map(ref); - if (refs.length === 0 || refs.some((r) => r === undefined)) break; - const fields = refs as MetaField[]; + const fields = fieldList(v.attr(VALIDATOR_ATTR_FIELDS)); + if (fields === undefined) break; const anyPresent = fields.map((f) => `${acc(f)} != null`).join(" || "); - blocks.push(` if (!(${anyPresent})) ${issue(JSON.stringify(fields[0]!.name), `at least one of ${fields.map((f) => f.name).join(", ")} is required`)}`); + push(v, ` if (!(${anyPresent})) ${issue(JSON.stringify(fields[0]!.name), `at least one of ${fields.map((f) => f.name).join(", ")} is required`)}`); + break; + } + case VALIDATOR_SUBTYPE_EXACTLY_ONE: { + const fields = fieldList(v.attr(VALIDATOR_ATTR_FIELDS)); + if (fields === undefined) break; + const present = fields.map((f) => `${acc(f)}`).join(", "); + push(v, ` if ([${present}].filter((x) => x != null).length !== 1) ${issue(JSON.stringify(fields[0]!.name), `exactly one of ${fields.map((f) => f.name).join(", ")} is required`)}`); break; } default: break; } } - return blocks; + return { blocks, helpers }; } /** `.superRefine(...)` suffix for an insert-shaped schema, or "" when the object has no * enforceable cross-field validator (output is then byte-identical to before). */ function insertRefinementSuffix(obj: MetaObject): string { - const blocks = crossFieldRefinementBody(obj); + const { blocks, helpers } = crossFieldRefinement(obj); if (blocks.length === 0) return ""; - return `.superRefine((row, ctx) => {\n${blocks.join("\n")}\n})`; + // Declared in a fixed order, so the output does not depend on which rule needed one first. + const decls = [HELPER_CMP, HELPER_TRI, HELPER_MICROS].filter((h) => helpers.has(h)); + return `.superRefine((row, ctx) => {\n${[...decls, ...blocks].join("\n")}\n})`; } /** @@ -426,6 +593,7 @@ export function renderInsertSchemaOnly(obj: MetaObject, ctx?: RenderContext): Co const z = imp("z@zod"); const utcUsesBefore = utcTimestampUses; const decimalUsesBefore = decimalWriteUses; + const durationUsesBefore = durationWriteUses; const autoGenPkFields = autoGenPkFieldNames(obj); const assignedPkFields = assignedPkFieldNames(obj); const tphPin = tphDiscriminatorPin(obj); @@ -477,7 +645,7 @@ export function renderInsertSchemaOnly(obj: MetaObject, ctx?: RenderContext): Co const docsPrefix = docs ? `${docs}\n` : ""; return code` -${utcTimestampHelperSince(utcUsesBefore)}${decimalHelperSince(decimalUsesBefore)}${docsPrefix}export const ${insertSchemaName} = ${z}.object({ +${utcTimestampHelperSince(utcUsesBefore)}${decimalHelperSince(decimalUsesBefore)}${durationHelperSince(durationUsesBefore)}${docsPrefix}export const ${insertSchemaName} = ${z}.object({ ${joinCode(insertFieldLines, { on: ",\n" })} })${insertRefinementSuffix(obj)}; `; @@ -586,6 +754,7 @@ export function renderZodValidators(obj: MetaObject, ctx?: RenderContext): Code const z = imp("z@zod"); const utcUsesBefore = utcTimestampUses; const decimalUsesBefore = decimalWriteUses; + const durationUsesBefore = durationWriteUses; const autoGenPkFields = autoGenPkFieldNames(obj); const pkFields = primaryKeyFieldNames(obj); const assignedPkFields = assignedPkFieldNames(obj); @@ -728,7 +897,7 @@ export type ${objName}CreatePreserving = ${z}.inputNames` and, on TypeScript, the `.meta.ts` twin. Where the port emits no names artifact yet, derive the list from the metadata directly (`@table` / `@schema` / `@column`, else the naming strategy applied to the field name; `@values`; `@title`) and record the un-wired generator as the first finding (below). (2) Grep the non-generated tree once — code AND checked-in config, fixtures, YAML, JSON, `.env`, OpenAPI fragments — with an alternation of those VALUES as literals; distinctive values (`"email_address"`, `"/subscribers"`, `"edited_approved"`) score on the hit, generic ones (`"id"`, `"name"`, `255`) only by reading the consumer, never by count. This step is what finds the topic name, the index field, the `COPY` header and the flag key without knowing they exist. (3) Trace each hit to its CONSUMER and ask what that consumer matches it against. Matched against the schema, the generated route table, a generated wire shape or a generated label → the metadata owns it, apply the discriminator. Matched against something the app chose — its own page-route table, its own copy deck — → not owned, not this finding. (4) Only then run the shape greps — raw SQL, `fetch(` / `app.get(` / a `queryKey`, a hand validator — as the second pass, for what a value grep cannot see: a literal that has ALREADY drifted from the declared value, a concatenated or interpolated name, a case-transformed one. - **Do not flag — one rule, and its consequences.** A literal is exempt when (a) it is resolved, not compared: a typed ORM handle (a Drizzle column object, an Exposed `Column`, an EF property, a Pydantic attribute), a typed property access (`row.email`, `form.input.email`), a typed filter object, an enum member narrowed by the generated union — rewriting any of these to `Subscriber.email.name` or a string lookup swaps a checked reference for an unchecked one and is a regression (the emitted `options: [...] as const` populates a dropdown; it does not replace checked literals); (b) the metadata does not OWN it — the value coincides with a derived one but the app may change it without the metadata being wrong: a client-side page route or link (``, ``) is a navigation URL the app chose, measured on an estate where all 11 apparent `$path` hits were exactly this and the true count was 0, and converting one couples the site's URL structure to the API's; likewise the deployment prefix of a fetch URL, which the constants surface deliberately does not carry — `/api` is supplied at runtime through the client provider's `baseUrl`, never frozen at `meta gen` — so `fetch("/api/subscribers")` decomposes into a prefix with no constant by design and a `/subscribers` that is `$path`; (c) no generated constant exists for it — next paragraph; (d) it sits where a constant cannot be imported or names something no single declaration owns: a `source.rdb` `@sql` body is metadata, fingerprinted precisely because it can reference nothing; a flattened value-object composite column belongs to no single field of either object; a relationship-synthesized FK column is derived, never declared; a write-through entity's replica view name is not in the artifact, which holds the PRIMARY source's. A hand-written repository that imports `Names` is the gold standard — call it out as good. + **Do not flag — one rule, and its consequences.** A literal is exempt when (a) it is resolved, not compared: a typed ORM handle (a Drizzle column object, an Exposed `Column`, an EF property, a Pydantic attribute), a typed property access (`row.email`, `form.input.email`), a typed filter object, an enum member narrowed by the generated union — rewriting any of these to `Subscriber.email.name` or a string lookup swaps a checked reference for an unchecked one and is a regression (the emitted `options: [...] as const` populates a dropdown; it does not replace checked literals); (b) the metadata does not OWN it — the value coincides with a derived one but the app may change it without the metadata being wrong: a client-side page route or link (``, ``) is a navigation URL the app chose, measured on an estate where all 11 apparent `$path` hits were exactly this and the true count was 0, and converting one couples the site's URL structure to the API's; likewise the deployment prefix of a fetch URL, which the constants surface deliberately does not carry — `/api` is supplied at runtime through the client provider's `baseUrl`, never frozen at `meta gen` — so `fetch("/api/subscribers")` decomposes into a prefix with no constant by design and a `/subscribers` that is `$path`; (c) no generated constant exists for it — next paragraph; (d) it sits where a constant cannot be imported or names something no single declaration owns: a `source.rdb` `@sql` body is metadata, fingerprinted precisely because it can reference nothing; a relationship-synthesized FK column is derived, never declared. (A flattened value object's member column and a write-through entity's replica view name are NOT exempt: the artifact carries both, as the field's member column constant and the source in role `replica`.) A hand-written repository that imports `Names` is the gold standard — call it out as good. **Score only where a constant actually exists.** A fact declared in the project's CODEGEN CONFIG rather than in metadata — a bespoke route mount path, a hand-chosen JSON envelope key — is genuinely spelled twice when a client re-types it, but no generated constant holds it. That is a GENERATOR GAP, reported as one, never a literal-site finding: never invent a constant that the emitters do not emit. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md index 0457d764eb..bad20b4e25 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md @@ -39,6 +39,10 @@ such, surfaced in the roadmap, but **non-failing** (the code works; the form is URL validation) — a distinct native type + behavior is a subtype, not a validated string. - A `field.string` that **holds an IP address** → recommend **`field.inet`** (native IP type; Postgres `inet` column). +- A `field.string` (or a CLR/JVM type override) that **holds a time span** (`TimeSpan`, an + `interval` column, "seconds until…") → recommend **`field.duration`** (native duration type; + Postgres `interval` column; ISO-8601 on the wire). Not a calendar span: months and years + are not durations. **Custom-provider vocabulary (adopters who register their own types/attrs):** check new/custom vocab against the ADR-0037 procedure (advisory) — e.g. *a custom subtype diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index 01fb198e0d..ff940062ae 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -245,6 +245,7 @@ Reach for these before inventing anything. When no row fits, run the decision pr | Open JSON, no reader narrows it | `field.string` + `@dbColumnType: jsonb` | the LAST rung — see "A JSON column" | | URL / URI | `field.uri` | native `URI`/`Uri`; `text` column; strict absolute-URI validation (add `@lenient: true` to store any string) — a real native type + behavior, so a subtype (not a validated string) | | IP address | `field.inet` | native IP type; Postgres `inet` column; strict IPv4/IPv6-literal validation (add `@lenient: true` for a plain-string `text` column) | +| Elapsed time / timeout / delay | `field.duration` | native `TimeSpan`/`Duration`/`timedelta`; Postgres `interval` column; ISO-8601 on the wire (`PT1H30M`). Not for a calendar span ("every 3 months"): that is an int and a unit. Never a string or a count of seconds | | Validated plain string (email / hostname) | `field.string` + `@stringFormat` | `@stringFormat: email` or `@stringFormat: hostname` — idiomatic per-port validation; don't hand-write the `validator.regex` | Four rules behind those rows, in short (full text: `references/choosing-a-shape.md`): @@ -286,9 +287,12 @@ Reuse a constraint set across entities with an abstract `field.enum` + `extends` `@storage` controls persistence: - `flattened` — one DB column per sub-field (`address_street`, `address_city`, …). - Illegal on array fields. + Illegal on array fields. The prefix is the field's column and `_`; + `@flattenedColumnPrefix` on the field replaces it verbatim (`""` keeps the members' + own columns). Refused on any other storage. - `jsonb` — one `jsonb` column. -- `subdocument` (default, back-compat) — single jsonb column. +- `subdocument` — single jsonb column (a hint for document-store targets). +- not set (the default) — one `jsonb` column, as `jsonb`. ```json { "field.object": { "name": "address", "@objectRef": "Address", "@storage": "flattened" } } @@ -438,12 +442,18 @@ store with no `CHECK` support is still covered. PATCH and partial-update models | `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` | +| `validator.exactlyOne` | exactly one of `@fields` (2+) is present | `@fields` | + +All five take an optional `@condition`, an expression over the entity's fields (the +`origin.computed` grammar): the rule then holds only where the condition does. And every validator +that becomes a CHECK takes an optional db-provider `@constraintName` naming it. ```json { "validator.comparison": { "name": "hpInRange", "@left": "currentHp", "@op": "lte", "@right": "maxHp" } } { "validator.requiredWhen": { "name": "reasonIfRejected", "@field": "rejectReason", "@when": "status", "@equals": "rejected" } } { "validator.presentIff": { "name": "usedAtWhenUsed", "@field": "usedAt", "@when": "isUsed", "@equals": "true" } } { "validator.atLeastOne": { "name": "emailOrPhone", "@fields": ["email", "phone"] } } +{ "validator.exactlyOne": { "name": "oneOwner", "@fields": ["customerId", "accountId"] } } ``` These are children of `object.entity`, alongside its fields and identities. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/physical-names.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/physical-names.md index 83f8364241..27bf59a63b 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/physical-names.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/physical-names.md @@ -89,14 +89,26 @@ literally. **Java and Python generate no SQL at all**: their DTOs and models car names, and persistence is the repository interface/`Protocol` you implement. There the artifact exists *for your code*, which is the only place a physical name appears. -Two categories stay literal, deliberately, and the gate pins them as such rather than -exempting them: - -- a **flattened value-object column** (`@storage: flattened`) is a composite — - `_` — belonging to no single field of either object, - so there is no one constant to reference; -- a **write-through entity's replica view name**: the artifact holds the object's PRIMARY - source's name (its table), and a write-through entity has two physical names. +**A flattened value object's columns are constants of the OWNING object's artifact.** A +`@storage: flattened` field has no column of its own: each member of the value object is a +column of the owner's table, named by core naming (the field's `@flattenedColumnPrefix`, +else `_`, then the member's column). The artifact carries one constant per +member, and the field itself has none, because the table has no column of that name: + +| Port | Reads as | +|---|---| +| TypeScript | `ShipmentNames.fields.shipTo.members.street.column` | +| C# | `ShipmentNames.ShipToStreetColumn` | +| Java / Kotlin | `ShipmentNames.SHIP_TO_STREET_COLUMN` | +| Python | `SHIPMENT_SHIP_TO_STREET_COLUMN` | + +The column-by-field maps (`ColumnsByField`, `COLUMNS_BY_FIELD`) key these `shipTo.street`. +A member constant that would share its name with another constant (a field `shipToStreet` +beside a flattened `shipTo.street` with a custom prefix) is left out with a warning; rename +one to get it. The generated EF Core and Exposed bindings still spell these columns as +literals, because an owned copy of an older names generator does not emit the member +constants. Without the artifact, compute the name with core naming's +`resolveFlattenedColumnName` (or its port equivalent), never by concatenating. **But prefer a typed handle where one exists — this rule has a real limit.** If the ORM gives you a type-checked object for the same thing, use that. Replacing a Drizzle column diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-verify/references/migration.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-verify/references/migration.md index 6828d995d3..f589708490 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-verify/references/migration.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-verify/references/migration.md @@ -283,6 +283,6 @@ emitted, created, diffed and dropped** — derived from the model, never written SQL. `field.enum` membership (`IN (…)`, the mapped integers for an `@intValueMap` enum), `validator.numeric` min/max, `validator.length` min/max, `validator.regex` (Postgres only — SQLite has no regex operator), and the entity-level cross-field -validators (`comparison`, `requiredWhen`, `presentIff`, `atLeastOne`) each become a +validators (`comparison`, `requiredWhen`, `presentIff`, `atLeastOne`, `exactlyOne`) each become a named `CHECK`. Array columns get none (the element rules are enforced app-side). (Partial + descending **indexes** are supported too — see Index modeling above.) diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/SKILL.md index 21a2973750..525029b69c 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/SKILL.md @@ -186,8 +186,8 @@ When folding hand validators into metadata, apply human judgment per constraint. A constraint enters **shared metadata** only if it is a **true cross-language domain invariant**; a one-consumer preference stays in a thin local refinement layer. -Cross-field rules **are** modelable (`comparison` / `atLeastOne` / `requiredWhen` / -`presentIff`); ratification decides *which* belong in shared metadata. Output a +Cross-field rules **are** modelable (`comparison` / `atLeastOne` / `exactlyOne` / +`requiredWhen` / `presentIff`); ratification decides *which* belong in shared metadata. Output a **ratification table**: KEEP-IN-METADATA / LOCAL-REFINEMENT / DROP + rationale — human-approved, never applied silently. Distinguish `required` from has-a-safe-default (`@default` often fixes the over-requiring bug). A core attr ripples cross-port; for a diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md index e145d1ccf6..71672d2a81 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -182,7 +182,8 @@ soft-delete / status / type view it models. `validator.regex`** — hunt hand field-level validation (presence, length, numeric range, array bounds, `@pattern` regex) the validator subtypes model. - **Cross-field validators** — `validator.comparison` (`@left`/`@op`/`@right`, e.g. - "end ≥ start"), `validator.atLeastOne` (one-of-N present), `validator.requiredWhen` + "end ≥ start"), `validator.atLeastOne` (one-of-N present), `validator.exactlyOne` (exactly one of N + present), `validator.requiredWhen` (conditional-required), `validator.presentIff` (mutual presence). Hunt these as hand-coded multi-field rules — they ARE modelable (see the Semantic-constraint ratification section in `SKILL.md` to decide what belongs in shared metadata vs port-local). diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md index 351893deb6..5148b26db4 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md @@ -35,7 +35,7 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend **Hunt from the declaration outward, never from code shapes inward.** (1) Build the value list from the generated artifacts, because they already carry every derived value after every transform — naming strategy, pluralized path, humanized label: read `Names` and, on TypeScript, the `.meta.ts` twin. Where the port emits no names artifact yet, derive the list from the metadata directly (`@table` / `@schema` / `@column`, else the naming strategy applied to the field name; `@values`; `@title`) and record the un-wired generator as the first finding (below). (2) Grep the non-generated tree once — code AND checked-in config, fixtures, YAML, JSON, `.env`, OpenAPI fragments — with an alternation of those VALUES as literals; distinctive values (`"email_address"`, `"/subscribers"`, `"edited_approved"`) score on the hit, generic ones (`"id"`, `"name"`, `255`) only by reading the consumer, never by count. This step is what finds the topic name, the index field, the `COPY` header and the flag key without knowing they exist. (3) Trace each hit to its CONSUMER and ask what that consumer matches it against. Matched against the schema, the generated route table, a generated wire shape or a generated label → the metadata owns it, apply the discriminator. Matched against something the app chose — its own page-route table, its own copy deck — → not owned, not this finding. (4) Only then run the shape greps — raw SQL, `fetch(` / `app.get(` / a `queryKey`, a hand validator — as the second pass, for what a value grep cannot see: a literal that has ALREADY drifted from the declared value, a concatenated or interpolated name, a case-transformed one. - **Do not flag — one rule, and its consequences.** A literal is exempt when (a) it is resolved, not compared: a typed ORM handle (a Drizzle column object, an Exposed `Column`, an EF property, a Pydantic attribute), a typed property access (`row.email`, `form.input.email`), a typed filter object, an enum member narrowed by the generated union — rewriting any of these to `Subscriber.email.name` or a string lookup swaps a checked reference for an unchecked one and is a regression (the emitted `options: [...] as const` populates a dropdown; it does not replace checked literals); (b) the metadata does not OWN it — the value coincides with a derived one but the app may change it without the metadata being wrong: a client-side page route or link (``, ``) is a navigation URL the app chose, measured on an estate where all 11 apparent `$path` hits were exactly this and the true count was 0, and converting one couples the site's URL structure to the API's; likewise the deployment prefix of a fetch URL, which the constants surface deliberately does not carry — `/api` is supplied at runtime through the client provider's `baseUrl`, never frozen at `meta gen` — so `fetch("/api/subscribers")` decomposes into a prefix with no constant by design and a `/subscribers` that is `$path`; (c) no generated constant exists for it — next paragraph; (d) it sits where a constant cannot be imported or names something no single declaration owns: a `source.rdb` `@sql` body is metadata, fingerprinted precisely because it can reference nothing; a flattened value-object composite column belongs to no single field of either object; a relationship-synthesized FK column is derived, never declared; a write-through entity's replica view name is not in the artifact, which holds the PRIMARY source's. A hand-written repository that imports `Names` is the gold standard — call it out as good. + **Do not flag — one rule, and its consequences.** A literal is exempt when (a) it is resolved, not compared: a typed ORM handle (a Drizzle column object, an Exposed `Column`, an EF property, a Pydantic attribute), a typed property access (`row.email`, `form.input.email`), a typed filter object, an enum member narrowed by the generated union — rewriting any of these to `Subscriber.email.name` or a string lookup swaps a checked reference for an unchecked one and is a regression (the emitted `options: [...] as const` populates a dropdown; it does not replace checked literals); (b) the metadata does not OWN it — the value coincides with a derived one but the app may change it without the metadata being wrong: a client-side page route or link (``, ``) is a navigation URL the app chose, measured on an estate where all 11 apparent `$path` hits were exactly this and the true count was 0, and converting one couples the site's URL structure to the API's; likewise the deployment prefix of a fetch URL, which the constants surface deliberately does not carry — `/api` is supplied at runtime through the client provider's `baseUrl`, never frozen at `meta gen` — so `fetch("/api/subscribers")` decomposes into a prefix with no constant by design and a `/subscribers` that is `$path`; (c) no generated constant exists for it — next paragraph; (d) it sits where a constant cannot be imported or names something no single declaration owns: a `source.rdb` `@sql` body is metadata, fingerprinted precisely because it can reference nothing; a relationship-synthesized FK column is derived, never declared. (A flattened value object's member column and a write-through entity's replica view name are NOT exempt: the artifact carries both, as the field's member column constant and the source in role `replica`.) A hand-written repository that imports `Names` is the gold standard — call it out as good. **Score only where a constant actually exists.** A fact declared in the project's CODEGEN CONFIG rather than in metadata — a bespoke route mount path, a hand-chosen JSON envelope key — is genuinely spelled twice when a client re-types it, but no generated constant holds it. That is a GENERATOR GAP, reported as one, never a literal-site finding: never invent a constant that the emitters do not emit. diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md index 0457d764eb..bad20b4e25 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md @@ -39,6 +39,10 @@ such, surfaced in the roadmap, but **non-failing** (the code works; the form is URL validation) — a distinct native type + behavior is a subtype, not a validated string. - A `field.string` that **holds an IP address** → recommend **`field.inet`** (native IP type; Postgres `inet` column). +- A `field.string` (or a CLR/JVM type override) that **holds a time span** (`TimeSpan`, an + `interval` column, "seconds until…") → recommend **`field.duration`** (native duration type; + Postgres `interval` column; ISO-8601 on the wire). Not a calendar span: months and years + are not durations. **Custom-provider vocabulary (adopters who register their own types/attrs):** check new/custom vocab against the ADR-0037 procedure (advisory) — e.g. *a custom subtype diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md index 01fb198e0d..ff940062ae 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -245,6 +245,7 @@ Reach for these before inventing anything. When no row fits, run the decision pr | Open JSON, no reader narrows it | `field.string` + `@dbColumnType: jsonb` | the LAST rung — see "A JSON column" | | URL / URI | `field.uri` | native `URI`/`Uri`; `text` column; strict absolute-URI validation (add `@lenient: true` to store any string) — a real native type + behavior, so a subtype (not a validated string) | | IP address | `field.inet` | native IP type; Postgres `inet` column; strict IPv4/IPv6-literal validation (add `@lenient: true` for a plain-string `text` column) | +| Elapsed time / timeout / delay | `field.duration` | native `TimeSpan`/`Duration`/`timedelta`; Postgres `interval` column; ISO-8601 on the wire (`PT1H30M`). Not for a calendar span ("every 3 months"): that is an int and a unit. Never a string or a count of seconds | | Validated plain string (email / hostname) | `field.string` + `@stringFormat` | `@stringFormat: email` or `@stringFormat: hostname` — idiomatic per-port validation; don't hand-write the `validator.regex` | Four rules behind those rows, in short (full text: `references/choosing-a-shape.md`): @@ -286,9 +287,12 @@ Reuse a constraint set across entities with an abstract `field.enum` + `extends` `@storage` controls persistence: - `flattened` — one DB column per sub-field (`address_street`, `address_city`, …). - Illegal on array fields. + Illegal on array fields. The prefix is the field's column and `_`; + `@flattenedColumnPrefix` on the field replaces it verbatim (`""` keeps the members' + own columns). Refused on any other storage. - `jsonb` — one `jsonb` column. -- `subdocument` (default, back-compat) — single jsonb column. +- `subdocument` — single jsonb column (a hint for document-store targets). +- not set (the default) — one `jsonb` column, as `jsonb`. ```json { "field.object": { "name": "address", "@objectRef": "Address", "@storage": "flattened" } } @@ -438,12 +442,18 @@ store with no `CHECK` support is still covered. PATCH and partial-update models | `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` | +| `validator.exactlyOne` | exactly one of `@fields` (2+) is present | `@fields` | + +All five take an optional `@condition`, an expression over the entity's fields (the +`origin.computed` grammar): the rule then holds only where the condition does. And every validator +that becomes a CHECK takes an optional db-provider `@constraintName` naming it. ```json { "validator.comparison": { "name": "hpInRange", "@left": "currentHp", "@op": "lte", "@right": "maxHp" } } { "validator.requiredWhen": { "name": "reasonIfRejected", "@field": "rejectReason", "@when": "status", "@equals": "rejected" } } { "validator.presentIff": { "name": "usedAtWhenUsed", "@field": "usedAt", "@when": "isUsed", "@equals": "true" } } { "validator.atLeastOne": { "name": "emailOrPhone", "@fields": ["email", "phone"] } } +{ "validator.exactlyOne": { "name": "oneOwner", "@fields": ["customerId", "accountId"] } } ``` These are children of `object.entity`, alongside its fields and identities. diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/physical-names.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/physical-names.md index 83f8364241..27bf59a63b 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/physical-names.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/physical-names.md @@ -89,14 +89,26 @@ literally. **Java and Python generate no SQL at all**: their DTOs and models car names, and persistence is the repository interface/`Protocol` you implement. There the artifact exists *for your code*, which is the only place a physical name appears. -Two categories stay literal, deliberately, and the gate pins them as such rather than -exempting them: - -- a **flattened value-object column** (`@storage: flattened`) is a composite — - `_` — belonging to no single field of either object, - so there is no one constant to reference; -- a **write-through entity's replica view name**: the artifact holds the object's PRIMARY - source's name (its table), and a write-through entity has two physical names. +**A flattened value object's columns are constants of the OWNING object's artifact.** A +`@storage: flattened` field has no column of its own: each member of the value object is a +column of the owner's table, named by core naming (the field's `@flattenedColumnPrefix`, +else `_`, then the member's column). The artifact carries one constant per +member, and the field itself has none, because the table has no column of that name: + +| Port | Reads as | +|---|---| +| TypeScript | `ShipmentNames.fields.shipTo.members.street.column` | +| C# | `ShipmentNames.ShipToStreetColumn` | +| Java / Kotlin | `ShipmentNames.SHIP_TO_STREET_COLUMN` | +| Python | `SHIPMENT_SHIP_TO_STREET_COLUMN` | + +The column-by-field maps (`ColumnsByField`, `COLUMNS_BY_FIELD`) key these `shipTo.street`. +A member constant that would share its name with another constant (a field `shipToStreet` +beside a flattened `shipTo.street` with a custom prefix) is left out with a warning; rename +one to get it. The generated EF Core and Exposed bindings still spell these columns as +literals, because an owned copy of an older names generator does not emit the member +constants. Without the artifact, compute the name with core naming's +`resolveFlattenedColumnName` (or its port equivalent), never by concatenating. **But prefer a typed handle where one exists — this rule has a real limit.** If the ORM gives you a type-checked object for the same thing, use that. Replacing a Drizzle column diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-verify/references/migration.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-verify/references/migration.md index 6828d995d3..f589708490 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-verify/references/migration.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-verify/references/migration.md @@ -283,6 +283,6 @@ emitted, created, diffed and dropped** — derived from the model, never written SQL. `field.enum` membership (`IN (…)`, the mapped integers for an `@intValueMap` enum), `validator.numeric` min/max, `validator.length` min/max, `validator.regex` (Postgres only — SQLite has no regex operator), and the entity-level cross-field -validators (`comparison`, `requiredWhen`, `presentIff`, `atLeastOne`) each become a +validators (`comparison`, `requiredWhen`, `presentIff`, `atLeastOne`, `exactlyOne`) each become a named `CHECK`. Array columns get none (the element rules are enforced app-side). (Partial + descending **indexes** are supported too — see Index modeling above.) diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/SKILL.md index 21a2973750..525029b69c 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/SKILL.md @@ -186,8 +186,8 @@ When folding hand validators into metadata, apply human judgment per constraint. A constraint enters **shared metadata** only if it is a **true cross-language domain invariant**; a one-consumer preference stays in a thin local refinement layer. -Cross-field rules **are** modelable (`comparison` / `atLeastOne` / `requiredWhen` / -`presentIff`); ratification decides *which* belong in shared metadata. Output a +Cross-field rules **are** modelable (`comparison` / `atLeastOne` / `exactlyOne` / +`requiredWhen` / `presentIff`); ratification decides *which* belong in shared metadata. Output a **ratification table**: KEEP-IN-METADATA / LOCAL-REFINEMENT / DROP + rationale — human-approved, never applied silently. Distinguish `required` from has-a-safe-default (`@default` often fixes the over-requiring bug). A core attr ripples cross-port; for a diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md index e145d1ccf6..71672d2a81 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -182,7 +182,8 @@ soft-delete / status / type view it models. `validator.regex`** — hunt hand field-level validation (presence, length, numeric range, array bounds, `@pattern` regex) the validator subtypes model. - **Cross-field validators** — `validator.comparison` (`@left`/`@op`/`@right`, e.g. - "end ≥ start"), `validator.atLeastOne` (one-of-N present), `validator.requiredWhen` + "end ≥ start"), `validator.atLeastOne` (one-of-N present), `validator.exactlyOne` (exactly one of N + present), `validator.requiredWhen` (conditional-required), `validator.presentIff` (mutual presence). Hunt these as hand-coded multi-field rules — they ARE modelable (see the Semantic-constraint ratification section in `SKILL.md` to decide what belongs in shared metadata vs port-local). diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md index 351893deb6..5148b26db4 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md @@ -35,7 +35,7 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend **Hunt from the declaration outward, never from code shapes inward.** (1) Build the value list from the generated artifacts, because they already carry every derived value after every transform — naming strategy, pluralized path, humanized label: read `Names` and, on TypeScript, the `.meta.ts` twin. Where the port emits no names artifact yet, derive the list from the metadata directly (`@table` / `@schema` / `@column`, else the naming strategy applied to the field name; `@values`; `@title`) and record the un-wired generator as the first finding (below). (2) Grep the non-generated tree once — code AND checked-in config, fixtures, YAML, JSON, `.env`, OpenAPI fragments — with an alternation of those VALUES as literals; distinctive values (`"email_address"`, `"/subscribers"`, `"edited_approved"`) score on the hit, generic ones (`"id"`, `"name"`, `255`) only by reading the consumer, never by count. This step is what finds the topic name, the index field, the `COPY` header and the flag key without knowing they exist. (3) Trace each hit to its CONSUMER and ask what that consumer matches it against. Matched against the schema, the generated route table, a generated wire shape or a generated label → the metadata owns it, apply the discriminator. Matched against something the app chose — its own page-route table, its own copy deck — → not owned, not this finding. (4) Only then run the shape greps — raw SQL, `fetch(` / `app.get(` / a `queryKey`, a hand validator — as the second pass, for what a value grep cannot see: a literal that has ALREADY drifted from the declared value, a concatenated or interpolated name, a case-transformed one. - **Do not flag — one rule, and its consequences.** A literal is exempt when (a) it is resolved, not compared: a typed ORM handle (a Drizzle column object, an Exposed `Column`, an EF property, a Pydantic attribute), a typed property access (`row.email`, `form.input.email`), a typed filter object, an enum member narrowed by the generated union — rewriting any of these to `Subscriber.email.name` or a string lookup swaps a checked reference for an unchecked one and is a regression (the emitted `options: [...] as const` populates a dropdown; it does not replace checked literals); (b) the metadata does not OWN it — the value coincides with a derived one but the app may change it without the metadata being wrong: a client-side page route or link (``, ``) is a navigation URL the app chose, measured on an estate where all 11 apparent `$path` hits were exactly this and the true count was 0, and converting one couples the site's URL structure to the API's; likewise the deployment prefix of a fetch URL, which the constants surface deliberately does not carry — `/api` is supplied at runtime through the client provider's `baseUrl`, never frozen at `meta gen` — so `fetch("/api/subscribers")` decomposes into a prefix with no constant by design and a `/subscribers` that is `$path`; (c) no generated constant exists for it — next paragraph; (d) it sits where a constant cannot be imported or names something no single declaration owns: a `source.rdb` `@sql` body is metadata, fingerprinted precisely because it can reference nothing; a flattened value-object composite column belongs to no single field of either object; a relationship-synthesized FK column is derived, never declared; a write-through entity's replica view name is not in the artifact, which holds the PRIMARY source's. A hand-written repository that imports `Names` is the gold standard — call it out as good. + **Do not flag — one rule, and its consequences.** A literal is exempt when (a) it is resolved, not compared: a typed ORM handle (a Drizzle column object, an Exposed `Column`, an EF property, a Pydantic attribute), a typed property access (`row.email`, `form.input.email`), a typed filter object, an enum member narrowed by the generated union — rewriting any of these to `Subscriber.email.name` or a string lookup swaps a checked reference for an unchecked one and is a regression (the emitted `options: [...] as const` populates a dropdown; it does not replace checked literals); (b) the metadata does not OWN it — the value coincides with a derived one but the app may change it without the metadata being wrong: a client-side page route or link (``, ``) is a navigation URL the app chose, measured on an estate where all 11 apparent `$path` hits were exactly this and the true count was 0, and converting one couples the site's URL structure to the API's; likewise the deployment prefix of a fetch URL, which the constants surface deliberately does not carry — `/api` is supplied at runtime through the client provider's `baseUrl`, never frozen at `meta gen` — so `fetch("/api/subscribers")` decomposes into a prefix with no constant by design and a `/subscribers` that is `$path`; (c) no generated constant exists for it — next paragraph; (d) it sits where a constant cannot be imported or names something no single declaration owns: a `source.rdb` `@sql` body is metadata, fingerprinted precisely because it can reference nothing; a relationship-synthesized FK column is derived, never declared. (A flattened value object's member column and a write-through entity's replica view name are NOT exempt: the artifact carries both, as the field's member column constant and the source in role `replica`.) A hand-written repository that imports `Names` is the gold standard — call it out as good. **Score only where a constant actually exists.** A fact declared in the project's CODEGEN CONFIG rather than in metadata — a bespoke route mount path, a hand-chosen JSON envelope key — is genuinely spelled twice when a client re-types it, but no generated constant holds it. That is a GENERATOR GAP, reported as one, never a literal-site finding: never invent a constant that the emitters do not emit. diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md index 0457d764eb..bad20b4e25 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md @@ -39,6 +39,10 @@ such, surfaced in the roadmap, but **non-failing** (the code works; the form is URL validation) — a distinct native type + behavior is a subtype, not a validated string. - A `field.string` that **holds an IP address** → recommend **`field.inet`** (native IP type; Postgres `inet` column). +- A `field.string` (or a CLR/JVM type override) that **holds a time span** (`TimeSpan`, an + `interval` column, "seconds until…") → recommend **`field.duration`** (native duration type; + Postgres `interval` column; ISO-8601 on the wire). Not a calendar span: months and years + are not durations. **Custom-provider vocabulary (adopters who register their own types/attrs):** check new/custom vocab against the ADR-0037 procedure (advisory) — e.g. *a custom subtype diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md index 01fb198e0d..ff940062ae 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -245,6 +245,7 @@ Reach for these before inventing anything. When no row fits, run the decision pr | Open JSON, no reader narrows it | `field.string` + `@dbColumnType: jsonb` | the LAST rung — see "A JSON column" | | URL / URI | `field.uri` | native `URI`/`Uri`; `text` column; strict absolute-URI validation (add `@lenient: true` to store any string) — a real native type + behavior, so a subtype (not a validated string) | | IP address | `field.inet` | native IP type; Postgres `inet` column; strict IPv4/IPv6-literal validation (add `@lenient: true` for a plain-string `text` column) | +| Elapsed time / timeout / delay | `field.duration` | native `TimeSpan`/`Duration`/`timedelta`; Postgres `interval` column; ISO-8601 on the wire (`PT1H30M`). Not for a calendar span ("every 3 months"): that is an int and a unit. Never a string or a count of seconds | | Validated plain string (email / hostname) | `field.string` + `@stringFormat` | `@stringFormat: email` or `@stringFormat: hostname` — idiomatic per-port validation; don't hand-write the `validator.regex` | Four rules behind those rows, in short (full text: `references/choosing-a-shape.md`): @@ -286,9 +287,12 @@ Reuse a constraint set across entities with an abstract `field.enum` + `extends` `@storage` controls persistence: - `flattened` — one DB column per sub-field (`address_street`, `address_city`, …). - Illegal on array fields. + Illegal on array fields. The prefix is the field's column and `_`; + `@flattenedColumnPrefix` on the field replaces it verbatim (`""` keeps the members' + own columns). Refused on any other storage. - `jsonb` — one `jsonb` column. -- `subdocument` (default, back-compat) — single jsonb column. +- `subdocument` — single jsonb column (a hint for document-store targets). +- not set (the default) — one `jsonb` column, as `jsonb`. ```json { "field.object": { "name": "address", "@objectRef": "Address", "@storage": "flattened" } } @@ -438,12 +442,18 @@ store with no `CHECK` support is still covered. PATCH and partial-update models | `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` | +| `validator.exactlyOne` | exactly one of `@fields` (2+) is present | `@fields` | + +All five take an optional `@condition`, an expression over the entity's fields (the +`origin.computed` grammar): the rule then holds only where the condition does. And every validator +that becomes a CHECK takes an optional db-provider `@constraintName` naming it. ```json { "validator.comparison": { "name": "hpInRange", "@left": "currentHp", "@op": "lte", "@right": "maxHp" } } { "validator.requiredWhen": { "name": "reasonIfRejected", "@field": "rejectReason", "@when": "status", "@equals": "rejected" } } { "validator.presentIff": { "name": "usedAtWhenUsed", "@field": "usedAt", "@when": "isUsed", "@equals": "true" } } { "validator.atLeastOne": { "name": "emailOrPhone", "@fields": ["email", "phone"] } } +{ "validator.exactlyOne": { "name": "oneOwner", "@fields": ["customerId", "accountId"] } } ``` These are children of `object.entity`, alongside its fields and identities. diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/physical-names.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/physical-names.md index 83f8364241..27bf59a63b 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/physical-names.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/physical-names.md @@ -89,14 +89,26 @@ literally. **Java and Python generate no SQL at all**: their DTOs and models car names, and persistence is the repository interface/`Protocol` you implement. There the artifact exists *for your code*, which is the only place a physical name appears. -Two categories stay literal, deliberately, and the gate pins them as such rather than -exempting them: - -- a **flattened value-object column** (`@storage: flattened`) is a composite — - `_` — belonging to no single field of either object, - so there is no one constant to reference; -- a **write-through entity's replica view name**: the artifact holds the object's PRIMARY - source's name (its table), and a write-through entity has two physical names. +**A flattened value object's columns are constants of the OWNING object's artifact.** A +`@storage: flattened` field has no column of its own: each member of the value object is a +column of the owner's table, named by core naming (the field's `@flattenedColumnPrefix`, +else `_`, then the member's column). The artifact carries one constant per +member, and the field itself has none, because the table has no column of that name: + +| Port | Reads as | +|---|---| +| TypeScript | `ShipmentNames.fields.shipTo.members.street.column` | +| C# | `ShipmentNames.ShipToStreetColumn` | +| Java / Kotlin | `ShipmentNames.SHIP_TO_STREET_COLUMN` | +| Python | `SHIPMENT_SHIP_TO_STREET_COLUMN` | + +The column-by-field maps (`ColumnsByField`, `COLUMNS_BY_FIELD`) key these `shipTo.street`. +A member constant that would share its name with another constant (a field `shipToStreet` +beside a flattened `shipTo.street` with a custom prefix) is left out with a warning; rename +one to get it. The generated EF Core and Exposed bindings still spell these columns as +literals, because an owned copy of an older names generator does not emit the member +constants. Without the artifact, compute the name with core naming's +`resolveFlattenedColumnName` (or its port equivalent), never by concatenating. **But prefer a typed handle where one exists — this rule has a real limit.** If the ORM gives you a type-checked object for the same thing, use that. Replacing a Drizzle column diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-verify/references/migration.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-verify/references/migration.md index 6828d995d3..f589708490 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-verify/references/migration.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-verify/references/migration.md @@ -283,6 +283,6 @@ emitted, created, diffed and dropped** — derived from the model, never written SQL. `field.enum` membership (`IN (…)`, the mapped integers for an `@intValueMap` enum), `validator.numeric` min/max, `validator.length` min/max, `validator.regex` (Postgres only — SQLite has no regex operator), and the entity-level cross-field -validators (`comparison`, `requiredWhen`, `presentIff`, `atLeastOne`) each become a +validators (`comparison`, `requiredWhen`, `presentIff`, `atLeastOne`, `exactlyOne`) each become a named `CHECK`. Array columns get none (the element rules are enforced app-side). (Partial + descending **indexes** are supported too — see Index modeling above.) diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md index 21a2973750..525029b69c 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/SKILL.md @@ -186,8 +186,8 @@ When folding hand validators into metadata, apply human judgment per constraint. A constraint enters **shared metadata** only if it is a **true cross-language domain invariant**; a one-consumer preference stays in a thin local refinement layer. -Cross-field rules **are** modelable (`comparison` / `atLeastOne` / `requiredWhen` / -`presentIff`); ratification decides *which* belong in shared metadata. Output a +Cross-field rules **are** modelable (`comparison` / `atLeastOne` / `exactlyOne` / +`requiredWhen` / `presentIff`); ratification decides *which* belong in shared metadata. Output a **ratification table**: KEEP-IN-METADATA / LOCAL-REFINEMENT / DROP + rationale — human-approved, never applied silently. Distinguish `required` from has-a-safe-default (`@default` often fixes the over-requiring bug). A core attr ripples cross-port; for a diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md index e145d1ccf6..71672d2a81 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -182,7 +182,8 @@ soft-delete / status / type view it models. `validator.regex`** — hunt hand field-level validation (presence, length, numeric range, array bounds, `@pattern` regex) the validator subtypes model. - **Cross-field validators** — `validator.comparison` (`@left`/`@op`/`@right`, e.g. - "end ≥ start"), `validator.atLeastOne` (one-of-N present), `validator.requiredWhen` + "end ≥ start"), `validator.atLeastOne` (one-of-N present), `validator.exactlyOne` (exactly one of N + present), `validator.requiredWhen` (conditional-required), `validator.presentIff` (mutual presence). Hunt these as hand-coded multi-field rules — they ARE modelable (see the Semantic-constraint ratification section in `SKILL.md` to decide what belongs in shared metadata vs port-local). diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md index 351893deb6..5148b26db4 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md @@ -35,7 +35,7 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend **Hunt from the declaration outward, never from code shapes inward.** (1) Build the value list from the generated artifacts, because they already carry every derived value after every transform — naming strategy, pluralized path, humanized label: read `Names` and, on TypeScript, the `.meta.ts` twin. Where the port emits no names artifact yet, derive the list from the metadata directly (`@table` / `@schema` / `@column`, else the naming strategy applied to the field name; `@values`; `@title`) and record the un-wired generator as the first finding (below). (2) Grep the non-generated tree once — code AND checked-in config, fixtures, YAML, JSON, `.env`, OpenAPI fragments — with an alternation of those VALUES as literals; distinctive values (`"email_address"`, `"/subscribers"`, `"edited_approved"`) score on the hit, generic ones (`"id"`, `"name"`, `255`) only by reading the consumer, never by count. This step is what finds the topic name, the index field, the `COPY` header and the flag key without knowing they exist. (3) Trace each hit to its CONSUMER and ask what that consumer matches it against. Matched against the schema, the generated route table, a generated wire shape or a generated label → the metadata owns it, apply the discriminator. Matched against something the app chose — its own page-route table, its own copy deck — → not owned, not this finding. (4) Only then run the shape greps — raw SQL, `fetch(` / `app.get(` / a `queryKey`, a hand validator — as the second pass, for what a value grep cannot see: a literal that has ALREADY drifted from the declared value, a concatenated or interpolated name, a case-transformed one. - **Do not flag — one rule, and its consequences.** A literal is exempt when (a) it is resolved, not compared: a typed ORM handle (a Drizzle column object, an Exposed `Column`, an EF property, a Pydantic attribute), a typed property access (`row.email`, `form.input.email`), a typed filter object, an enum member narrowed by the generated union — rewriting any of these to `Subscriber.email.name` or a string lookup swaps a checked reference for an unchecked one and is a regression (the emitted `options: [...] as const` populates a dropdown; it does not replace checked literals); (b) the metadata does not OWN it — the value coincides with a derived one but the app may change it without the metadata being wrong: a client-side page route or link (``, ``) is a navigation URL the app chose, measured on an estate where all 11 apparent `$path` hits were exactly this and the true count was 0, and converting one couples the site's URL structure to the API's; likewise the deployment prefix of a fetch URL, which the constants surface deliberately does not carry — `/api` is supplied at runtime through the client provider's `baseUrl`, never frozen at `meta gen` — so `fetch("/api/subscribers")` decomposes into a prefix with no constant by design and a `/subscribers` that is `$path`; (c) no generated constant exists for it — next paragraph; (d) it sits where a constant cannot be imported or names something no single declaration owns: a `source.rdb` `@sql` body is metadata, fingerprinted precisely because it can reference nothing; a flattened value-object composite column belongs to no single field of either object; a relationship-synthesized FK column is derived, never declared; a write-through entity's replica view name is not in the artifact, which holds the PRIMARY source's. A hand-written repository that imports `Names` is the gold standard — call it out as good. + **Do not flag — one rule, and its consequences.** A literal is exempt when (a) it is resolved, not compared: a typed ORM handle (a Drizzle column object, an Exposed `Column`, an EF property, a Pydantic attribute), a typed property access (`row.email`, `form.input.email`), a typed filter object, an enum member narrowed by the generated union — rewriting any of these to `Subscriber.email.name` or a string lookup swaps a checked reference for an unchecked one and is a regression (the emitted `options: [...] as const` populates a dropdown; it does not replace checked literals); (b) the metadata does not OWN it — the value coincides with a derived one but the app may change it without the metadata being wrong: a client-side page route or link (``, ``) is a navigation URL the app chose, measured on an estate where all 11 apparent `$path` hits were exactly this and the true count was 0, and converting one couples the site's URL structure to the API's; likewise the deployment prefix of a fetch URL, which the constants surface deliberately does not carry — `/api` is supplied at runtime through the client provider's `baseUrl`, never frozen at `meta gen` — so `fetch("/api/subscribers")` decomposes into a prefix with no constant by design and a `/subscribers` that is `$path`; (c) no generated constant exists for it — next paragraph; (d) it sits where a constant cannot be imported or names something no single declaration owns: a `source.rdb` `@sql` body is metadata, fingerprinted precisely because it can reference nothing; a relationship-synthesized FK column is derived, never declared. (A flattened value object's member column and a write-through entity's replica view name are NOT exempt: the artifact carries both, as the field's member column constant and the source in role `replica`.) A hand-written repository that imports `Names` is the gold standard — call it out as good. **Score only where a constant actually exists.** A fact declared in the project's CODEGEN CONFIG rather than in metadata — a bespoke route mount path, a hand-chosen JSON envelope key — is genuinely spelled twice when a client re-types it, but no generated constant holds it. That is a GENERATOR GAP, reported as one, never a literal-site finding: never invent a constant that the emitters do not emit. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md index 0457d764eb..bad20b4e25 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md @@ -39,6 +39,10 @@ such, surfaced in the roadmap, but **non-failing** (the code works; the form is URL validation) — a distinct native type + behavior is a subtype, not a validated string. - A `field.string` that **holds an IP address** → recommend **`field.inet`** (native IP type; Postgres `inet` column). +- A `field.string` (or a CLR/JVM type override) that **holds a time span** (`TimeSpan`, an + `interval` column, "seconds until…") → recommend **`field.duration`** (native duration type; + Postgres `interval` column; ISO-8601 on the wire). Not a calendar span: months and years + are not durations. **Custom-provider vocabulary (adopters who register their own types/attrs):** check new/custom vocab against the ADR-0037 procedure (advisory) — e.g. *a custom subtype diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index 01fb198e0d..ff940062ae 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -245,6 +245,7 @@ Reach for these before inventing anything. When no row fits, run the decision pr | Open JSON, no reader narrows it | `field.string` + `@dbColumnType: jsonb` | the LAST rung — see "A JSON column" | | URL / URI | `field.uri` | native `URI`/`Uri`; `text` column; strict absolute-URI validation (add `@lenient: true` to store any string) — a real native type + behavior, so a subtype (not a validated string) | | IP address | `field.inet` | native IP type; Postgres `inet` column; strict IPv4/IPv6-literal validation (add `@lenient: true` for a plain-string `text` column) | +| Elapsed time / timeout / delay | `field.duration` | native `TimeSpan`/`Duration`/`timedelta`; Postgres `interval` column; ISO-8601 on the wire (`PT1H30M`). Not for a calendar span ("every 3 months"): that is an int and a unit. Never a string or a count of seconds | | Validated plain string (email / hostname) | `field.string` + `@stringFormat` | `@stringFormat: email` or `@stringFormat: hostname` — idiomatic per-port validation; don't hand-write the `validator.regex` | Four rules behind those rows, in short (full text: `references/choosing-a-shape.md`): @@ -286,9 +287,12 @@ Reuse a constraint set across entities with an abstract `field.enum` + `extends` `@storage` controls persistence: - `flattened` — one DB column per sub-field (`address_street`, `address_city`, …). - Illegal on array fields. + Illegal on array fields. The prefix is the field's column and `_`; + `@flattenedColumnPrefix` on the field replaces it verbatim (`""` keeps the members' + own columns). Refused on any other storage. - `jsonb` — one `jsonb` column. -- `subdocument` (default, back-compat) — single jsonb column. +- `subdocument` — single jsonb column (a hint for document-store targets). +- not set (the default) — one `jsonb` column, as `jsonb`. ```json { "field.object": { "name": "address", "@objectRef": "Address", "@storage": "flattened" } } @@ -438,12 +442,18 @@ store with no `CHECK` support is still covered. PATCH and partial-update models | `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` | +| `validator.exactlyOne` | exactly one of `@fields` (2+) is present | `@fields` | + +All five take an optional `@condition`, an expression over the entity's fields (the +`origin.computed` grammar): the rule then holds only where the condition does. And every validator +that becomes a CHECK takes an optional db-provider `@constraintName` naming it. ```json { "validator.comparison": { "name": "hpInRange", "@left": "currentHp", "@op": "lte", "@right": "maxHp" } } { "validator.requiredWhen": { "name": "reasonIfRejected", "@field": "rejectReason", "@when": "status", "@equals": "rejected" } } { "validator.presentIff": { "name": "usedAtWhenUsed", "@field": "usedAt", "@when": "isUsed", "@equals": "true" } } { "validator.atLeastOne": { "name": "emailOrPhone", "@fields": ["email", "phone"] } } +{ "validator.exactlyOne": { "name": "oneOwner", "@fields": ["customerId", "accountId"] } } ``` These are children of `object.entity`, alongside its fields and identities. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/physical-names.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/physical-names.md index 83f8364241..27bf59a63b 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/physical-names.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/physical-names.md @@ -89,14 +89,26 @@ literally. **Java and Python generate no SQL at all**: their DTOs and models car names, and persistence is the repository interface/`Protocol` you implement. There the artifact exists *for your code*, which is the only place a physical name appears. -Two categories stay literal, deliberately, and the gate pins them as such rather than -exempting them: - -- a **flattened value-object column** (`@storage: flattened`) is a composite — - `_` — belonging to no single field of either object, - so there is no one constant to reference; -- a **write-through entity's replica view name**: the artifact holds the object's PRIMARY - source's name (its table), and a write-through entity has two physical names. +**A flattened value object's columns are constants of the OWNING object's artifact.** A +`@storage: flattened` field has no column of its own: each member of the value object is a +column of the owner's table, named by core naming (the field's `@flattenedColumnPrefix`, +else `_`, then the member's column). The artifact carries one constant per +member, and the field itself has none, because the table has no column of that name: + +| Port | Reads as | +|---|---| +| TypeScript | `ShipmentNames.fields.shipTo.members.street.column` | +| C# | `ShipmentNames.ShipToStreetColumn` | +| Java / Kotlin | `ShipmentNames.SHIP_TO_STREET_COLUMN` | +| Python | `SHIPMENT_SHIP_TO_STREET_COLUMN` | + +The column-by-field maps (`ColumnsByField`, `COLUMNS_BY_FIELD`) key these `shipTo.street`. +A member constant that would share its name with another constant (a field `shipToStreet` +beside a flattened `shipTo.street` with a custom prefix) is left out with a warning; rename +one to get it. The generated EF Core and Exposed bindings still spell these columns as +literals, because an owned copy of an older names generator does not emit the member +constants. Without the artifact, compute the name with core naming's +`resolveFlattenedColumnName` (or its port equivalent), never by concatenating. **But prefer a typed handle where one exists — this rule has a real limit.** If the ORM gives you a type-checked object for the same thing, use that. Replacing a Drizzle column diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-verify/references/migration.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-verify/references/migration.md index 6828d995d3..f589708490 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-verify/references/migration.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-verify/references/migration.md @@ -283,6 +283,6 @@ emitted, created, diffed and dropped** — derived from the model, never written SQL. `field.enum` membership (`IN (…)`, the mapped integers for an `@intValueMap` enum), `validator.numeric` min/max, `validator.length` min/max, `validator.regex` (Postgres only — SQLite has no regex operator), and the entity-level cross-field -validators (`comparison`, `requiredWhen`, `presentIff`, `atLeastOne`) each become a +validators (`comparison`, `requiredWhen`, `presentIff`, `atLeastOne`, `exactlyOne`) each become a named `CHECK`. Array columns get none (the element rules are enforced app-side). (Partial + descending **indexes** are supported too — see Index modeling above.) diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/SKILL.md index 21a2973750..525029b69c 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/SKILL.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/SKILL.md @@ -186,8 +186,8 @@ When folding hand validators into metadata, apply human judgment per constraint. A constraint enters **shared metadata** only if it is a **true cross-language domain invariant**; a one-consumer preference stays in a thin local refinement layer. -Cross-field rules **are** modelable (`comparison` / `atLeastOne` / `requiredWhen` / -`presentIff`); ratification decides *which* belong in shared metadata. Output a +Cross-field rules **are** modelable (`comparison` / `atLeastOne` / `exactlyOne` / +`requiredWhen` / `presentIff`); ratification decides *which* belong in shared metadata. Output a **ratification table**: KEEP-IN-METADATA / LOCAL-REFINEMENT / DROP + rationale — human-approved, never applied silently. Distinguish `required` from has-a-safe-default (`@default` often fixes the over-requiring bug). A core attr ripples cross-port; for a diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md index e145d1ccf6..71672d2a81 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/capability-checklist.md @@ -182,7 +182,8 @@ soft-delete / status / type view it models. `validator.regex`** — hunt hand field-level validation (presence, length, numeric range, array bounds, `@pattern` regex) the validator subtypes model. - **Cross-field validators** — `validator.comparison` (`@left`/`@op`/`@right`, e.g. - "end ≥ start"), `validator.atLeastOne` (one-of-N present), `validator.requiredWhen` + "end ≥ start"), `validator.atLeastOne` (one-of-N present), `validator.exactlyOne` (exactly one of N + present), `validator.requiredWhen` (conditional-required), `validator.presentIff` (mutual presence). Hunt these as hand-coded multi-field rules — they ARE modelable (see the Semantic-constraint ratification section in `SKILL.md` to decide what belongs in shared metadata vs port-local). diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md index 351893deb6..5148b26db4 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/drift-signatures.md @@ -35,7 +35,7 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend **Hunt from the declaration outward, never from code shapes inward.** (1) Build the value list from the generated artifacts, because they already carry every derived value after every transform — naming strategy, pluralized path, humanized label: read `Names` and, on TypeScript, the `.meta.ts` twin. Where the port emits no names artifact yet, derive the list from the metadata directly (`@table` / `@schema` / `@column`, else the naming strategy applied to the field name; `@values`; `@title`) and record the un-wired generator as the first finding (below). (2) Grep the non-generated tree once — code AND checked-in config, fixtures, YAML, JSON, `.env`, OpenAPI fragments — with an alternation of those VALUES as literals; distinctive values (`"email_address"`, `"/subscribers"`, `"edited_approved"`) score on the hit, generic ones (`"id"`, `"name"`, `255`) only by reading the consumer, never by count. This step is what finds the topic name, the index field, the `COPY` header and the flag key without knowing they exist. (3) Trace each hit to its CONSUMER and ask what that consumer matches it against. Matched against the schema, the generated route table, a generated wire shape or a generated label → the metadata owns it, apply the discriminator. Matched against something the app chose — its own page-route table, its own copy deck — → not owned, not this finding. (4) Only then run the shape greps — raw SQL, `fetch(` / `app.get(` / a `queryKey`, a hand validator — as the second pass, for what a value grep cannot see: a literal that has ALREADY drifted from the declared value, a concatenated or interpolated name, a case-transformed one. - **Do not flag — one rule, and its consequences.** A literal is exempt when (a) it is resolved, not compared: a typed ORM handle (a Drizzle column object, an Exposed `Column`, an EF property, a Pydantic attribute), a typed property access (`row.email`, `form.input.email`), a typed filter object, an enum member narrowed by the generated union — rewriting any of these to `Subscriber.email.name` or a string lookup swaps a checked reference for an unchecked one and is a regression (the emitted `options: [...] as const` populates a dropdown; it does not replace checked literals); (b) the metadata does not OWN it — the value coincides with a derived one but the app may change it without the metadata being wrong: a client-side page route or link (``, ``) is a navigation URL the app chose, measured on an estate where all 11 apparent `$path` hits were exactly this and the true count was 0, and converting one couples the site's URL structure to the API's; likewise the deployment prefix of a fetch URL, which the constants surface deliberately does not carry — `/api` is supplied at runtime through the client provider's `baseUrl`, never frozen at `meta gen` — so `fetch("/api/subscribers")` decomposes into a prefix with no constant by design and a `/subscribers` that is `$path`; (c) no generated constant exists for it — next paragraph; (d) it sits where a constant cannot be imported or names something no single declaration owns: a `source.rdb` `@sql` body is metadata, fingerprinted precisely because it can reference nothing; a flattened value-object composite column belongs to no single field of either object; a relationship-synthesized FK column is derived, never declared; a write-through entity's replica view name is not in the artifact, which holds the PRIMARY source's. A hand-written repository that imports `Names` is the gold standard — call it out as good. + **Do not flag — one rule, and its consequences.** A literal is exempt when (a) it is resolved, not compared: a typed ORM handle (a Drizzle column object, an Exposed `Column`, an EF property, a Pydantic attribute), a typed property access (`row.email`, `form.input.email`), a typed filter object, an enum member narrowed by the generated union — rewriting any of these to `Subscriber.email.name` or a string lookup swaps a checked reference for an unchecked one and is a regression (the emitted `options: [...] as const` populates a dropdown; it does not replace checked literals); (b) the metadata does not OWN it — the value coincides with a derived one but the app may change it without the metadata being wrong: a client-side page route or link (``, ``) is a navigation URL the app chose, measured on an estate where all 11 apparent `$path` hits were exactly this and the true count was 0, and converting one couples the site's URL structure to the API's; likewise the deployment prefix of a fetch URL, which the constants surface deliberately does not carry — `/api` is supplied at runtime through the client provider's `baseUrl`, never frozen at `meta gen` — so `fetch("/api/subscribers")` decomposes into a prefix with no constant by design and a `/subscribers` that is `$path`; (c) no generated constant exists for it — next paragraph; (d) it sits where a constant cannot be imported or names something no single declaration owns: a `source.rdb` `@sql` body is metadata, fingerprinted precisely because it can reference nothing; a relationship-synthesized FK column is derived, never declared. (A flattened value object's member column and a write-through entity's replica view name are NOT exempt: the artifact carries both, as the field's member column constant and the source in role `replica`.) A hand-written repository that imports `Names` is the gold standard — call it out as good. **Score only where a constant actually exists.** A fact declared in the project's CODEGEN CONFIG rather than in metadata — a bespoke route mount path, a hand-chosen JSON envelope key — is genuinely spelled twice when a client re-types it, but no generated constant holds it. That is a GENERATOR GAP, reported as one, never a literal-site finding: never invent a constant that the emitters do not emit. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md index 0457d764eb..bad20b4e25 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-audit/references/vocabulary-hygiene.md @@ -39,6 +39,10 @@ such, surfaced in the roadmap, but **non-failing** (the code works; the form is URL validation) — a distinct native type + behavior is a subtype, not a validated string. - A `field.string` that **holds an IP address** → recommend **`field.inet`** (native IP type; Postgres `inet` column). +- A `field.string` (or a CLR/JVM type override) that **holds a time span** (`TimeSpan`, an + `interval` column, "seconds until…") → recommend **`field.duration`** (native duration type; + Postgres `interval` column; ISO-8601 on the wire). Not a calendar span: months and years + are not durations. **Custom-provider vocabulary (adopters who register their own types/attrs):** check new/custom vocab against the ADR-0037 procedure (advisory) — e.g. *a custom subtype diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md index 01fb198e0d..ff940062ae 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -245,6 +245,7 @@ Reach for these before inventing anything. When no row fits, run the decision pr | Open JSON, no reader narrows it | `field.string` + `@dbColumnType: jsonb` | the LAST rung — see "A JSON column" | | URL / URI | `field.uri` | native `URI`/`Uri`; `text` column; strict absolute-URI validation (add `@lenient: true` to store any string) — a real native type + behavior, so a subtype (not a validated string) | | IP address | `field.inet` | native IP type; Postgres `inet` column; strict IPv4/IPv6-literal validation (add `@lenient: true` for a plain-string `text` column) | +| Elapsed time / timeout / delay | `field.duration` | native `TimeSpan`/`Duration`/`timedelta`; Postgres `interval` column; ISO-8601 on the wire (`PT1H30M`). Not for a calendar span ("every 3 months"): that is an int and a unit. Never a string or a count of seconds | | Validated plain string (email / hostname) | `field.string` + `@stringFormat` | `@stringFormat: email` or `@stringFormat: hostname` — idiomatic per-port validation; don't hand-write the `validator.regex` | Four rules behind those rows, in short (full text: `references/choosing-a-shape.md`): @@ -286,9 +287,12 @@ Reuse a constraint set across entities with an abstract `field.enum` + `extends` `@storage` controls persistence: - `flattened` — one DB column per sub-field (`address_street`, `address_city`, …). - Illegal on array fields. + Illegal on array fields. The prefix is the field's column and `_`; + `@flattenedColumnPrefix` on the field replaces it verbatim (`""` keeps the members' + own columns). Refused on any other storage. - `jsonb` — one `jsonb` column. -- `subdocument` (default, back-compat) — single jsonb column. +- `subdocument` — single jsonb column (a hint for document-store targets). +- not set (the default) — one `jsonb` column, as `jsonb`. ```json { "field.object": { "name": "address", "@objectRef": "Address", "@storage": "flattened" } } @@ -438,12 +442,18 @@ store with no `CHECK` support is still covered. PATCH and partial-update models | `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` | +| `validator.exactlyOne` | exactly one of `@fields` (2+) is present | `@fields` | + +All five take an optional `@condition`, an expression over the entity's fields (the +`origin.computed` grammar): the rule then holds only where the condition does. And every validator +that becomes a CHECK takes an optional db-provider `@constraintName` naming it. ```json { "validator.comparison": { "name": "hpInRange", "@left": "currentHp", "@op": "lte", "@right": "maxHp" } } { "validator.requiredWhen": { "name": "reasonIfRejected", "@field": "rejectReason", "@when": "status", "@equals": "rejected" } } { "validator.presentIff": { "name": "usedAtWhenUsed", "@field": "usedAt", "@when": "isUsed", "@equals": "true" } } { "validator.atLeastOne": { "name": "emailOrPhone", "@fields": ["email", "phone"] } } +{ "validator.exactlyOne": { "name": "oneOwner", "@fields": ["customerId", "accountId"] } } ``` These are children of `object.entity`, alongside its fields and identities. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/physical-names.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/physical-names.md index 83f8364241..27bf59a63b 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/physical-names.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/physical-names.md @@ -89,14 +89,26 @@ literally. **Java and Python generate no SQL at all**: their DTOs and models car names, and persistence is the repository interface/`Protocol` you implement. There the artifact exists *for your code*, which is the only place a physical name appears. -Two categories stay literal, deliberately, and the gate pins them as such rather than -exempting them: - -- a **flattened value-object column** (`@storage: flattened`) is a composite — - `_` — belonging to no single field of either object, - so there is no one constant to reference; -- a **write-through entity's replica view name**: the artifact holds the object's PRIMARY - source's name (its table), and a write-through entity has two physical names. +**A flattened value object's columns are constants of the OWNING object's artifact.** A +`@storage: flattened` field has no column of its own: each member of the value object is a +column of the owner's table, named by core naming (the field's `@flattenedColumnPrefix`, +else `_`, then the member's column). The artifact carries one constant per +member, and the field itself has none, because the table has no column of that name: + +| Port | Reads as | +|---|---| +| TypeScript | `ShipmentNames.fields.shipTo.members.street.column` | +| C# | `ShipmentNames.ShipToStreetColumn` | +| Java / Kotlin | `ShipmentNames.SHIP_TO_STREET_COLUMN` | +| Python | `SHIPMENT_SHIP_TO_STREET_COLUMN` | + +The column-by-field maps (`ColumnsByField`, `COLUMNS_BY_FIELD`) key these `shipTo.street`. +A member constant that would share its name with another constant (a field `shipToStreet` +beside a flattened `shipTo.street` with a custom prefix) is left out with a warning; rename +one to get it. The generated EF Core and Exposed bindings still spell these columns as +literals, because an owned copy of an older names generator does not emit the member +constants. Without the artifact, compute the name with core naming's +`resolveFlattenedColumnName` (or its port equivalent), never by concatenating. **But prefer a typed handle where one exists — this rule has a real limit.** If the ORM gives you a type-checked object for the same thing, use that. Replacing a Drizzle column diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-verify/references/migration.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-verify/references/migration.md index 6828d995d3..f589708490 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-verify/references/migration.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-verify/references/migration.md @@ -283,6 +283,6 @@ emitted, created, diffed and dropped** — derived from the model, never written SQL. `field.enum` membership (`IN (…)`, the mapped integers for an `@intValueMap` enum), `validator.numeric` min/max, `validator.length` min/max, `validator.regex` (Postgres only — SQLite has no regex operator), and the entity-level cross-field -validators (`comparison`, `requiredWhen`, `presentIff`, `atLeastOne`) each become a +validators (`comparison`, `requiredWhen`, `presentIff`, `atLeastOne`, `exactlyOne`) each become a named `CHECK`. Array columns get none (the element rules are enforced app-side). (Partial + descending **indexes** are supported too — see Index modeling above.) diff --git a/fixtures/api-contract-conformance/.metaobjects/config.json b/fixtures/api-contract-conformance/.metaobjects/config.json index d6d2b1359d..3a5a786b8c 100644 --- a/fixtures/api-contract-conformance/.metaobjects/config.json +++ b/fixtures/api-contract-conformance/.metaobjects/config.json @@ -1,5 +1,5 @@ { "schema_version": 1, "naming": { "columns": "literal" }, - "wire": { "decimals": "string", "nullMembers": "omit" } + "wire": { "decimals": "string", "nullMembers": "omit", "durations": "iso" } } diff --git a/fixtures/api-contract-conformance/README.md b/fixtures/api-contract-conformance/README.md index 7574beeab5..2e49a231e3 100644 --- a/fixtures/api-contract-conformance/README.md +++ b/fixtures/api-contract-conformance/README.md @@ -52,8 +52,8 @@ fixtures/api-contract-conformance/ └── filter-invalid-value.yaml # uncoercible value → 400; the fourth envelope, previously ungated ``` -Beside the 31 core scenarios above sit eight **sub-corpora**, each a directory with its -own `meta.json`, `seed.json`, `scenarios/` and `README.md` (88 scenarios in all, 119 with the +Beside the 31 core scenarios above sit nine **sub-corpora**, each a directory with its +own `meta.json`, `seed.json`, `scenarios/` and `README.md` (90 scenarios in all, 121 with the core): | Directory | Scenarios | Gates | Lanes | @@ -66,6 +66,7 @@ core): | `projection/` | 12 | a read-only `object.projection`: reads served, every write `405`; no declared identity means no `/{id}` route; a key not named `id` addresses the row; decimal and float filters. Its `docs-routes.json` pins the routes each port documents | generated only | | `report/` | 37 | a view-backed `object.report` (FR-044): list, filter, sort and paging on derived fields, `POST` is `405`, no `/{id}` route; a `@spine` report keeps a row with no facts, and a measure with `@default` filters and sorts as its default; a dimension over an array or a map is neither filterable nor sortable. Also carries `schema.postgres.sql`, the TypeScript-produced table and views the C# lane executes | generated only | | `decimal/` | 11 | `field.decimal` is an exact canonical string on the wire (ADR-0019 Amendment 1): read, filter, sort, write and a report over decimals. A scenario's `engines:` key is exact; the `-15` twins run on SQLite and D1 as well. Also carries `schema.postgres.sql` / `schema.sqlite.sql` / `schema.mysql.sql` | generated only | +| `duration/` | 2 | `field.duration` is a canonical ISO-8601 span on the wire under `"wire": { "durations": "iso" }`: write, read, refuse, and filter and sort as a span on every engine. Also carries `schema.postgres.sql` / `schema.sqlite.sql` / `schema.mysql.sql` | generated only | `meta.json` declares a single canonical `Author` entity in the `acme::blog` package: diff --git a/fixtures/api-contract-conformance/duration/README.md b/fixtures/api-contract-conformance/duration/README.md new file mode 100644 index 0000000000..82026ccaa3 --- /dev/null +++ b/fixtures/api-contract-conformance/duration/README.md @@ -0,0 +1,46 @@ +# `api-contract-conformance/duration/` — a duration is an ISO-8601 span on the wire + +Cross-port REST contract for `field.duration`, over one writable entity: `Timer` (table `timers`, +served at `/api/timers`) with a nullable, filterable, sortable `timeout`. The corpus config +(`../.metaobjects/config.json`) pins `"wire": { "durations": "iso" }`, so every port writes the +same canonical form. Under the default `native` setting each port writes its own serializer's form +and this corpus does not apply (`docs/features/api-contract.md`, "The wire setting"). + +## The rule (D1 to D4) + +- **D1, emit.** A duration is a JSON string in the canonical form `[-]PT[nH][nM][n[.f]S]`: hours + are never folded into days (`PT26H`), zero components are left out, zero is `PT0S`, and a + fraction has no trailing zeros. `null` stays `null`. +- **D2, accept.** A request body carries the accepted form: optional leading weeks (`P1W` is 7 days) + and days (`P1DT2H` is 26 hours), then hours, minutes and seconds. It is stored as the span and + answered canonical (`PT5400S` is answered `PT1H30M`). +- **D3, refuse.** Years and months (a calendar span has no fixed length), a clock spelling + (`01:30:00`), lowercase designators and a JSON number are `400 {"error": "validation"}` and + persist nothing. +- **D4, order.** A filter operand uses the D2 grammar and compares spans, never text: `gt`, `lte` + and a sort take in a negative span, `eq PT5400S` matches `PT1H30M`, and `in` matches across + spellings. A bad operand is `400 {"error": "invalid_filter_value", "field": "timeout"}`. Postgres + holds an `interval`; SQLite, D1 and MySQL a BIGINT count of microseconds, which orders the same way. + +The wire grammar and its vectors are in [`fixtures/duration-wire/`](../../duration-wire/). Neither +scenario carries an `engines:` key, so both run on every engine a port's lane has. + +## Scenarios + +| Scenario | What it pins | +|---|---| +| `duration-write-read` | POST and GET by id answer the canonical form for an hour-and-minutes span, days, seconds past an hour, a week, a negative span, zero and a fraction; an absent `timeout` reads `null`; PATCH replaces; `P1M`, `01:30:00`, `pt1h` and `5400` are `400 validation` and persist nothing | +| `duration-filter-sort` | five rows written by POST, then `gt` with a descending sort, `lte` with an ascending sort over a negative span, `eq` and `in` across spellings, `isNull` both ways with `limit`, and two bad operands | + +## Files + +``` +duration/ +├── README.md +├── meta.json # Timer (/api/timers) +├── seed.json # `timers` (empty, so the first POST is id 1) +├── schema.postgres.sql # TypeScript-produced: `timeout` is INTERVAL +├── schema.sqlite.sql # the same, for SQLite and D1: `timeout` is INTEGER (microseconds) +├── schema.mysql.sql # MySQL 8.4: the hand-written table, `timeout` BIGINT (microseconds) +└── scenarios/ +``` diff --git a/fixtures/api-contract-conformance/duration/meta.json b/fixtures/api-contract-conformance/duration/meta.json new file mode 100644 index 0000000000..a3d0a27584 --- /dev/null +++ b/fixtures/api-contract-conformance/duration/meta.json @@ -0,0 +1,12 @@ +{ "metadata.root": { + "package": "acme::ops", + "children": [ + { "object.entity": { "name": "Timer", "children": [ + { "source.rdb": { "@table": "timers" } }, + { "field.long": { "name": "id", "@filterable": true, "@sortable": true } }, + { "field.string": { "name": "label", "@required": true, "@maxLength": 16, "@filterable": true } }, + { "field.duration": { "name": "timeout", "@filterable": true, "@sortable": true } }, + { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "increment" } } + ] } } + ] +} } diff --git a/fixtures/api-contract-conformance/duration/scenarios/duration-filter-sort.yaml b/fixtures/api-contract-conformance/duration/scenarios/duration-filter-sort.yaml new file mode 100644 index 0000000000..67a419d8f7 --- /dev/null +++ b/fixtures/api-contract-conformance/duration/scenarios/duration-filter-sort.yaml @@ -0,0 +1,80 @@ +name: duration-filter-sort +description: > + A duration filters and sorts as the span it is, never as text, on every engine (D4): Postgres keeps + an interval, SQLite, D1 and MySQL a 64-bit count of microseconds. A range filter takes in a + negative span, equality holds across spellings (PT5400S is PT1H30M), and a bad operand is + 400 invalid_filter_value naming the field. +requests: + - { id: s1, method: POST, path: /api/timers, body: { label: thirty, timeout: "PT30M" }, expect: { status: 201 } } + - { id: s2, method: POST, path: /api/timers, body: { label: ninety, timeout: "PT1H30M" }, expect: { status: 201 } } + - { id: s3, method: POST, path: /api/timers, body: { label: minus, timeout: "-PT1M30S" }, expect: { status: 201 } } + - { id: s4, method: POST, path: /api/timers, body: { label: long, timeout: "PT26H" }, expect: { status: 201 } } + - { id: s5, method: POST, path: /api/timers, body: { label: none }, expect: { status: 201 } } + - id: r1 + method: GET + path: /api/timers?filter[timeout][gt]=PT1H&sort=timeout:desc + expect: + status: 200 + body: + equals: + - { id: 4, label: long, timeout: "PT26H" } + - { id: 2, label: ninety, timeout: "PT1H30M" } + - id: r2 + method: GET + path: /api/timers?filter[timeout][lte]=PT30M&sort=timeout:asc + expect: + status: 200 + body: + equals: + - { id: 3, label: minus, timeout: "-PT1M30S" } + - { id: 1, label: thirty, timeout: "PT30M" } + - id: r3 + method: GET + path: /api/timers?filter[timeout][eq]=PT5400S + expect: + status: 200 + body: + equals: + - { id: 2, label: ninety, timeout: "PT1H30M" } + - id: r4 + method: GET + path: /api/timers?filter[timeout][in]=PT30M,P1DT2H&sort=timeout:asc + expect: + status: 200 + body: + equals: + - { id: 1, label: thirty, timeout: "PT30M" } + - { id: 4, label: long, timeout: "PT26H" } + - id: r5 + method: GET + path: /api/timers?filter[timeout][isNull]=true + expect: + status: 200 + body: + equals: + - { id: 5, label: none, timeout: null } + - id: r6 + method: GET + path: /api/timers?filter[timeout][isNull]=false&sort=timeout:asc&limit=2 + expect: + status: 200 + body: + equals: + - { id: 3, label: minus, timeout: "-PT1M30S" } + - { id: 1, label: thirty, timeout: "PT30M" } + - id: r7 + method: GET + path: /api/timers?filter[timeout][gt]=P1M + expect: + status: 400 + body: + error: invalid_filter_value + field: timeout + - id: r8 + method: GET + path: /api/timers?filter[timeout][gt]=soon + expect: + status: 400 + body: + error: invalid_filter_value + field: timeout diff --git a/fixtures/api-contract-conformance/duration/scenarios/duration-write-read.yaml b/fixtures/api-contract-conformance/duration/scenarios/duration-write-read.yaml new file mode 100644 index 0000000000..669f7e1087 --- /dev/null +++ b/fixtures/api-contract-conformance/duration/scenarios/duration-write-read.yaml @@ -0,0 +1,132 @@ +name: duration-write-read +description: > + A field.duration is written and read as an ISO-8601 duration under "wire": { "durations": "iso" } + (D1, D2). Every port answers the one canonical form, [-]PT[nH][nM][n[.f]S]: hours are never folded + into days, a week is 7 days and a day 24 hours, and zero is PT0S. Years, months, a clock spelling, + lowercase designators and a JSON number are 400 validation (D3) and persist nothing. +requests: + - id: r1 + method: POST + path: /api/timers + body: { label: plain, timeout: "PT1H30M" } + expect: + status: 201 + body: + row: { id: 1, label: plain, timeout: "PT1H30M" } + - id: r2 + method: GET + path: /api/timers/1 + expect: + status: 200 + body: + row: { id: 1, label: plain, timeout: "PT1H30M" } + - id: r3 + method: POST + path: /api/timers + body: { label: days, timeout: "P1DT2H" } + expect: + status: 201 + body: + row: { label: days, timeout: "PT26H" } + - id: r4 + method: POST + path: /api/timers + body: { label: seconds, timeout: "PT5400S" } + expect: + status: 201 + body: + row: { label: seconds, timeout: "PT1H30M" } + - id: r5 + method: POST + path: /api/timers + body: { label: week, timeout: "P1W" } + expect: + status: 201 + body: + row: { label: week, timeout: "PT168H" } + - id: r6 + method: POST + path: /api/timers + body: { label: negative, timeout: "-PT1M30S" } + expect: + status: 201 + body: + row: { label: negative, timeout: "-PT1M30S" } + - id: r7 + method: POST + path: /api/timers + body: { label: zero, timeout: "PT0S" } + expect: + status: 201 + body: + row: { label: zero, timeout: "PT0S" } + - id: r8 + method: POST + path: /api/timers + body: { label: fraction, timeout: "PT1.5S" } + expect: + status: 201 + body: + row: { label: fraction, timeout: "PT1.5S" } + - id: r9 + method: POST + path: /api/timers + body: { label: unset } + expect: + status: 201 + body: + row: { label: unset, timeout: null } + - id: r10 + method: PATCH + path: /api/timers/1 + body: { timeout: "PT45M" } + expect: + status: 200 + body: + row: { id: 1, label: plain, timeout: "PT45M" } + - id: r11 + method: GET + path: /api/timers/1 + expect: + status: 200 + body: + row: { id: 1, label: plain, timeout: "PT45M" } + - id: r12 + method: POST + path: /api/timers + body: { label: bad, timeout: "P1M" } + expect: + status: 400 + body: + error: validation + - id: r13 + method: POST + path: /api/timers + body: { label: bad, timeout: "01:30:00" } + expect: + status: 400 + body: + error: validation + - id: r14 + method: POST + path: /api/timers + body: { label: bad, timeout: "pt1h" } + expect: + status: 400 + body: + error: validation + - id: r15 + method: POST + path: /api/timers + body: { label: bad, timeout: 5400 } + expect: + status: 400 + body: + error: validation + - id: r16 + method: GET + path: /api/timers?filter[label][eq]=bad + expect: + status: 200 + body: + length: 0 diff --git a/fixtures/api-contract-conformance/duration/schema.mysql.sql b/fixtures/api-contract-conformance/duration/schema.mysql.sql new file mode 100644 index 0000000000..fec083bf12 --- /dev/null +++ b/fixtures/api-contract-conformance/duration/schema.mysql.sql @@ -0,0 +1,9 @@ +-- @generated by @metaobjectsdev/integration-tests — DO NOT EDIT. +-- api-contract duration sub-corpus schema (MySQL 8.4, literal column naming). +-- Regenerate: `bun run gen:duration-api-schema` in server/typescript/packages/integration-tests. + +CREATE TABLE `timers` ( + `id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `label` VARCHAR(16) NOT NULL, + `timeout` BIGINT +); diff --git a/fixtures/api-contract-conformance/duration/schema.postgres.sql b/fixtures/api-contract-conformance/duration/schema.postgres.sql new file mode 100644 index 0000000000..887a56ab0e --- /dev/null +++ b/fixtures/api-contract-conformance/duration/schema.postgres.sql @@ -0,0 +1,10 @@ +-- @generated by @metaobjectsdev/integration-tests — DO NOT EDIT. +-- api-contract duration sub-corpus schema (Postgres, literal column naming). +-- Regenerate: `bun run gen:duration-api-schema` in server/typescript/packages/integration-tests. + +CREATE TABLE "timers" ( + "id" BIGINT GENERATED BY DEFAULT AS IDENTITY NOT NULL, + "label" VARCHAR(16) NOT NULL, + "timeout" INTERVAL, + CONSTRAINT "timers_pkey" PRIMARY KEY ("id") +); diff --git a/fixtures/api-contract-conformance/duration/schema.sqlite.sql b/fixtures/api-contract-conformance/duration/schema.sqlite.sql new file mode 100644 index 0000000000..26a265173a --- /dev/null +++ b/fixtures/api-contract-conformance/duration/schema.sqlite.sql @@ -0,0 +1,9 @@ +-- @generated by @metaobjectsdev/integration-tests — DO NOT EDIT. +-- api-contract duration sub-corpus schema (SQLite, literal column naming). +-- Regenerate: `bun run gen:duration-api-schema` in server/typescript/packages/integration-tests. + +CREATE TABLE "timers" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + "label" VARCHAR(16) NOT NULL, + "timeout" INTEGER +); diff --git a/fixtures/api-contract-conformance/duration/seed.json b/fixtures/api-contract-conformance/duration/seed.json new file mode 100644 index 0000000000..a60fbf99e8 --- /dev/null +++ b/fixtures/api-contract-conformance/duration/seed.json @@ -0,0 +1 @@ +{ "timers": [] } diff --git a/fixtures/contract-conformance/all-fields-wire-string-emit/case.json b/fixtures/contract-conformance/all-fields-wire-string-emit/case.json index d65a4fc249..a46445a727 100644 --- a/fixtures/contract-conformance/all-fields-wire-string-emit/case.json +++ b/fixtures/contract-conformance/all-fields-wire-string-emit/case.json @@ -1 +1 @@ -{ "meta": "fixtures/contract-conformance/all-fields/meta.json", "wire": { "decimals": "string", "nullMembers": "emit" }, "strictExclude": ["Bag", "Tree"] } +{ "meta": "fixtures/contract-conformance/all-fields/meta.json", "wire": { "decimals": "string", "nullMembers": "emit", "durations": "iso" }, "strictExclude": ["Bag", "Tree"] } diff --git a/fixtures/contract-conformance/all-fields-wire-string-emit/expected/openapi.json b/fixtures/contract-conformance/all-fields-wire-string-emit/expected/openapi.json index ee79a8a7ec..abb6483b2d 100644 --- a/fixtures/contract-conformance/all-fields-wire-string-emit/expected/openapi.json +++ b/fixtures/contract-conformance/all-fields-wire-string-emit/expected/openapi.json @@ -857,6 +857,85 @@ "type": "boolean" } }, + { + "name": "filter[holdFor]", + "in": "query", + "description": "Sugar for the eq operator.", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][eq]", + "in": "query", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][ne]", + "in": "query", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][gt]", + "in": "query", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][gte]", + "in": "query", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][lt]", + "in": "query", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][lte]", + "in": "query", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][in]", + "in": "query", + "schema": { + "type": "string", + "description": "Comma-separated values." + } + }, + { + "name": "filter[holdFor][isNull]", + "in": "query", + "schema": { + "type": "boolean" + } + }, { "name": "sort", "in": "query", @@ -904,7 +983,10 @@ "status:desc", "stage", "stage:asc", - "stage:desc" + "stage:desc", + "holdFor", + "holdFor:asc", + "holdFor:desc" ] } }, @@ -1716,6 +1798,14 @@ } ] }, + "holdFor": { + "type": [ + "string", + "null" + ], + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + }, "shipTo": { "anyOf": [ { @@ -1800,6 +1890,7 @@ "homepage", "looseLink", "address", + "holdFor", "shipTo", "stops", "tags", @@ -1913,6 +2004,11 @@ } ] }, + "holdFor": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + }, "shipTo": { "$ref": "#/components/schemas/AddressCreate" }, @@ -2116,6 +2212,14 @@ } ] }, + "holdFor": { + "type": [ + "string", + "null" + ], + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + }, "shipTo": { "anyOf": [ { diff --git a/fixtures/contract-conformance/all-fields-wire-string-emit/expected/schemas/acme/contract/AllFields.create.schema.json b/fixtures/contract-conformance/all-fields-wire-string-emit/expected/schemas/acme/contract/AllFields.create.schema.json index 9617555a35..6b274c2876 100644 --- a/fixtures/contract-conformance/all-fields-wire-string-emit/expected/schemas/acme/contract/AllFields.create.schema.json +++ b/fixtures/contract-conformance/all-fields-wire-string-emit/expected/schemas/acme/contract/AllFields.create.schema.json @@ -103,6 +103,11 @@ } ] }, + "holdFor": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + }, "shipTo": { "$ref": "./Address.create.schema.json" }, diff --git a/fixtures/contract-conformance/all-fields-wire-string-emit/expected/schemas/acme/contract/AllFields.schema.json b/fixtures/contract-conformance/all-fields-wire-string-emit/expected/schemas/acme/contract/AllFields.schema.json index e9b3852a45..2336826976 100644 --- a/fixtures/contract-conformance/all-fields-wire-string-emit/expected/schemas/acme/contract/AllFields.schema.json +++ b/fixtures/contract-conformance/all-fields-wire-string-emit/expected/schemas/acme/contract/AllFields.schema.json @@ -151,6 +151,14 @@ } ] }, + "holdFor": { + "type": [ + "string", + "null" + ], + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + }, "shipTo": { "anyOf": [ { @@ -235,6 +243,7 @@ "homepage", "looseLink", "address", + "holdFor", "shipTo", "stops", "tags", diff --git a/fixtures/contract-conformance/all-fields-wire-string-emit/expected/schemas/acme/contract/AllFields.strict.schema.json b/fixtures/contract-conformance/all-fields-wire-string-emit/expected/schemas/acme/contract/AllFields.strict.schema.json index 61e121e4bf..3f403bcb48 100644 --- a/fixtures/contract-conformance/all-fields-wire-string-emit/expected/schemas/acme/contract/AllFields.strict.schema.json +++ b/fixtures/contract-conformance/all-fields-wire-string-emit/expected/schemas/acme/contract/AllFields.strict.schema.json @@ -129,6 +129,13 @@ "null" ] }, + "holdFor": { + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months). Pattern: ^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$.", + "type": [ + "string", + "null" + ] + }, "shipTo": { "description": "A postal address.", "type": [ @@ -235,6 +242,7 @@ "homepage", "looseLink", "address", + "holdFor", "shipTo", "stops", "tags", diff --git a/fixtures/contract-conformance/all-fields-wire-string-emit/expected/schemas/acme/contract/AllFields.update.schema.json b/fixtures/contract-conformance/all-fields-wire-string-emit/expected/schemas/acme/contract/AllFields.update.schema.json index 5e867a0dcf..9b3af60d63 100644 --- a/fixtures/contract-conformance/all-fields-wire-string-emit/expected/schemas/acme/contract/AllFields.update.schema.json +++ b/fixtures/contract-conformance/all-fields-wire-string-emit/expected/schemas/acme/contract/AllFields.update.schema.json @@ -150,6 +150,14 @@ } ] }, + "holdFor": { + "type": [ + "string", + "null" + ], + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + }, "shipTo": { "anyOf": [ { diff --git a/fixtures/contract-conformance/all-fields-wire-string-omit/case.json b/fixtures/contract-conformance/all-fields-wire-string-omit/case.json index d68f000263..af019c30f0 100644 --- a/fixtures/contract-conformance/all-fields-wire-string-omit/case.json +++ b/fixtures/contract-conformance/all-fields-wire-string-omit/case.json @@ -1 +1 @@ -{ "meta": "fixtures/contract-conformance/all-fields/meta.json", "wire": { "decimals": "string", "nullMembers": "omit" }, "strictExclude": ["Bag", "Tree"] } +{ "meta": "fixtures/contract-conformance/all-fields/meta.json", "wire": { "decimals": "string", "nullMembers": "omit", "durations": "iso" }, "strictExclude": ["Bag", "Tree"] } diff --git a/fixtures/contract-conformance/all-fields-wire-string-omit/expected/openapi.json b/fixtures/contract-conformance/all-fields-wire-string-omit/expected/openapi.json index 0f3e79d1c5..0df2287e3d 100644 --- a/fixtures/contract-conformance/all-fields-wire-string-omit/expected/openapi.json +++ b/fixtures/contract-conformance/all-fields-wire-string-omit/expected/openapi.json @@ -857,6 +857,85 @@ "type": "boolean" } }, + { + "name": "filter[holdFor]", + "in": "query", + "description": "Sugar for the eq operator.", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][eq]", + "in": "query", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][ne]", + "in": "query", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][gt]", + "in": "query", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][gte]", + "in": "query", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][lt]", + "in": "query", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][lte]", + "in": "query", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][in]", + "in": "query", + "schema": { + "type": "string", + "description": "Comma-separated values." + } + }, + { + "name": "filter[holdFor][isNull]", + "in": "query", + "schema": { + "type": "boolean" + } + }, { "name": "sort", "in": "query", @@ -904,7 +983,10 @@ "status:desc", "stage", "stage:asc", - "stage:desc" + "stage:desc", + "holdFor", + "holdFor:asc", + "holdFor:desc" ] } }, @@ -1708,6 +1790,14 @@ } ] }, + "holdFor": { + "type": [ + "string", + "null" + ], + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + }, "shipTo": { "anyOf": [ { @@ -1792,6 +1882,7 @@ "homepage", "looseLink", "address", + "holdFor", "shipTo", "stops", "tags", @@ -1905,6 +1996,11 @@ } ] }, + "holdFor": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + }, "shipTo": { "$ref": "#/components/schemas/AddressCreate" }, @@ -2108,6 +2204,14 @@ } ] }, + "holdFor": { + "type": [ + "string", + "null" + ], + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + }, "shipTo": { "anyOf": [ { diff --git a/fixtures/contract-conformance/all-fields-wire-string-omit/expected/schemas/acme/contract/AllFields.create.schema.json b/fixtures/contract-conformance/all-fields-wire-string-omit/expected/schemas/acme/contract/AllFields.create.schema.json index 9617555a35..6b274c2876 100644 --- a/fixtures/contract-conformance/all-fields-wire-string-omit/expected/schemas/acme/contract/AllFields.create.schema.json +++ b/fixtures/contract-conformance/all-fields-wire-string-omit/expected/schemas/acme/contract/AllFields.create.schema.json @@ -103,6 +103,11 @@ } ] }, + "holdFor": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + }, "shipTo": { "$ref": "./Address.create.schema.json" }, diff --git a/fixtures/contract-conformance/all-fields-wire-string-omit/expected/schemas/acme/contract/AllFields.schema.json b/fixtures/contract-conformance/all-fields-wire-string-omit/expected/schemas/acme/contract/AllFields.schema.json index e9b3852a45..2336826976 100644 --- a/fixtures/contract-conformance/all-fields-wire-string-omit/expected/schemas/acme/contract/AllFields.schema.json +++ b/fixtures/contract-conformance/all-fields-wire-string-omit/expected/schemas/acme/contract/AllFields.schema.json @@ -151,6 +151,14 @@ } ] }, + "holdFor": { + "type": [ + "string", + "null" + ], + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + }, "shipTo": { "anyOf": [ { @@ -235,6 +243,7 @@ "homepage", "looseLink", "address", + "holdFor", "shipTo", "stops", "tags", diff --git a/fixtures/contract-conformance/all-fields-wire-string-omit/expected/schemas/acme/contract/AllFields.strict.schema.json b/fixtures/contract-conformance/all-fields-wire-string-omit/expected/schemas/acme/contract/AllFields.strict.schema.json index 61e121e4bf..3f403bcb48 100644 --- a/fixtures/contract-conformance/all-fields-wire-string-omit/expected/schemas/acme/contract/AllFields.strict.schema.json +++ b/fixtures/contract-conformance/all-fields-wire-string-omit/expected/schemas/acme/contract/AllFields.strict.schema.json @@ -129,6 +129,13 @@ "null" ] }, + "holdFor": { + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months). Pattern: ^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$.", + "type": [ + "string", + "null" + ] + }, "shipTo": { "description": "A postal address.", "type": [ @@ -235,6 +242,7 @@ "homepage", "looseLink", "address", + "holdFor", "shipTo", "stops", "tags", diff --git a/fixtures/contract-conformance/all-fields-wire-string-omit/expected/schemas/acme/contract/AllFields.update.schema.json b/fixtures/contract-conformance/all-fields-wire-string-omit/expected/schemas/acme/contract/AllFields.update.schema.json index 5e867a0dcf..9b3af60d63 100644 --- a/fixtures/contract-conformance/all-fields-wire-string-omit/expected/schemas/acme/contract/AllFields.update.schema.json +++ b/fixtures/contract-conformance/all-fields-wire-string-omit/expected/schemas/acme/contract/AllFields.update.schema.json @@ -150,6 +150,14 @@ } ] }, + "holdFor": { + "type": [ + "string", + "null" + ], + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + }, "shipTo": { "anyOf": [ { diff --git a/fixtures/contract-conformance/all-fields/expected/openapi.json b/fixtures/contract-conformance/all-fields/expected/openapi.json index 975be7c0ab..5a0735ad62 100644 --- a/fixtures/contract-conformance/all-fields/expected/openapi.json +++ b/fixtures/contract-conformance/all-fields/expected/openapi.json @@ -857,6 +857,85 @@ "type": "boolean" } }, + { + "name": "filter[holdFor]", + "in": "query", + "description": "Sugar for the eq operator.", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][eq]", + "in": "query", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][ne]", + "in": "query", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][gt]", + "in": "query", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][gte]", + "in": "query", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][lt]", + "in": "query", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][lte]", + "in": "query", + "schema": { + "type": "string", + "pattern": "^[-+]?P(([0-9]+W([0-9]+D)?|[0-9]+D)(T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))?|T([0-9]+H([0-9]+M)?([0-9]+(\\.[0-9]+)?S)?|[0-9]+M([0-9]+(\\.[0-9]+)?S)?|[0-9]+(\\.[0-9]+)?S))$", + "description": "An ISO-8601 duration: weeks, days, hours, minutes and seconds (no years or months)." + } + }, + { + "name": "filter[holdFor][in]", + "in": "query", + "schema": { + "type": "string", + "description": "Comma-separated values." + } + }, + { + "name": "filter[holdFor][isNull]", + "in": "query", + "schema": { + "type": "boolean" + } + }, { "name": "sort", "in": "query", @@ -904,7 +983,10 @@ "status:desc", "stage", "stage:asc", - "stage:desc" + "stage:desc", + "holdFor", + "holdFor:asc", + "holdFor:desc" ] } }, @@ -1716,6 +1798,13 @@ } ] }, + "holdFor": { + "type": [ + "string", + "null" + ], + "description": "A span of time, in the serving language's own form. Set wire.durations to \"iso\" to fix ISO-8601 across server languages." + }, "shipTo": { "anyOf": [ { @@ -1800,6 +1889,7 @@ "homepage", "looseLink", "address", + "holdFor", "shipTo", "stops", "tags", @@ -1913,6 +2003,10 @@ } ] }, + "holdFor": { + "type": "string", + "description": "A span of time, in the serving language's own form. Set wire.durations to \"iso\" to fix ISO-8601 across server languages." + }, "shipTo": { "$ref": "#/components/schemas/AddressCreate" }, @@ -2116,6 +2210,13 @@ } ] }, + "holdFor": { + "type": [ + "string", + "null" + ], + "description": "A span of time, in the serving language's own form. Set wire.durations to \"iso\" to fix ISO-8601 across server languages." + }, "shipTo": { "anyOf": [ { diff --git a/fixtures/contract-conformance/all-fields/expected/schemas/acme/contract/AllFields.create.schema.json b/fixtures/contract-conformance/all-fields/expected/schemas/acme/contract/AllFields.create.schema.json index 9617555a35..254d4b06ac 100644 --- a/fixtures/contract-conformance/all-fields/expected/schemas/acme/contract/AllFields.create.schema.json +++ b/fixtures/contract-conformance/all-fields/expected/schemas/acme/contract/AllFields.create.schema.json @@ -103,6 +103,10 @@ } ] }, + "holdFor": { + "type": "string", + "description": "A span of time, in the serving language's own form. Set wire.durations to \"iso\" to fix ISO-8601 across server languages." + }, "shipTo": { "$ref": "./Address.create.schema.json" }, diff --git a/fixtures/contract-conformance/all-fields/expected/schemas/acme/contract/AllFields.schema.json b/fixtures/contract-conformance/all-fields/expected/schemas/acme/contract/AllFields.schema.json index 3b7f7d4c4b..c15b124832 100644 --- a/fixtures/contract-conformance/all-fields/expected/schemas/acme/contract/AllFields.schema.json +++ b/fixtures/contract-conformance/all-fields/expected/schemas/acme/contract/AllFields.schema.json @@ -152,6 +152,13 @@ } ] }, + "holdFor": { + "type": [ + "string", + "null" + ], + "description": "A span of time, in the serving language's own form. Set wire.durations to \"iso\" to fix ISO-8601 across server languages." + }, "shipTo": { "anyOf": [ { @@ -236,6 +243,7 @@ "homepage", "looseLink", "address", + "holdFor", "shipTo", "stops", "tags", diff --git a/fixtures/contract-conformance/all-fields/expected/schemas/acme/contract/AllFields.strict.schema.json b/fixtures/contract-conformance/all-fields/expected/schemas/acme/contract/AllFields.strict.schema.json index 61e121e4bf..b7d000c544 100644 --- a/fixtures/contract-conformance/all-fields/expected/schemas/acme/contract/AllFields.strict.schema.json +++ b/fixtures/contract-conformance/all-fields/expected/schemas/acme/contract/AllFields.strict.schema.json @@ -129,6 +129,13 @@ "null" ] }, + "holdFor": { + "description": "A span of time, in the serving language's own form. Set wire.durations to \"iso\" to fix ISO-8601 across server languages.", + "type": [ + "string", + "null" + ] + }, "shipTo": { "description": "A postal address.", "type": [ @@ -235,6 +242,7 @@ "homepage", "looseLink", "address", + "holdFor", "shipTo", "stops", "tags", diff --git a/fixtures/contract-conformance/all-fields/expected/schemas/acme/contract/AllFields.update.schema.json b/fixtures/contract-conformance/all-fields/expected/schemas/acme/contract/AllFields.update.schema.json index 5e867a0dcf..9ff9fd5721 100644 --- a/fixtures/contract-conformance/all-fields/expected/schemas/acme/contract/AllFields.update.schema.json +++ b/fixtures/contract-conformance/all-fields/expected/schemas/acme/contract/AllFields.update.schema.json @@ -150,6 +150,13 @@ } ] }, + "holdFor": { + "type": [ + "string", + "null" + ], + "description": "A span of time, in the serving language's own form. Set wire.durations to \"iso\" to fix ISO-8601 across server languages." + }, "shipTo": { "anyOf": [ { diff --git a/fixtures/contract-conformance/all-fields/meta.json b/fixtures/contract-conformance/all-fields/meta.json index 19cb18136e..467b76126c 100644 --- a/fixtures/contract-conformance/all-fields/meta.json +++ b/fixtures/contract-conformance/all-fields/meta.json @@ -48,6 +48,7 @@ { "field.uri": { "name": "homepage" } }, { "field.uri": { "name": "looseLink", "@lenient": true } }, { "field.inet": { "name": "address" } }, + { "field.duration": { "name": "holdFor", "@filterable": true } }, { "field.object": { "name": "shipTo", "@objectRef": "Address", "@storage": "jsonb" } }, { "field.object": { "name": "stops", "@objectRef": "Address", "isArray": true, "@storage": "jsonb", "children": [ { "validator.array": { "name": "stopsCount", "@min": 1, "@max": 5 } } diff --git a/fixtures/contract-conformance/blog/case.json b/fixtures/contract-conformance/blog/case.json index ee85dd0638..34c8c69c5e 100644 --- a/fixtures/contract-conformance/blog/case.json +++ b/fixtures/contract-conformance/blog/case.json @@ -1 +1 @@ -{ "meta": "fixtures/api-contract-conformance/meta.json", "wire": {"decimals": "string", "nullMembers": "omit"} } +{ "meta": "fixtures/api-contract-conformance/meta.json", "wire": {"decimals": "string", "nullMembers": "omit", "durations": "iso"} } diff --git a/fixtures/contract-conformance/decimal/case.json b/fixtures/contract-conformance/decimal/case.json index 099ba18278..36f114890d 100644 --- a/fixtures/contract-conformance/decimal/case.json +++ b/fixtures/contract-conformance/decimal/case.json @@ -1 +1 @@ -{ "meta": "fixtures/api-contract-conformance/decimal/meta.json", "wire": {"decimals": "string", "nullMembers": "omit"} } +{ "meta": "fixtures/api-contract-conformance/decimal/meta.json", "wire": {"decimals": "string", "nullMembers": "omit", "durations": "iso"} } diff --git a/fixtures/contract-conformance/jsonb/case.json b/fixtures/contract-conformance/jsonb/case.json index 410b0c92a6..b5a484e78d 100644 --- a/fixtures/contract-conformance/jsonb/case.json +++ b/fixtures/contract-conformance/jsonb/case.json @@ -1 +1 @@ -{ "meta": "fixtures/api-contract-conformance/jsonb/meta.json", "wire": {"decimals": "string", "nullMembers": "omit"}, "strictExclude": ["Document", "Pricing"] } +{ "meta": "fixtures/api-contract-conformance/jsonb/meta.json", "wire": {"decimals": "string", "nullMembers": "omit", "durations": "iso"}, "strictExclude": ["Document", "Pricing"] } diff --git a/fixtures/contract-conformance/m2m/case.json b/fixtures/contract-conformance/m2m/case.json index 9c456f6100..d8bf82969c 100644 --- a/fixtures/contract-conformance/m2m/case.json +++ b/fixtures/contract-conformance/m2m/case.json @@ -1 +1 @@ -{ "meta": "fixtures/api-contract-conformance/m2m/meta.json", "wire": {"decimals": "string", "nullMembers": "omit"} } +{ "meta": "fixtures/api-contract-conformance/m2m/meta.json", "wire": {"decimals": "string", "nullMembers": "omit", "durations": "iso"} } diff --git a/fixtures/contract-conformance/projection/case.json b/fixtures/contract-conformance/projection/case.json index 7df31120b3..f80698c768 100644 --- a/fixtures/contract-conformance/projection/case.json +++ b/fixtures/contract-conformance/projection/case.json @@ -1 +1 @@ -{ "meta": "fixtures/api-contract-conformance/projection/meta.json", "wire": {"decimals": "string", "nullMembers": "omit"} } +{ "meta": "fixtures/api-contract-conformance/projection/meta.json", "wire": {"decimals": "string", "nullMembers": "omit", "durations": "iso"} } diff --git a/fixtures/contract-conformance/report/case.json b/fixtures/contract-conformance/report/case.json index ebebb502a7..9f0e1526b1 100644 --- a/fixtures/contract-conformance/report/case.json +++ b/fixtures/contract-conformance/report/case.json @@ -1 +1 @@ -{ "meta": "fixtures/api-contract-conformance/report/meta.json", "wire": {"decimals": "string", "nullMembers": "omit"}, "strictExclude": ["Ticket", "TicketsByTags"], "reportExport": true } +{ "meta": "fixtures/api-contract-conformance/report/meta.json", "wire": {"decimals": "string", "nullMembers": "omit", "durations": "iso"}, "strictExclude": ["Ticket", "TicketsByTags"], "reportExport": true } diff --git a/fixtures/contract-conformance/secret-fields/case.json b/fixtures/contract-conformance/secret-fields/case.json index 51ec47303f..c347962dda 100644 --- a/fixtures/contract-conformance/secret-fields/case.json +++ b/fixtures/contract-conformance/secret-fields/case.json @@ -1 +1 @@ -{ "meta": "fixtures/api-contract-conformance/secret-fields/meta.json", "wire": {"decimals": "string", "nullMembers": "omit"} } +{ "meta": "fixtures/api-contract-conformance/secret-fields/meta.json", "wire": {"decimals": "string", "nullMembers": "omit", "durations": "iso"} } diff --git a/fixtures/contract-conformance/tph/case.json b/fixtures/contract-conformance/tph/case.json index 986490c531..9e6bcbac62 100644 --- a/fixtures/contract-conformance/tph/case.json +++ b/fixtures/contract-conformance/tph/case.json @@ -1 +1 @@ -{ "meta": "fixtures/api-contract-conformance/tph/meta.json", "wire": {"decimals": "string", "nullMembers": "omit"} } +{ "meta": "fixtures/api-contract-conformance/tph/meta.json", "wire": {"decimals": "string", "nullMembers": "omit", "durations": "iso"} } diff --git a/fixtures/contract-conformance/write-through/case.json b/fixtures/contract-conformance/write-through/case.json index 1b7e6bd8cb..b381bc8226 100644 --- a/fixtures/contract-conformance/write-through/case.json +++ b/fixtures/contract-conformance/write-through/case.json @@ -1 +1 @@ -{ "meta": "fixtures/api-contract-conformance/write-through/meta.json", "wire": {"decimals": "string", "nullMembers": "omit"} } +{ "meta": "fixtures/api-contract-conformance/write-through/meta.json", "wire": {"decimals": "string", "nullMembers": "omit", "durations": "iso"} } diff --git a/fixtures/duration-wire/README.md b/fixtures/duration-wire/README.md new file mode 100644 index 0000000000..5033b54ad4 --- /dev/null +++ b/fixtures/duration-wire/README.md @@ -0,0 +1,44 @@ +# field.duration wire vectors + +The wire rule for `field.duration` (a fixed-length span of time), and the codecs some ports need +to store one. [`vectors.json`](./vectors.json) has two parts. + +## `wire`: every port passes every vector + +The ISO-8601 text a caller sends and a port answers. Clients depend on it, so every port agrees. + +- **`parse`**: a duration a caller may send, and the canonical form a port answers for it. The + canonical form is `[-]PT[H][M][[.]S]`: hours are never folded into days, zero + components are left out, zero is `PT0S`, trailing zeros of the fraction are dropped, and a + negative duration has one leading `-`. A day (`D`) is exactly 24 hours and a week (`W`) exactly + 7 days. +- **`precision`**: the fraction may have any number of digits. A port keeps as many as its own + duration type holds and truncates the rest toward zero, never rounding. `out` is keyed by that + number of digits, and a port checks the entry for its own: 6 for TypeScript and Python + (microseconds), 7 for C# (a `TimeSpan` tick is 100 ns), 9 for Java and Kotlin (`java.time.Duration`). +- **`refused`**: input that is not a duration. Years and months are refused because a calendar + span has no fixed length. So are lowercase designators, fractional hours, minutes or weeks, a + comma decimal separator, per-component signs, out-of-order components and whitespace. + +There is no shared upper bound. Each port is limited only by its own type (a .NET `TimeSpan`, a +`timedelta`) and each database by its own column; a port refuses what it cannot hold rather than +wrapping it, and tests that limit in its own suite. + +## `codecs`: a port passes a group only when it has that codec + +| Group | What it pins | Passed by | +|---|---|---| +| `postgres` | the text a Postgres `interval` column returns under the default `IntervalStyle` (`postgres`), and its canonical form | a port that reads an interval as text, or checks a native value (Npgsql `TimeSpan`, a JDBC `PGInterval`) against the same rule | +| `postgresRefused` | an interval with a year or month part; reading one is an error, never a guess at the length of a month | the same ports | +| `postgresText` | a canonical duration and its Postgres spelling, `[-]HH:MM:SS[.f]` with unbounded hours and no day part | a port that binds a duration to Postgres as text, and `meta migrate` for a declared `@default`. Postgres refuses a signed ISO-8601 duration (`-PT1M30S` is "invalid input syntax for type interval"), and returns an interval written this way in the same spelling, so a default never drifts | +| `storedMicros` | a duration and the integer count of microseconds a SQLite, D1 or MySQL column stores for it (`BIGINT`), sub-microsecond digits truncated toward zero | a port that stores durations on one of those databases | + +An integer column orders and compares as the span does, so sorting, range filters and equality are +right on every database. This is how Django stores a `DurationField` outside Postgres. The 64-bit +column holds about 2.56 billion hours; a port refuses to bind a longer span there. Postgres keeps its +native `interval`, which holds microseconds. + +The Postgres text readers assume the server's default `IntervalStyle`, `postgres`. A session set to +`iso_8601`, `sql_standard` or `postgres_verbose` returns another spelling. A reader accepts it only +where it means the same span (an unsigned `iso_8601` value is plain ISO-8601) and refuses the rest, +such as `iso_8601`'s per-component signs (`PT-1M-30S`), rather than misreading it. diff --git a/fixtures/duration-wire/vectors.json b/fixtures/duration-wire/vectors.json new file mode 100644 index 0000000000..bd284d12e0 --- /dev/null +++ b/fixtures/duration-wire/vectors.json @@ -0,0 +1,105 @@ +{ + "$comment": "The field.duration wire rule and the codecs some ports need. Every port passes `wire`; a port passes a `codecs` group only when it has that codec. See README.md.", + "wire": { + "parse": [ + { "in": "PT1H30M", "out": "PT1H30M" }, + { "in": "PT90M", "out": "PT1H30M" }, + { "in": "PT5400S", "out": "PT1H30M" }, + { "in": "P1D", "out": "PT24H" }, + { "in": "P1DT2H", "out": "PT26H" }, + { "in": "P10DT0.5S", "out": "PT240H0.5S" }, + { "in": "PT1.5S", "out": "PT1.5S" }, + { "in": "PT0.000001S", "out": "PT0.000001S" }, + { "in": "PT1.100S", "out": "PT1.1S" }, + { "in": "PT1H5S", "out": "PT1H5S" }, + { "in": "PT0S", "out": "PT0S" }, + { "in": "P0D", "out": "PT0S" }, + { "in": "-PT0S", "out": "PT0S" }, + { "in": "+PT2M", "out": "PT2M" }, + { "in": "-PT90S", "out": "-PT1M30S" }, + { "in": "-P1DT1H", "out": "-PT25H" }, + { "in": "PT876000H", "out": "PT876000H" }, + { "in": "P1W", "out": "PT168H" }, + { "in": "P2W", "out": "PT336H" }, + { "in": "P1W1DT1H", "out": "PT193H" }, + { "in": "-P1W", "out": "-PT168H" } + ], + "precision": [ + { "in": "PT1.123456789S", "out": { "6": "PT1.123456S", "7": "PT1.1234567S", "9": "PT1.123456789S" } }, + { "in": "PT0.0000001S", "out": { "6": "PT0S", "7": "PT0.0000001S", "9": "PT0.0000001S" } }, + { "in": "PT0.000000001S", "out": { "6": "PT0S", "7": "PT0S", "9": "PT0.000000001S" } }, + { "in": "PT1.1234567890123S", "out": { "6": "PT1.123456S", "7": "PT1.1234567S", "9": "PT1.123456789S" } }, + { "in": "PT0.99999999999999S", "out": { "6": "PT0.999999S", "7": "PT0.9999999S", "9": "PT0.999999999S" } }, + { "in": "-PT1.9999999999S", "out": { "6": "-PT1.999999S", "7": "-PT1.9999999S", "9": "-PT1.999999999S" } }, + { "in": "PT1.10000000S", "out": { "6": "PT1.1S", "7": "PT1.1S", "9": "PT1.1S" } } + ], + "refused": [ + "", + "P", + "PT", + "P1DT", + "P1Y", + "P1M", + "P1Y2M3D", + "pt1h", + "PT1.5H", + "PT1,5S", + "PT1.S", + "PT.5S", + "1:30:00", + "PT1H 30M", + " PT1H", + "PT-1H", + "--PT1H", + "PT1M1H", + "P1D1W", + "P1.5W", + "PT1W" + ] + }, + "codecs": { + "postgres": [ + { "in": "01:30:00", "out": "PT1H30M" }, + { "in": "00:00:00", "out": "PT0S" }, + { "in": "00:00:01.5", "out": "PT1.5S" }, + { "in": "00:00:00.000001", "out": "PT0.000001S" }, + { "in": "-00:01:30", "out": "-PT1M30S" }, + { "in": "26:00:00", "out": "PT26H" }, + { "in": "1 day", "out": "PT24H" }, + { "in": "1 day 02:00:00", "out": "PT26H" }, + { "in": "3 days", "out": "PT72H" }, + { "in": "-1 days", "out": "-PT24H" }, + { "in": "-1 days +02:00:00", "out": "-PT22H" }, + { "in": "1 day -01:00:00", "out": "PT23H" }, + { "in": "876000:00:00", "out": "PT876000H" } + ], + "postgresRefused": [ + "1 year", + "2 mons", + "1 year 2 mons 3 days 04:05:06", + "1 mon 00:00:01", + "abc" + ], + "postgresText": [ + { "in": "PT1H30M", "out": "01:30:00" }, + { "in": "PT0S", "out": "00:00:00" }, + { "in": "PT1.5S", "out": "00:00:01.5" }, + { "in": "PT0.000001S", "out": "00:00:00.000001" }, + { "in": "-PT1M30S", "out": "-00:01:30" }, + { "in": "PT26H", "out": "26:00:00" }, + { "in": "-PT26H0.5S", "out": "-26:00:00.5" }, + { "in": "PT876000H", "out": "876000:00:00" } + ], + "storedMicros": [ + { "in": "PT0S", "out": "0" }, + { "in": "PT0.000001S", "out": "1" }, + { "in": "PT1.5S", "out": "1500000" }, + { "in": "PT1H30M", "out": "5400000000" }, + { "in": "-PT1M30S", "out": "-90000000" }, + { "in": "PT26H", "out": "93600000000" }, + { "in": "PT876000H", "out": "3153600000000000" }, + { "in": "PT1.123456789S", "out": "1123456" }, + { "in": "-PT1.0000015S", "out": "-1000001" } + ] + } +} diff --git a/fixtures/validation-conformance/.metaobjects/config.json b/fixtures/validation-conformance/.metaobjects/config.json new file mode 100644 index 0000000000..5c95a8d2c8 --- /dev/null +++ b/fixtures/validation-conformance/.metaobjects/config.json @@ -0,0 +1,4 @@ +{ + "schema_version": 1, + "wire": { "durations": "iso" } +} diff --git a/fixtures/validation-conformance/README.md b/fixtures/validation-conformance/README.md index 0382d6982d..59c43ebbd6 100644 --- a/fixtures/validation-conformance/README.md +++ b/fixtures/validation-conformance/README.md @@ -113,11 +113,12 @@ FR-036 pin cases (the two semantic pins + the length-precedence pin): ## Cross-field validators — `meta.cross-field.json` + `cross-field-cases.json` -A second, separate pair of files pins the four entity-scoped cross-field validators +A second, separate pair of files pins the five entity-scoped cross-field validators (`validator.comparison`, `validator.requiredWhen`, `validator.presentIff`, -`validator.atLeastOne`) on the generated **full record (create) model**. They are separate files -so the run-time runners, which read `cases.json`, are unaffected. The entity is `Event`; every field -is optional, so absence is expressible in every port. +`validator.atLeastOne`, `validator.exactlyOne`) and their `@condition` on the generated **full +record (create) model**. They are separate files so the run-time runners, which read `cases.json`, +are unaffected. There are two entities, `Event` (the unconditional rules) and `Shift` (`exactlyOne` +and the conditional rules); every field is optional, so absence is expressible in every port. The semantics mirror the DB `CHECK` derivation in `migrate-ts/src/expected-schema.ts`, with an absent or null value standing for SQL `NULL` (and `""` NOT null). One deliberate difference: a comparison the @@ -129,13 +130,23 @@ can compare the two column types: 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). + strings, temporals and durations 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). Two durations order by + length, not by their text (`Shift.actual lte planned` accepts `PT45M` against `PT1H`); the duration + payloads are ISO-8601, so a runner whose port defaults to another form pins `wire.durations: iso`. - `requiredWhen`: violated only when the gating field equals `@equals` and the target is null; a null gate is unconstrained. Both a string gate (`kind = BIRTH`) and a boolean gate (`flag = true`). - `presentIff`: the target is present exactly when the gate equals `@equals`; a null gate counts as not equal, so the target must be absent. - `atLeastOne` (`email`, `phone`): at least one is non-null. +- `exactlyOne` (`Shift`: `storeId`, `kioskId`): exactly one is non-null; none and both are invalid. +- `@condition` (`Shift`): the rule is checked only when the condition is TRUE under SQL three-valued + logic, since the CHECK is `NOT (condition) OR (rule)`. A false condition and a NULL one (a field it + compares is absent) both skip the rule. The cases cover a field-to-field `eq`, `not` over `isNull`, + and `and` / `or` / `ne` / `isNull` over a `coalesce` with a literal default. A comparison inside a + condition follows the `comparison` rule above: when two fields it reads are of different kinds, the + whole rule is left to the database (`lateCallNeedsPhone` compares a date with a timestamp, so a `CALL` + without a phone is valid). - A rule naming a field the create body never carries (`reviewedBy`, `@mutability: readOnly`; likewise a derived field or a store-generated key) is skipped, so `review = DONE` without a reviewer is valid. @@ -184,6 +195,16 @@ So the rule is: Python fuses both (Pydantic construct-or-`ValidationError`); the Java/Kotlin/C# runners wrap the bind step so a native-parse failure maps to `valid=false`. +## `field.duration` accepted form + +`Account.holdFor` (`field.duration`, OPTIONAL) pins the duration accept/reject verdicts. ACCEPT an +ISO-8601 duration of days, hours, minutes and seconds (`PT1H30M`, `P1DT2H`, `-PT30S`, `PT0.5S`, +`PT0S`). REJECT a calendar span (`P1M`, `P1Y`, `P2W`), a clock form (`1:30:00`), a fractional hour +(`PT1.5H`), lowercase designators, and a `T` with no component (`PT`). The full rule, with the +canonical form every port writes, is in [`fixtures/duration-wire/`](../duration-wire/); a port +that binds a native duration type (`TimeSpan`, `java.time.Duration`, `timedelta`) still refuses +what that type's own parser would accept beyond this form. + ## Run-time runners (TypeScript and Python) Two ports also ship a metadata-driven **run-time** runner that needs no generated code: diff --git a/fixtures/validation-conformance/cases.json b/fixtures/validation-conformance/cases.json index 2f65ea2514..04b8b49be9 100644 --- a/fixtures/validation-conformance/cases.json +++ b/fixtures/validation-conformance/cases.json @@ -109,6 +109,18 @@ { "name": "lenient-inet-accepts-valid", "payload": { "name": "Acme", "code": "ABC", "score": 50, "tags": ["x"], "note": "ok", "reportedIp": "10.0.0.1" }, "expectValid": true }, { "name": "assigned-pk-present", "entity": "Ledger", "payload": { "code": "L-1", "label": "ok" }, "expectValid": true }, - { "name": "assigned-pk-missing", "entity": "Ledger", "payload": { "label": "ok" }, "expectValid": false } + { "name": "assigned-pk-missing", "entity": "Ledger", "payload": { "label": "ok" }, "expectValid": false }, + { "name": "duration-accept-hours-minutes", "payload": { "name": "Acme", "code": "ABC", "score": 50, "tags": ["x"], "note": "ok", "holdFor": "PT1H30M" }, "expectValid": true }, + { "name": "duration-accept-days", "payload": { "name": "Acme", "code": "ABC", "score": 50, "tags": ["x"], "note": "ok", "holdFor": "P1DT2H" }, "expectValid": true }, + { "name": "duration-accept-negative", "payload": { "name": "Acme", "code": "ABC", "score": 50, "tags": ["x"], "note": "ok", "holdFor": "-PT30S" }, "expectValid": true }, + { "name": "duration-accept-fraction", "payload": { "name": "Acme", "code": "ABC", "score": 50, "tags": ["x"], "note": "ok", "holdFor": "PT0.5S" }, "expectValid": true }, + { "name": "duration-accept-zero", "payload": { "name": "Acme", "code": "ABC", "score": 50, "tags": ["x"], "note": "ok", "holdFor": "PT0S" }, "expectValid": true }, + { "name": "duration-reject-months", "payload": { "name": "Acme", "code": "ABC", "score": 50, "tags": ["x"], "note": "ok", "holdFor": "P1M" }, "expectValid": false }, + { "name": "duration-reject-years", "payload": { "name": "Acme", "code": "ABC", "score": 50, "tags": ["x"], "note": "ok", "holdFor": "P1Y" }, "expectValid": false }, + { "name": "duration-accept-weeks", "payload": { "name": "Acme", "code": "ABC", "score": 50, "tags": ["x"], "note": "ok", "holdFor": "P2W" }, "expectValid": true }, + { "name": "duration-reject-clock", "payload": { "name": "Acme", "code": "ABC", "score": 50, "tags": ["x"], "note": "ok", "holdFor": "1:30:00" }, "expectValid": false }, + { "name": "duration-reject-fractional-hours", "payload": { "name": "Acme", "code": "ABC", "score": 50, "tags": ["x"], "note": "ok", "holdFor": "PT1.5H" }, "expectValid": false }, + { "name": "duration-reject-lowercase", "payload": { "name": "Acme", "code": "ABC", "score": 50, "tags": ["x"], "note": "ok", "holdFor": "pt1h" }, "expectValid": false }, + { "name": "duration-reject-empty-time", "payload": { "name": "Acme", "code": "ABC", "score": 50, "tags": ["x"], "note": "ok", "holdFor": "PT" }, "expectValid": false } ] } diff --git a/fixtures/validation-conformance/cross-field-cases.json b/fixtures/validation-conformance/cross-field-cases.json index dae54a0eef..d077a6763b 100644 --- a/fixtures/validation-conformance/cross-field-cases.json +++ b/fixtures/validation-conformance/cross-field-cases.json @@ -318,6 +318,244 @@ "approverId": "9b7d4e22-0c1a-4f3b-8d65-2a7e9c5f1b34" }, "expectValid": true + }, + { + "name": "shift-valid-baseline", + "entity": "Shift", + "payload": { + "startDay": 1, + "endDay": 1, + "startMin": 60, + "endMin": 120, + "storeId": 7, + "kind": "DESK" + }, + "expectValid": true + }, + { + "name": "condition-true-rule-violated", + "entity": "Shift", + "payload": { + "startDay": 1, + "endDay": 1, + "startMin": 120, + "endMin": 60, + "storeId": 7, + "kind": "DESK" + }, + "expectValid": false + }, + { + "name": "condition-false-rule-skipped", + "entity": "Shift", + "payload": { + "startDay": 1, + "endDay": 2, + "startMin": 120, + "endMin": 60, + "storeId": 7, + "kind": "DESK" + }, + "expectValid": true + }, + { + "name": "condition-null-rule-skipped", + "entity": "Shift", + "payload": { + "endDay": 1, + "startMin": 120, + "endMin": 60, + "storeId": 7, + "kind": "DESK" + }, + "expectValid": true + }, + { + "name": "exactly-one-none", + "entity": "Shift", + "payload": { + "startDay": 1, + "endDay": 1, + "startMin": 60, + "endMin": 120, + "kind": "DESK" + }, + "expectValid": false + }, + { + "name": "exactly-one-both", + "entity": "Shift", + "payload": { + "startDay": 1, + "endDay": 1, + "startMin": 60, + "endMin": 120, + "storeId": 7, + "kind": "DESK", + "kioskId": 3 + }, + "expectValid": false + }, + { + "name": "exactly-one-other", + "entity": "Shift", + "payload": { + "startDay": 1, + "endDay": 1, + "startMin": 60, + "endMin": 120, + "kind": "DESK", + "kioskId": 3 + }, + "expectValid": true + }, + { + "name": "condition-not-is-null-true-violated", + "entity": "Shift", + "payload": { + "startDay": 1, + "endDay": 1, + "startMin": 60, + "endMin": 120, + "storeId": 7, + "kind": "PHONE" + }, + "expectValid": false + }, + { + "name": "condition-not-is-null-false-skipped", + "entity": "Shift", + "payload": { + "startDay": 1, + "endDay": 1, + "startMin": 60, + "endMin": 120, + "kind": "PHONE", + "kioskId": 3 + }, + "expectValid": true + }, + { + "name": "condition-and-true-violated", + "entity": "Shift", + "payload": { + "startDay": 1, + "endDay": 1, + "startMin": 60, + "endMin": 120, + "storeId": 7, + "kind": "DESK", + "priority": 3 + }, + "expectValid": false + }, + { + "name": "condition-and-satisfied", + "entity": "Shift", + "payload": { + "startDay": 1, + "endDay": 1, + "startMin": 60, + "endMin": 120, + "storeId": 7, + "kind": "DESK", + "priority": 3, + "phone": "555" + }, + "expectValid": true + }, + { + "name": "condition-coalesce-default-skipped", + "entity": "Shift", + "payload": { + "startDay": 1, + "endDay": 1, + "startMin": 60, + "endMin": 120, + "storeId": 7, + "kind": "DESK" + }, + "expectValid": true + }, + { + "name": "condition-and-false-by-or-skipped", + "entity": "Shift", + "payload": { + "startDay": 1, + "endDay": 1, + "startMin": 60, + "endMin": 120, + "storeId": 7, + "kind": "BATCH", + "priority": 3 + }, + "expectValid": true + }, + { + "name": "condition-or-null-branch-violated", + "entity": "Shift", + "payload": { + "startDay": 1, + "endDay": 1, + "startMin": 60, + "endMin": 120, + "storeId": 7, + "priority": 3 + }, + "expectValid": false + }, + { + "name": "condition-comparing-across-kinds-leaves-rule-to-database", + "entity": "Shift", + "payload": { + "storeId": 7, + "kind": "CALL", + "shiftDate": "2030-01-02", + "loggedAt": "2020-01-01T00:00:00Z" + }, + "expectValid": true + }, + { + "name": "condition-ne-on-ipv4-and-ipv6-addresses-enforces-the-rule", + "entity": "Shift", + "payload": { + "storeId": 7, + "kind": "CALL", + "clientIp": "10.0.0.1", + "siteIp": "::1" + }, + "expectValid": false + }, + { + "name": "condition-ne-on-equal-addresses-lets-the-record-through", + "entity": "Shift", + "payload": { + "storeId": 7, + "kind": "CALL", + "clientIp": "10.0.0.1", + "siteIp": "10.0.0.1" + }, + "expectValid": true + }, + { + "name": "duration-comparison-rejects-a-longer-span", + "entity": "Shift", + "payload": { + "storeId": 7, + "planned": "PT1H", + "actual": "PT1H30M" + }, + "expectValid": false + }, + { + "name": "duration-comparison-orders-by-length-not-text", + "entity": "Shift", + "payload": { + "storeId": 7, + "planned": "PT1H", + "actual": "PT45M" + }, + "expectValid": true } ] } diff --git a/fixtures/validation-conformance/meta.cross-field.json b/fixtures/validation-conformance/meta.cross-field.json index a47faea675..093886570b 100644 --- a/fixtures/validation-conformance/meta.cross-field.json +++ b/fixtures/validation-conformance/meta.cross-field.json @@ -36,6 +36,48 @@ { "validator.atLeastOne": { "name": "reachable", "@fields": ["email", "phone"] } }, { "validator.requiredWhen": { "name": "reviewNeedsReviewer", "@field": "reviewedBy", "@when": "review", "@equals": "DONE" } } ] + }}, + { "object.entity": { + "name": "Shift", + "children": [ + { "source.rdb": { "@table": "shifts" } }, + { "field.long": { "name": "id" } }, + { "field.int": { "name": "startDay" } }, + { "field.int": { "name": "endDay" } }, + { "field.int": { "name": "startMin" } }, + { "field.int": { "name": "endMin" } }, + { "field.long": { "name": "storeId" } }, + { "field.long": { "name": "kioskId" } }, + { "field.string": { "name": "kind" } }, + { "field.string": { "name": "note" } }, + { "field.int": { "name": "priority" } }, + { "field.string": { "name": "email" } }, + { "field.string": { "name": "phone" } }, + { "field.date": { "name": "shiftDate" } }, + { "field.timestamp": { "name": "loggedAt" } }, + { "field.duration": { "name": "planned" } }, + { "field.duration": { "name": "actual" } }, + { "field.inet": { "name": "clientIp" } }, + { "field.inet": { "name": "siteIp" } }, + { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } }, + { "validator.comparison": { "name": "sameDayTimes", "@left": "endMin", "@op": "gt", "@right": "startMin", + "@condition": { "op": "eq", "left": { "field": "startDay" }, "right": { "field": "endDay" } } } }, + { "validator.comparison": { "name": "withinPlan", "@left": "actual", "@op": "lte", "@right": "planned" } }, + { "validator.exactlyOne": { "name": "oneLocation", "@fields": ["storeId", "kioskId"] } }, + { "validator.requiredWhen": { "name": "storePhoneNeedsNote", "@field": "note", "@when": "kind", "@equals": "PHONE", + "@condition": { "op": "not", "arg": { "op": "isNull", "arg": { "field": "storeId" } } } } }, + { "validator.requiredWhen": { "name": "offSiteCallNeedsPhone", "@field": "phone", "@when": "kind", "@equals": "CALL", "@condition": { "op": "ne", "left": { "field": "clientIp" }, "right": { "field": "siteIp" } } } }, + { "validator.requiredWhen": { "name": "lateCallNeedsPhone", "@field": "phone", "@when": "kind", "@equals": "CALL", + "@condition": { "op": "gt", "left": { "field": "shiftDate" }, "right": { "field": "loggedAt" } } } }, + { "validator.atLeastOne": { "name": "urgentReachable", "@fields": ["email", "phone"], + "@condition": { "op": "and", "args": [ + { "op": "gt", "left": { "fn": "coalesce", "args": [{ "field": "priority" }, { "value": 0 }] }, "right": { "value": 2 } }, + { "op": "or", "args": [ + { "op": "ne", "left": { "field": "kind" }, "right": { "value": "BATCH" } }, + { "op": "isNull", "arg": { "field": "kind" } } + ] } + ] } } } + ] }} ] } diff --git a/fixtures/validation-conformance/meta.json b/fixtures/validation-conformance/meta.json index a8a4daf79d..3f696a5c98 100644 --- a/fixtures/validation-conformance/meta.json +++ b/fixtures/validation-conformance/meta.json @@ -28,6 +28,7 @@ { "field.inet": { "name": "sourceIp" } }, { "field.uri": { "name": "citationUrl", "@lenient": true } }, { "field.inet": { "name": "reportedIp", "@lenient": true } }, + { "field.duration": { "name": "holdFor" } }, { "identity.primary": { "name": "id", "@fields": "id", "@generation": "increment" } } ] }}, diff --git a/fixtures/validation-conformance/runtime-errors.json b/fixtures/validation-conformance/runtime-errors.json index 1f70ea54ca..4ee0515d58 100644 --- a/fixtures/validation-conformance/runtime-errors.json +++ b/fixtures/validation-conformance/runtime-errors.json @@ -207,6 +207,60 @@ "rule": "required", "message": "'code' is required" } + ], + "duration-reject-months": [ + { + "field": "holdFor", + "rule": "format", + "message": "'holdFor' must be an ISO-8601 duration such as PT1H30M (no years or months)", + "expected": "duration", + "received": "P1M" + } + ], + "duration-reject-years": [ + { + "field": "holdFor", + "rule": "format", + "message": "'holdFor' must be an ISO-8601 duration such as PT1H30M (no years or months)", + "expected": "duration", + "received": "P1Y" + } + ], + "duration-reject-clock": [ + { + "field": "holdFor", + "rule": "format", + "message": "'holdFor' must be an ISO-8601 duration such as PT1H30M (no years or months)", + "expected": "duration", + "received": "1:30:00" + } + ], + "duration-reject-fractional-hours": [ + { + "field": "holdFor", + "rule": "format", + "message": "'holdFor' must be an ISO-8601 duration such as PT1H30M (no years or months)", + "expected": "duration", + "received": "PT1.5H" + } + ], + "duration-reject-lowercase": [ + { + "field": "holdFor", + "rule": "format", + "message": "'holdFor' must be an ISO-8601 duration such as PT1H30M (no years or months)", + "expected": "duration", + "received": "pt1h" + } + ], + "duration-reject-empty-time": [ + { + "field": "holdFor", + "rule": "format", + "message": "'holdFor' must be an ISO-8601 duration such as PT1H30M (no years or months)", + "expected": "duration", + "received": "PT" + } ] } } diff --git a/fixtures/wire-conformance/README.md b/fixtures/wire-conformance/README.md index 17ad57a549..e39a32c1c3 100644 --- a/fixtures/wire-conformance/README.md +++ b/fixtures/wire-conformance/README.md @@ -1,11 +1,12 @@ # Wire conformance The `wire` block of `.metaobjects/config.json` (ADR-0019 Amendment 2) chooses the OUTPUT form of -a generated REST surface: how a `field.decimal` is written, and whether a value object's null -member is written. The default is `native` for both keys, which means each port writes what its -language's standard serializer writes. `"decimals": "string"` and `"nullMembers": "omit"` give -identical output across the five ports; the api-contract and persistence corpora pin exactly -that form in their own `.metaobjects/config.json`. +a generated REST surface: how a `field.decimal` is written, whether a value object's null +member is written, and how a `field.duration` is written and read. The default is `native` for +every key, which means each port writes what its language's standard serializer writes. +`"decimals": "string"`, `"nullMembers": "omit"` and `"durations": "iso"` give identical output +across the five ports; the api-contract and persistence corpora pin exactly that form in their +own `.metaobjects/config.json`. [`config-blocks.json`](./config-blocks.json) holds the blocks every port must accept, with the values they resolve to, and the blocks every port must refuse when it reads the file. Each port diff --git a/fixtures/wire-conformance/config-blocks.json b/fixtures/wire-conformance/config-blocks.json index eaa35234bc..385d05f251 100644 --- a/fixtures/wire-conformance/config-blocks.json +++ b/fixtures/wire-conformance/config-blocks.json @@ -1,12 +1,13 @@ { - "_comment": "Cross-port wire-format setting (ADR-0019 Amendment 2). Each case is the `wire` block of a project's .metaobjects/config.json (null = the key is absent). A port parses it with the reader it uses for that file and must resolve `decimals` and `nullMembers` to the values below; the default is `native` for both, which means each port's own language-standard output. `invalid` blocks must be refused when the config is read.", + "_comment": "Cross-port wire-format setting (ADR-0019 Amendment 2). Each case is the `wire` block of a project's .metaobjects/config.json (null = the key is absent). A port parses it with the reader it uses for that file and must resolve `decimals`, `nullMembers` and `durations` to the values below; the default is `native` for each, which means each port's own language-standard output. `invalid` blocks must be refused when the config is read.", "cases": [ - { "name": "default", "wire": null, "decimals": "native", "nullMembers": "native" }, - { "name": "empty block is the default", "wire": {}, "decimals": "native", "nullMembers": "native" }, - { "name": "explicit native", "wire": { "decimals": "native", "nullMembers": "native" }, "decimals": "native", "nullMembers": "native" }, - { "name": "portable form (what the corpora pin)", "wire": { "decimals": "string", "nullMembers": "omit" }, "decimals": "string", "nullMembers": "omit" }, - { "name": "decimals alone", "wire": { "decimals": "string" }, "decimals": "string", "nullMembers": "native" }, - { "name": "emit null members", "wire": { "nullMembers": "emit" }, "decimals": "native", "nullMembers": "emit" } + { "name": "default", "wire": null, "decimals": "native", "nullMembers": "native", "durations": "native" }, + { "name": "empty block is the default", "wire": {}, "decimals": "native", "nullMembers": "native", "durations": "native" }, + { "name": "explicit native", "wire": { "decimals": "native", "nullMembers": "native", "durations": "native" }, "decimals": "native", "nullMembers": "native", "durations": "native" }, + { "name": "portable form (what the corpora pin)", "wire": { "decimals": "string", "nullMembers": "omit", "durations": "iso" }, "decimals": "string", "nullMembers": "omit", "durations": "iso" }, + { "name": "decimals alone", "wire": { "decimals": "string" }, "decimals": "string", "nullMembers": "native", "durations": "native" }, + { "name": "emit null members", "wire": { "nullMembers": "emit" }, "decimals": "native", "nullMembers": "emit", "durations": "native" }, + { "name": "iso durations alone", "wire": { "durations": "iso" }, "decimals": "native", "nullMembers": "native", "durations": "iso" } ], "invalid": [ { "name": "wire is not an object", "wire": "string" }, @@ -16,6 +17,8 @@ { "name": "unknown decimals value", "wire": { "decimals": "canonical" } }, { "name": "decimals is not a string", "wire": { "decimals": true } }, { "name": "unknown nullMembers value", "wire": { "nullMembers": "skip" } }, - { "name": "nullMembers is not a string", "wire": { "nullMembers": null } } + { "name": "nullMembers is not a string", "wire": { "nullMembers": null } }, + { "name": "unknown durations value", "wire": { "durations": "iso8601" } }, + { "name": "durations is not a string", "wire": { "durations": false } } ] } diff --git a/server/csharp/MetaObjects.Cli.Tests/WireSettingCliTests.cs b/server/csharp/MetaObjects.Cli.Tests/WireSettingCliTests.cs index d3cde2ecb9..c81a6212b0 100644 --- a/server/csharp/MetaObjects.Cli.Tests/WireSettingCliTests.cs +++ b/server/csharp/MetaObjects.Cli.Tests/WireSettingCliTests.cs @@ -108,12 +108,12 @@ public void Verify_with_the_wrong_wire_setting_says_the_setting_differs_instead_ var output = stdout + stderr; Assert.Equal(1, exit); Assert.Contains( - "The committed output was generated with \"wire\": { \"decimals\": \"string\", \"nullMembers\": \"omit\" }; " + - "this run used \"wire\": { \"decimals\": \"native\", \"nullMembers\": \"native\" } " + + "The committed output was generated with \"wire\": { \"decimals\": \"string\", \"nullMembers\": \"omit\", \"durations\": \"native\" }; " + + "this run used \"wire\": { \"decimals\": \"native\", \"nullMembers\": \"native\", \"durations\": \"native\" } " + "(.metaobjects/config.json; each key defaults to native). Do not regenerate", output); Assert.Contains( - "Declare \"wire\": { \"decimals\": \"string\", \"nullMembers\": \"omit\" } in .metaobjects/config.json", + "Declare \"wire\": { \"decimals\": \"string\", \"nullMembers\": \"omit\", \"durations\": \"native\" } in .metaobjects/config.json", output); Assert.DoesNotContain("Run 'dotnet meta gen' to regenerate", output); } diff --git a/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs index 8ec4fd1b50..45617482c5 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/CrossFieldValidationConformanceTests.cs @@ -6,6 +6,8 @@ using MetaObjects.Codegen; using MetaObjects.Codegen.Generators; using MetaObjects.Loader; +using MetaObjects.Config; +using MetaObjects.Meta; using Xunit; namespace MetaObjects.Codegen.Tests; @@ -45,7 +47,7 @@ public static IEnumerable Cases() => AllCases().Select(c => new object[] { c.GetProperty("name").GetString()! }); [Fact] - public void Discovers_all_cross_field_cases() => Assert.Equal(24, AllCases().Length); + public void Discovers_all_cross_field_cases() => Assert.Equal(43, AllCases().Length); [Theory] [MemberData(nameof(Cases))] @@ -100,6 +102,10 @@ private static Assembly Compile() var loadResult = new MetaDataLoader().Load( [new FileSource(Path.Combine(CorpusDir(), "meta.cross-field.json"))]); Assert.Empty(loadResult.Errors); + // The corpus pins its wire form in its own .metaobjects/config.json (`wire.durations: + // iso`, the form its duration payloads take); C#'s native default is System.Text.Json's + // own TimeSpan form. + loadResult.Root.WireFormat = NeutralConfig.Read(CorpusDir())?.Wire ?? WireFormat.Default; var ctx = new GenContext { Entities = loadResult.Root.Objects(), diff --git a/server/csharp/MetaObjects.Codegen.Tests/DurationWireTests.cs b/server/csharp/MetaObjects.Codegen.Tests/DurationWireTests.cs new file mode 100644 index 0000000000..f3dbb7d6c5 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/DurationWireTests.cs @@ -0,0 +1,383 @@ +// field.duration — the C# half of the duration wire rule, gated against the shared vectors in +// fixtures/duration-wire/vectors.json. No database: the production wire helper +// (Runtime/DurationWire.cs, the SAME text the entity generator emits into every generated project) +// runs every `wire` vector (the `precision` ones at seven digits: a TimeSpan tick is 100 ns), and +// the emitted text is proven identical to it. Of the `codecs` groups C# has two in production: +// `storedMicros` (the generated value converter on SQLite and MySQL, here) and the Postgres read +// (`postgres` and `postgresRefused`, through the generated NpgsqlInterval mapping, gated against a +// live database in MetaObjects.IntegrationTests/Api/DurationWireGeneratedApiTests). C# never spells +// a duration as Postgres text (Npgsql binds a TimeSpan natively), so `postgresText` is not its codec. + +using System.Reflection; +using System.Text.Json; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using MetaObjects.Codegen.Generators; +using MetaObjects.Codegen.Runtime; +using MetaObjects.Core.Query; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; +using static MetaObjects.Core.Field.FieldConstants; + +namespace MetaObjects.Codegen.Tests; + +public class DurationWireTests +{ + private static JsonElement Vectors() => + JsonDocument.Parse(File.ReadAllText(Path.Combine( + CorpusPaths.RepoRoot(), "fixtures", "duration-wire", "vectors.json"))).RootElement; + + private static JsonElement Wire(string group) => Vectors().GetProperty("wire").GetProperty(group); + + // The `precision` key for this port: the fractional digits a TimeSpan holds. + private const string PrecisionKey = "7"; + + public static TheoryData ParseVectors() + { + var data = new TheoryData(); + foreach (var v in Wire("parse").EnumerateArray()) + data.Add(v.GetProperty("in").GetString()!, v.GetProperty("out").GetString()!); + return data; + } + + public static TheoryData PrecisionVectors() + { + var data = new TheoryData(); + foreach (var v in Wire("precision").EnumerateArray()) + data.Add(v.GetProperty("in").GetString()!, v.GetProperty("out").GetProperty(PrecisionKey).GetString()!); + return data; + } + + public static TheoryData RefusedVectors() + { + var data = new TheoryData(); + foreach (var v in Wire("refused").EnumerateArray()) data.Add(v.GetString()!); + return data; + } + + public sealed class Holder + { + [DurationWire.Iso] public TimeSpan Span { get; set; } + [DurationWire.Iso] public TimeSpan? Maybe { get; set; } + [DurationWire.Iso] public IList Many { get; set; } = new List(); + } + + [Theory, MemberData(nameof(ParseVectors))] + public void An_accepted_duration_parses_to_its_canonical_form(string input, string canonical) + { + Assert.True(DurationWire.TryParse(input, out var span), $"'{input}' was refused"); + Assert.Equal(canonical, DurationWire.Format(span)); + Assert.Equal(canonical, DurationWire.Canonical(input)); + // The canonical form is a fixed point. + Assert.Equal(canonical, DurationWire.Format(DurationWire.Parse(canonical))); + } + + [Theory, MemberData(nameof(PrecisionVectors))] + public void A_fraction_keeps_whole_ticks_and_truncates_the_rest_toward_zero(string input, string canonical) + { + Assert.True(DurationWire.TryParse(input, out var span), $"'{input}' was refused"); + Assert.Equal(canonical, DurationWire.Format(span)); + Assert.Equal(canonical, DurationWire.Format(DurationWire.Parse(canonical))); + } + + [Theory, MemberData(nameof(ParseVectors)), MemberData(nameof(PrecisionVectors))] + public void A_property_reads_the_accepted_form_and_writes_the_canonical_one(string input, string canonical) + { + var h = JsonSerializer.Deserialize($"{{\"Span\":\"{input}\",\"Maybe\":\"{input}\",\"Many\":[\"{input}\"]}}")!; + Assert.Equal($"{{\"Span\":\"{canonical}\",\"Maybe\":\"{canonical}\",\"Many\":[\"{canonical}\"]}}", JsonSerializer.Serialize(h)); + } + + [Theory, MemberData(nameof(RefusedVectors))] + public void A_refused_value_is_not_a_duration(string input) + { + Assert.False(DurationWire.TryParse(input, out _)); + Assert.Null(DurationWire.Canonical(input)); + Assert.Throws(() => DurationWire.Parse(input)); + var json = JsonSerializer.Serialize(input); + Assert.ThrowsAny(() => JsonSerializer.Deserialize($"{{\"Span\":{json}}}")); + Assert.ThrowsAny(() => JsonSerializer.Deserialize($"{{\"Maybe\":{json}}}")); + Assert.ThrowsAny(() => JsonSerializer.Deserialize($"{{\"Many\":[{json}]}}")); + } + + [Theory] + [InlineData("1")] // a number is not a duration + [InlineData("true")] + [InlineData("{}")] + [InlineData("\"01:30:00\"")] // System.Text.Json's own TimeSpan form + [InlineData("\"PT99999999999999999999H\"")] // past what a TimeSpan holds: refused, not wrapped + public void A_non_duration_token_is_refused(string json) => + Assert.ThrowsAny(() => JsonSerializer.Deserialize($"{{\"Span\":{json}}}")); + + [Fact] + public void Null_crosses_as_null_and_every_tick_is_written() + { + Assert.Null(JsonSerializer.Deserialize("{\"Maybe\":null}")!.Maybe); + Assert.Contains("\"Maybe\":null", JsonSerializer.Serialize(new Holder())); + Assert.Equal("PT0.0000009S", DurationWire.Format(TimeSpan.FromTicks(9))); + Assert.Equal("-PT0.0000009S", DurationWire.Format(TimeSpan.FromTicks(-9))); + Assert.Equal("PT0.0000019S", DurationWire.Format(TimeSpan.FromTicks(19))); + Assert.Equal("-PT0.0000019S", DurationWire.Format(TimeSpan.FromTicks(-19))); + Assert.Equal("-PT1M30S", DurationWire.Format(TimeSpan.FromSeconds(-90))); + Assert.Equal(TimeSpan.FromTicks(-19), DurationWire.Parse("-PT0.00000199S")); // truncated toward zero + } + + public static TheoryData StoredMicrosVectors() + { + var data = new TheoryData(); + foreach (var v in Vectors().GetProperty("codecs").GetProperty("storedMicros").EnumerateArray()) + data.Add(v.GetProperty("in").GetString()!, v.GetProperty("out").GetString()!); + return data; + } + + [Theory, MemberData(nameof(StoredMicrosVectors))] + public void On_a_database_without_an_interval_a_duration_is_its_microsecond_count(string duration, string micros) + { + var count = long.Parse(micros, System.Globalization.CultureInfo.InvariantCulture); + Assert.Equal(count, DurationWire.ToMicros(DurationWire.Parse(duration))); + // The column's precision is a microsecond: what reads back is the span to the microsecond. + Assert.Equal(TimeSpan.FromTicks(count * 10), DurationWire.FromMicros(count)); + Assert.Equal(count, DurationWire.ToMicros(DurationWire.FromMicros(count))); + } + + // C#'s own limit — a TimeSpan's range, about 10.7 million days — not a cross-port one: a span + // past it is refused, never wrapped, on parse and on a stored microsecond count. The whole + // range is reachable, to the tick, MinValue (one tick longer than MaxValue) included. + [Fact] + public void A_span_past_a_timespan_is_refused_never_wrapped() + { + Assert.Equal("PT256204778H48M5.4775807S", DurationWire.Format(TimeSpan.MaxValue)); + Assert.Equal("-PT256204778H48M5.4775808S", DurationWire.Format(TimeSpan.MinValue)); + Assert.Equal(TimeSpan.MaxValue, DurationWire.Parse("PT256204778H48M5.4775807S")); + Assert.Equal(TimeSpan.MaxValue, DurationWire.Parse("PT256204778H48M5.47758079999S")); // truncated, not rounded past + Assert.Equal(TimeSpan.MinValue, DurationWire.Parse("-PT256204778H48M5.4775808S")); + Assert.Equal("PT256204778H", DurationWire.Format(DurationWire.Parse("PT256204778H"))); + Assert.Equal("-PT256204778H", DurationWire.Format(DurationWire.Parse("-P10675199DT2H"))); + Assert.Equal("PT256204704H", DurationWire.Format(DurationWire.Parse("P1525028W"))); + foreach (var past in new[] { + "PT256204778H48M5.4775808S", "-PT256204778H48M5.4775809S", "PT256204779H", "P10675200D", + "P1525029W", "P1525028W4D", "-PT256204779H", "PT99999999999999999999S", "P99999999999999999999W" }) + Assert.False(DurationWire.TryParse(past, out _), past); + Assert.Equal(TimeSpan.MaxValue.Ticks / 10, DurationWire.ToMicros(TimeSpan.MaxValue)); + Assert.Throws(() => DurationWire.FromMicros(long.MaxValue)); + Assert.Throws(() => DurationWire.FromMicros(long.MinValue)); + Assert.Equal(-1, DurationWire.ToMicros(TimeSpan.FromTicks(-19))); // truncated toward zero + } + + // ---- filter operands ---------------------------------------------------------------------- + + public sealed class Row { public TimeSpan Wait { get; set; } public TimeSpan? Grace { get; set; } } + + private static FilterParseResult Check(string field, string op, object value) => + EfCoreFilterDispatch.Validate(FilterParseResult.Ok([new FilterPredicate(field, op, value)])); + + [Theory] + [InlineData("wait", "eq", "P1M")] + [InlineData("wait", "eq", "01:30:00")] + [InlineData("grace", "ne", "pt1h")] + [InlineData("grace", "eq", "")] + public void A_malformed_duration_operand_is_invalid_filter_value_naming_the_field(string field, string op, string operand) + { + var r = Check(field, op, operand); + Assert.Equal("invalid_filter_value", r.ErrorEnvelope); + Assert.Equal(field, r.Field); + } + + [Fact] + public void A_good_duration_operand_passes_and_an_in_list_is_checked_element_wise() + { + Assert.Null(Check("wait", "eq", "PT90M").ErrorEnvelope); + Assert.Null(Check("grace", "ne", "-P1DT0.5S").ErrorEnvelope); + Assert.Null(Check("wait", "in", new List { "PT1H", "P2D", "P1W" }).ErrorEnvelope); + Assert.Equal("invalid_filter_value", Check("wait", "in", new List { "PT1H", "P1Y" }).ErrorEnvelope); + } + + [Fact] + public void A_duration_filters_on_the_ordered_band() + { + Assert.Equal(["eq", "ne", "gt", "gte", "lt", "lte", "in", "isNull"], QueryConstants.OpsForSubType(FIELD_SUBTYPE_DURATION)); + } + + // ---- generated code ------------------------------------------------------------------------- + + private const string Model = """ + { "metadata.root": { "package": "acme::ops", "children": [ + { "object.value": { "name": "Window", "children": [ + { "field.duration": { "name": "length", "@required": true } }, + { "field.duration": { "name": "slack" } }, + { "field.duration": { "name": "steps", "isArray": true } } + ]}}, + { "object.value": { "name": "Limits", "children": [ + { "field.duration": { "name": "cap" } }, + { "field.duration": { "name": "marks", "isArray": true } } + ]}}, + { "object.entity": { "name": "Timer", "children": [ + { "source.rdb": { "@table": "timers" } }, + { "field.long": { "name": "id" } }, + { "field.duration": { "name": "timeout", "@required": true, "@default": "PT90M" } }, + { "field.duration": { "name": "grace", "@filterable": true } }, + { "field.duration": { "name": "backoff", "isArray": true } }, + { "field.object": { "name": "window", "@objectRef": "Window", "@storage": "jsonb" } }, + { "field.object": { "name": "limits", "@objectRef": "Limits", "@storage": "flattened" } }, + { "identity.primary": { "@fields": "id", "@generation": "increment" } } + ]}} + ]}} + """; + + private static List Emit(DurationWireForm durations = DurationWireForm.Iso) + { + var r = new MetaDataLoader().Load([new InMemoryStringSource(Model, id: "duration-model.json")]); + Assert.Empty(r.Errors); + r.Root.WireFormat = WireFormat.Default with { Durations = durations }; + var ctx = new GenContext + { + Entities = r.Root.Objects(), Root = r.Root, + Config = new GenConfig { OutDir = Path.Combine(Path.GetTempPath(), "unused-duration"), Namespace = "Acme.Ops" }, + }; + return new IGenerator[] { new EntityGenerator(), new DbContextGenerator(), new FilterAllowlistGenerator(), new RoutesGenerator() } + .SelectMany(g => g.Generate(ctx)).ToList(); + } + + private static Assembly Compile(IEnumerable files) + { + var trees = files.Select(f => CSharpSyntaxTree.ParseText(f.Content, new CSharpParseOptions(LanguageVersion.CSharp12), path: f.Path)); + var comp = CSharpCompilation.Create("duration_" + Guid.NewGuid().ToString("N"), trees, + DbContextCompileTests.BuildReferences(), new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + using var ms = new MemoryStream(); + var emit = comp.Emit(ms); + Assert.True(emit.Success, string.Join("\n", emit.Diagnostics.Where(d => d.Severity == DiagnosticSeverity.Error))); + return Assembly.Load(ms.ToArray()); + } + + [Fact] + public void The_emitted_helper_is_the_production_helper_with_only_the_namespace_changed() + { + var emitted = Emit().Single(f => f.Path == "DurationWire.g.cs"); + var production = File.ReadAllText(Path.Combine( + CorpusPaths.RepoRoot(), "server", "csharp", "MetaObjects.Codegen", "Runtime", "DurationWire.cs")).Replace("\r\n", "\n"); + Assert.Equal(production.Replace("namespace MetaObjects.Codegen.Runtime;", "namespace Acme.Ops;"), emitted.Content.Replace("\r\n", "\n")); + } + + [Fact] + public void A_duration_is_a_TimeSpan_with_the_wire_converter_an_interval_on_Postgres_and_text_elsewhere() + { + var files = Emit(); + var timer = files.Single(f => f.Path == "Timer.g.cs").Content; + Assert.Contains(" [DurationWire.Iso]\n public TimeSpan Timeout { get; set; } = DurationWire.Parse(\"PT90M\");", timer.Replace("\r\n", "\n")); + Assert.Contains("public TimeSpan? Grace { get; set; }", timer); + Assert.Contains("public IList Backoff { get; set; } = new List();", timer); + Assert.DoesNotContain("using MetaObjects", timer); + + var window = files.Single(f => f.Path == "Window.g.cs").Content; + Assert.Contains("[DurationWire.Iso]", window); + + var ctx = files.Single(f => f.Path == "AppDbContext.g.cs").Content.Replace("\r\n", "\n"); + const string Npgsql = "if (Database.ProviderName == \"Npgsql.EntityFrameworkCore.PostgreSQL\")"; + const string Micros = "new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter(" + + "v => global::Acme.Ops.DurationWire.ToMicros(v), v => global::Acme.Ops.DurationWire.FromMicros(v))"; + // Npgsql: the native interval, read through the generated NpgsqlInterval mapping; anything + // else: an integer count of microseconds. + Assert.Contains( + $" {Npgsql}\n" + + " modelBuilder.Entity().Property(x => x.Timeout).HasColumnType(\"interval\").Metadata.SetTypeMapping(DurationInterval.Mapping);\n" + + " else\n" + + $" modelBuilder.Entity().Property(x => x.Timeout).HasConversion({Micros});", + ctx); + Assert.Contains( + $" {Npgsql}\n" + + " modelBuilder.Entity().PrimitiveCollection(x => x.Backoff).Metadata.SetTypeMapping(DurationInterval.ArrayMapping);\n" + + " else\n" + + $" modelBuilder.Entity().PrimitiveCollection(x => x.Backoff).ElementType().HasConversion({Micros});", + ctx); + Assert.Contains("internal static class DurationInterval", ctx); + Assert.DoesNotContain("using Npgsql", ctx); + // A duration member of a jsonb value object is stored as its canonical string on every provider. + Assert.Contains("b.Property(p => p.Length).HasConversion(new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter(", ctx); + Assert.Contains("b.PrimitiveCollection(p => p.Steps).ElementType().HasConversion(new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter(", ctx); + // ...and a duration member of a FLATTENED value object takes the top-level column mapping. + Assert.Contains( + " b.Property(p => p.Cap).HasColumnName(\"limits_cap\");\n" + + $" {Npgsql}\n" + + " b.Property(p => p.Cap).HasColumnType(\"interval\").Metadata.SetTypeMapping(DurationInterval.Mapping);\n" + + " else\n" + + $" b.Property(p => p.Cap).HasConversion({Micros});", + ctx); + Assert.Contains($" b.PrimitiveCollection(p => p.Marks).ElementType().HasConversion({Micros});", ctx); + + var allowlist = files.Single(f => f.Path == "TimerFilterAllowlist.g.cs").Content; + Assert.Contains("[\"grace\"] = new(System.StringComparer.Ordinal) { \"eq\", \"ne\", \"gt\", \"gte\", \"lt\", \"lte\", \"in\", \"isNull\" },", allowlist); + + // The PATCH merge reads a duration through the wire helper, not System.Text.Json's own form. + var routes = files.Single(f => f.Path == "TimerRoutes.g.cs").Content; + Assert.Contains("if (clr == typeof(System.TimeSpan))", routes); + Assert.Contains("DurationWire.TryParseElement(prop.Value, out var __dur)", routes); + } + + // Stand-ins for NpgsqlTypes.NpgsqlInterval: the shape the generated Npgsql mapping reads, and two + // that have drifted from it. + public readonly struct IntervalLike { public int Months { get; } public int Days { get; } public long Time { get; } } + public readonly struct IntervalWithoutMonths { public int Days { get; } public long Time { get; } } + public readonly struct IntervalWithIntTime { public int Months { get; } public int Days { get; } public int Time { get; } } + + [Fact] + public void The_npgsql_mapping_names_what_is_missing_when_the_interval_shape_has_changed() + { + var asm = Compile(Emit()); + var shape = asm.GetType("Acme.Ops.DurationInterval+IntervalShape")!; + var problem = shape.GetMethod("Problem", BindingFlags.NonPublic | BindingFlags.Static)!; + string? Check(Type? t) => (string?)problem.Invoke(null, [t]); + + Assert.Null(Check(typeof(IntervalLike))); + Assert.Contains("no type NpgsqlTypes.NpgsqlInterval was found", Check(null)); + var noMonths = Check(typeof(IntervalWithoutMonths))!; + Assert.Contains("has no Int32 Months property", noMonths); + Assert.Contains("tested with Npgsql 8.0.5", noMonths); + Assert.Contains("has no Int64 Time property", Check(typeof(IntervalWithIntTime))); + Assert.Equal("NpgsqlTypes.NpgsqlInterval, Npgsql", shape.GetField("TypeName", BindingFlags.NonPublic | BindingFlags.Static)!.GetValue(null)); + } + + [Fact] + public void The_generated_entity_compiles_and_crosses_json_in_the_canonical_form() + { + var asm = Compile(Emit()); + var timer = asm.GetType("Acme.Ops.Timer")!; + + var fresh = Activator.CreateInstance(timer)!; + Assert.Equal(TimeSpan.FromMinutes(90), timer.GetProperty("Timeout")!.GetValue(fresh)); // @default + + var row = JsonSerializer.Deserialize( + "{\"Id\":1,\"Timeout\":\"P1DT2H\",\"Grace\":\"-PT90S\",\"Backoff\":[\"PT1S\",\"PT0.5S\"],\"Window\":{\"length\":\"PT5400S\",\"steps\":[\"P1D\"]}}", + timer)!; + var json = JsonSerializer.Serialize(row, timer); + Assert.Contains("\"Timeout\":\"PT26H\"", json); + Assert.Contains("\"Grace\":\"-PT1M30S\"", json); + Assert.Contains("\"Backoff\":[\"PT1S\",\"PT0.5S\"]", json); + // A value object's members cross under their metadata (camelCase) names. + Assert.Contains("\"length\":\"PT1H30M\"", json); + Assert.Contains("\"slack\":null", json); + Assert.Contains("\"steps\":[\"PT24H\"]", json); + + Assert.ThrowsAny(() => JsonSerializer.Deserialize("{\"Timeout\":\"01:30:00\"}", timer)); + Assert.ThrowsAny(() => JsonSerializer.Deserialize("{\"Grace\":\"P1M\"}", timer)); + Assert.ThrowsAny(() => JsonSerializer.Deserialize("{\"Backoff\":[\"P1Y\"]}", timer)); + } + + // wire.durations: native (the default) puts no converter on the property: System.Text.Json's + // own TimeSpan form is the wire form, both ways. + [Fact] + public void Under_native_durations_the_property_has_no_converter_and_crosses_in_the_STJ_form() + { + var files = Emit(DurationWireForm.Native); + Assert.DoesNotContain("[DurationWire.Iso]", files.Single(f => f.Path == "Timer.g.cs").Content); + Assert.DoesNotContain("[DurationWire.Iso]", files.Single(f => f.Path == "Window.g.cs").Content); + var asm = Compile(files); + var timer = asm.GetType("Acme.Ops.Timer")!; + var row = JsonSerializer.Deserialize( + "{\"Id\":1,\"Timeout\":\"1.02:00:00\",\"Grace\":\"-00:01:30\",\"Backoff\":[\"00:00:01\"]}", timer)!; + var json = JsonSerializer.Serialize(row, timer); + Assert.Contains("\"Timeout\":\"1.02:00:00\"", json); + Assert.Contains("\"Grace\":\"-00:01:30\"", json); + Assert.Contains("\"Backoff\":[\"00:00:01\"]", json); + Assert.ThrowsAny(() => JsonSerializer.Deserialize("{\"Timeout\":\"PT1H\"}", timer)); + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/EnumLengthCliTests.cs b/server/csharp/MetaObjects.Codegen.Tests/EnumLengthCliTests.cs new file mode 100644 index 0000000000..0c51bf6f7f --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/EnumLengthCliTests.cs @@ -0,0 +1,60 @@ +// A member longer than the project's enums.maxLength is a metadata/config defect: `dotnet meta +// gen` and `verify --codegen` report it by name and fail, the way TypeScript's `meta gen` does, +// never as an unhandled exception and a stack trace. +using MetaObjects.Codegen.Generators; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +[Collection("Console")] +public sealed class EnumLengthCliTests : IDisposable +{ + private readonly string _root = Path.Combine(Path.GetTempPath(), "mo-enum-cli-" + Guid.NewGuid().ToString("N")); + private string Meta => Path.Combine(_root, "metaobjects"); + private string Out => Path.Combine(_root, "gen"); + + public EnumLengthCliTests() + { + Directory.CreateDirectory(Meta); + Directory.CreateDirectory(Path.Combine(_root, ".metaobjects")); + File.WriteAllText(Path.Combine(_root, ".metaobjects", "config.json"), """{ "schema_version": 1, "enums": { "maxLength": 4 } }"""); + File.WriteAllText(Path.Combine(Meta, "meta.acme.json"), """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Ticket", "children": [ + { "source.rdb": { "@table": "tickets" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "status", "@values": ["OPEN", "CLOSED"] } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """); + } + + public void Dispose() { try { Directory.Delete(_root, recursive: true); } catch { } } + + private (int Code, string Text) Run(params string[] args) + { + var (prevOut, prevErr) = (Console.Out, Console.Error); + var (o, e) = (new StringWriter(), new StringWriter()); + Console.SetOut(o); + Console.SetError(e); + try { return (CodegenCli.Run(args, [new DbContextGenerator()]), o + "\n" + e); } + finally { Console.SetOut(prevOut); Console.SetError(prevErr); } + } + + [Fact] + public void Gen_reports_the_member_by_name_and_fails() + { + var (code, text) = Run("gen", Meta, "--out", Out, "--namespace", "Acme.Generated"); + Assert.NotEqual(0, code); + Assert.Contains("field.enum \"Ticket.status\" has the member \"CLOSED\"", text); + } + + [Fact] + public void Verify_codegen_reports_the_member_by_name_and_fails() + { + var (code, text) = Run("verify", "--codegen", Meta, "--out", Out, "--namespace", "Acme.Generated"); + Assert.NotEqual(0, code); + Assert.Contains("field.enum \"Ticket.status\" has the member \"CLOSED\"", text); + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/EnumLengthCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/EnumLengthCodegenTests.cs new file mode 100644 index 0000000000..d5213eb399 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/EnumLengthCodegenTests.cs @@ -0,0 +1,55 @@ +// A string-stored enum with a column length (its @maxLength, else the project's +// enums.maxLength — EnumLength.ResolveMaxLength) maps with HasMaxLength(n), the VARCHAR(n) +// `meta migrate` creates; with none, the mapping is unchanged. +using MetaObjects.Codegen.Generators; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public class EnumLengthCodegenTests +{ + private static string DbContextFor(string statusAttrs, long? projectDefault = null) + { + var model = $$$""" + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Ticket", "children": [ + { "source.rdb": { "@table": "tickets" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "status", "@values": ["OPEN", "CLOSED"]{{{statusAttrs}}} } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + var r = new MetaDataLoader().Load([new InMemoryStringSource(model, id: "enum-length.json")]); + Assert.Empty(r.Errors); + if (projectDefault is { } d) r.Root.EnumDefaults = new EnumDefaults(d); + var ctx = new GenContext + { + Entities = r.Root.Objects(), Root = r.Root, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated" }, + }; + return new DbContextGenerator().Generate(ctx).Single().Content; + } + + [Fact] + public void An_enum_with_maxLength_maps_with_that_length() + { + Assert.Contains("HasConversion().HasMaxLength(16);", DbContextFor(""", "@maxLength": 16""")); + } + + [Fact] + public void The_project_default_sizes_an_enum_with_no_maxLength() + { + Assert.Contains("HasConversion().HasMaxLength(24);", DbContextFor("", projectDefault: 24)); + } + + [Fact] + public void No_length_anywhere_leaves_the_mapping_unchanged() + { + var dbContext = DbContextFor(""); + Assert.Contains("HasConversion();", dbContext); + Assert.DoesNotContain("HasMaxLength", dbContext); + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/EnumLengthConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/EnumLengthConformanceTests.cs new file mode 100644 index 0000000000..fe0a3c2557 --- /dev/null +++ b/server/csharp/MetaObjects.Codegen.Tests/EnumLengthConformanceTests.cs @@ -0,0 +1,101 @@ +// EnumLengthConformanceTests — runs the shared cross-port enum-length corpus +// (fixtures/enum-length-conformance/) against the C# port. Each case's `enums` block is +// written to a throwaway project's .metaobjects/config.json and read with NeutralConfig (the +// reader every project-aware load uses), put on the loaded meta.enums.json root, and every +// Ticket enum's column length resolved through EnumLength.ResolveMaxLength. Each `overflow` +// case must be refused with ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH, and each `invalid` block +// refused when the config is read. +using System.Text.Json; +using MetaObjects; +using MetaObjects.Config; +using MetaObjects.Loader; +using MetaObjects.Meta; +using Xunit; + +namespace MetaObjects.Codegen.Tests; + +public class EnumLengthConformanceTests +{ + private static string FixtureDir => + Path.Combine(CorpusPaths.RepoRoot(), "fixtures", "enum-length-conformance"); + + private static JsonDocument Corpus() => JsonDocument.Parse(File.ReadAllText(Path.Combine(FixtureDir, "cases.json"))); + + private static JsonElement Entry(string section, string name) + { + using var doc = Corpus(); + return doc.RootElement.GetProperty(section).EnumerateArray() + .Single(c => c.GetProperty("name").GetString() == name).Clone(); + } + + private static TheoryData Names(string section) + { + var data = new TheoryData(); + using var doc = Corpus(); + foreach (var el in doc.RootElement.GetProperty(section).EnumerateArray()) data.Add(el.GetProperty("name").GetString()!); + return data; + } + + public static TheoryData CaseNames() => Names("cases"); + public static TheoryData OverflowNames() => Names("overflow"); + public static TheoryData InvalidNames() => Names("invalid"); + + /// Reads as a project's config.json `enums` block. + private static EnumDefaults ReadEnums(JsonElement enums) + { + var dir = Path.Combine(Path.GetTempPath(), "mo-enums-conf-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(dir, ".metaobjects")); + var body = enums.ValueKind == JsonValueKind.Null + ? """{ "schema_version": 1 }""" + : $$"""{ "schema_version": 1, "enums": {{enums.GetRawText()}} }"""; + File.WriteAllText(Path.Combine(dir, ".metaobjects", "config.json"), body); + try + { + return NeutralConfig.Read(dir)?.Enums ?? EnumDefaults.Default; + } + finally + { + Directory.Delete(dir, recursive: true); + } + } + + private static MetaField TicketField(EnumDefaults defaults, string name) + { + var result = new MetaDataLoader().Load([new FileSource(Path.Combine(FixtureDir, "meta.enums.json"))]); + Assert.True(result.Errors.Count == 0, string.Join("\n", result.Errors.Select(e => $"{e.Code}: {e.Message}"))); + result.Root.EnumDefaults = defaults; + return result.Root.FindObject("Ticket")!.Fields().Single(f => f.Name == name); + } + + [Theory] + [MemberData(nameof(CaseNames))] + public void Resolves_every_enum_column_length(string name) + { + var c = Entry("cases", name); + var defaults = ReadEnums(c.GetProperty("enums")); + foreach (var p in c.GetProperty("lengths").EnumerateObject()) + { + long? expected = p.Value.ValueKind == JsonValueKind.Null ? null : p.Value.GetInt64(); + Assert.Equal(expected, EnumLength.ResolveMaxLength(TicketField(defaults, p.Name))); + } + } + + [Theory] + [MemberData(nameof(OverflowNames))] + public void Refuses_a_member_longer_than_the_project_default(string name) + { + var o = Entry("overflow", name); + var field = TicketField(ReadEnums(o.GetProperty("enums")), o.GetProperty("field").GetString()!); + var ex = Assert.Throws(() => EnumLength.ResolveMaxLength(field)); + Assert.Equal(ErrorCode.ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH, ex.Code); + Assert.Contains(o.GetProperty("member").GetString()!, ex.Message); + } + + [Theory] + [MemberData(nameof(InvalidNames))] + public void Refuses_an_invalid_enums_block(string name) + { + var bad = Entry("invalid", name); + Assert.Throws(() => ReadEnums(bad.GetProperty("enums"))); + } +} diff --git a/server/csharp/MetaObjects.Codegen.Tests/NamesGeneratorTests.cs b/server/csharp/MetaObjects.Codegen.Tests/NamesGeneratorTests.cs index 5ae9bb5e7c..3beae425ea 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/NamesGeneratorTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/NamesGeneratorTests.cs @@ -886,4 +886,97 @@ public void GenConfig_IncludeNames_defaults_to_false_so_a_bare_config_falls_back // artifact's symbol appears anywhere in the emitted entity. Assert.DoesNotContain("SubscriberNames", src); } + + private const string FlattenedModel = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.value": { "name": "Address", "children": [ + { "field.string": { "name": "street" } }, + { "field.string": { "name": "postalCode", "@column": "ZIP" } } + ]}}, + { "object.entity": { "name": "Shipment", "children": [ + { "source.rdb": { "@table": "shipments" } }, + { "field.long": { "name": "id" } }, + { "field.object": { "name": "shipTo", "@objectRef": "Address", "@storage": "flattened" } }, + { "field.object": { "name": "payload", "@objectRef": "Address" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + + [Fact] + public void A_flattened_member_gets_a_column_constant_beside_its_field_constants() + { + var content = new NamesGenerator().Generate(Ctx(Load(FlattenedModel))).Single(f => f.Path == "ShipmentNames.g.cs").Content; + + Assert.Contains("public const string ShipToStreetColumn = \"ship_to_street\";", content); + Assert.Contains("public const string ShipToPostalCodeColumn = \"ship_to_ZIP\";", content); + Assert.Contains("public const string ShipToField = \"shipTo\";", content); + Assert.Contains("public const string PayloadColumn = \"payload\";", content); + // A flattened field has no column of its own: no own constant, no own map key, no Obsolete. + Assert.DoesNotContain("ShipToColumn", content); + Assert.DoesNotContain("[\"shipTo\"]", content); + Assert.DoesNotContain("Obsolete", content); + Assert.Contains("[\"shipTo.street\"] = ShipToStreetColumn,", content); + Assert.Contains("[\"shipTo.postalCode\"] = ShipToPostalCodeColumn,", content); + } + + [Fact] + public void A_flattened_member_constant_that_collides_with_a_field_constant_is_left_out_with_a_warning() + { + var model = FlattenedModel.Replace( + "\"@storage\": \"flattened\" }", + "\"@storage\": \"flattened\", \"@flattenedColumnPrefix\": \"st_\" } },\n { \"field.string\": { \"name\": \"shipToStreet\" }"); + var warnings = new List(); + var root = Load(model); + var ctx = new GenContext + { + Entities = root.Objects(), Root = root, Warn = warnings.Add, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", ColumnNamingStrategy = ColumnNamingStrategy.SnakeCase, IncludeNames = true }, + }; + var content = new NamesGenerator().Generate(ctx).Single(f => f.Path == "ShipmentNames.g.cs").Content; + + // The field's constant wins; the model generates, as it did before member constants. + Assert.Contains("public const string ShipToStreetColumn = \"ship_to_street\";", content); + Assert.DoesNotContain("\"st_street\"", content); + Assert.Contains("public const string ShipToPostalCodeColumn = \"st_ZIP\";", content); + Assert.Contains(warnings, w => w.Contains("ShipToStreetColumn")); + } + + [Fact] + public void DbContext_keeps_the_literal_flattened_columns() + { + // An owned (ejected) names generator from before member constants does not emit them, + // so the EF mapping must not reference them. + var dbContext = new DbContextGenerator().Generate(Ctx(Load(FlattenedModel))).Single().Content; + + Assert.Contains("b.Property(p => p.Street).HasColumnName(\"ship_to_street\");", dbContext); + Assert.DoesNotContain("ShipToStreetColumn", dbContext); + } + + [Fact] + public void A_child_that_redeclares_a_flattened_field_as_jsonb_does_not_inherit_its_member_constants() + { + const string model = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.value": { "name": "Address", "children": [ { "field.string": { "name": "street" } } ]}}, + { "object.entity": { "name": "Base", "abstract": true, "children": [ + { "field.object": { "name": "shipTo", "@objectRef": "Address", "@storage": "flattened" } } + ]}}, + { "object.entity": { "name": "Order", "extends": "Base", "children": [ + { "source.rdb": { "@table": "orders" } }, + { "field.long": { "name": "id" } }, + { "field.object": { "name": "shipTo", "@objectRef": "Address", "@storage": "jsonb" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + var files = new NamesGenerator().Generate(Ctx(Load(model))).ToList(); + var order = files.Single(f => f.Path == "OrderNames.g.cs").Content; + + // Extending BaseNames would make BaseNames.ShipToStreetColumn a member of OrderNames, + // naming a column `orders` does not have; the artifact is emitted complete instead. + Assert.Contains("public abstract class OrderNames\n", order.Replace("\r\n", "\n")); + Assert.DoesNotContain("ShipToStreetColumn", order); + Assert.Contains("public const string ShipToColumn = \"ship_to\";", order); + } } diff --git a/server/csharp/MetaObjects.Codegen.Tests/NamingConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/NamingConformanceTests.cs index 304daf0902..3755e1179f 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/NamingConformanceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/NamingConformanceTests.cs @@ -10,7 +10,9 @@ // and CSharpNaming.Column — never a reimplementation. Each `invalid` block must be refused // when the config is read. using System.Text.Json; +using MetaObjects; using MetaObjects.Config; +using MetaObjects.Core.Field; using MetaObjects.Loader; using MetaObjects.Meta; using Xunit; @@ -121,14 +123,54 @@ public void Resolves_every_table_and_column_name(string name) foreach (var (key, expectedColumn) in c.Columns) { - var parts = key.Split('.', 2); + var parts = key.Split('.', 3); var obj = root.FindObject(parts[0]); Assert.NotNull(obj); // ADR-0039: Fields() is the resolving accessor (an inherited field must resolve). var field = obj!.Fields().FirstOrDefault(f => f.Name == parts[1]); Assert.NotNull(field); - // CSharpNaming.Column is the exact door the entity/names generators call. - Assert.Equal(expectedColumn, CSharpNaming.Column(field!, naming.Columns)); + if (parts.Length == 2) + { + // CSharpNaming.Column is the exact door the entity/names generators call. + Assert.Equal(expectedColumn, CSharpNaming.Column(field!, naming.Columns)); + continue; + } + // `..`: a member of the flattened value object the field + // embeds, named by core naming, the door the DbContext generator calls. + var target = NamingRefs.RefTarget(field!, FieldConstants.FIELD_ATTR_OBJECT_REF) as MetaObject; + Assert.NotNull(target); + var member = target!.Fields().FirstOrDefault(f => f.Name == parts[2]); + Assert.NotNull(member); + Assert.Equal(expectedColumn, PhysicalNaming.ResolveFlattenedColumnName(field!, member!, naming.Columns)); + } + } + + /// + /// The names artifact names every column in the corpus the way the resolvers above do: a + /// scalar field's Column, and each member of a flattened value object under its + /// field's Members. + /// + [Theory] + [MemberData(nameof(ValidCaseNames))] + public void Names_artifact_carries_every_column(string name) + { + var c = LoadValidCase(name); + var naming = ReadNaming(c.Naming); + var root = LoadModel(naming); + + foreach (var (key, expectedColumn) in c.Columns) + { + var parts = key.Split('.', 3); + var names = CSharpNaming.ResolveObjectNames(root.FindObject(parts[0])!, naming.Columns); + Assert.NotNull(names); + var field = names!.Fields[parts[1]]; + if (parts.Length == 2) + { + Assert.Equal(expectedColumn, field.Column); + continue; + } + Assert.NotNull(field.Members); + Assert.Equal(expectedColumn, field.Members![parts[2]].Column); } } diff --git a/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs index a704ee3c23..9a36fe8c73 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/ObjectFieldCodegenTests.cs @@ -96,6 +96,75 @@ public void Referenced_value_object_is_emitted_as_a_plain_poco() Assert.DoesNotContain("[Key]", address); } + // @flattenedColumnPrefix replaces the default `_` prefix, verbatim ("" keeps the members' + // own columns), and the default prefix follows the project's column rule: the names + // `meta migrate` creates for the same model (core naming's ResolveFlattenedColumnPrefix). + [Theory] + [InlineData(ColumnNamingStrategy.Literal, "street", "Officestreet", "homeAddress_street")] + [InlineData(ColumnNamingStrategy.UpperFirst, "Street", "OfficeStreet", "HomeAddress_Street")] + public void DbContext_flattened_columns_follow_column_prefix_and_the_column_rule( + ColumnNamingStrategy strategy, string plain, string prefixed, string byDefault) + { + const string model = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.value": { "name": "Address", "children": [ { "field.string": { "name": "street" } } ]}}, + { "object.entity": { "name": "Customer", "children": [ + { "source.rdb": { "@table": "customers" } }, + { "field.long": { "name": "id" } }, + { "field.object": { "name": "mailing", "@objectRef": "Address", "@storage": "flattened", "@flattenedColumnPrefix": "" } }, + { "field.object": { "name": "work", "@objectRef": "Address", "@storage": "flattened", "@flattenedColumnPrefix": "Office" } }, + { "field.object": { "name": "homeAddress", "@objectRef": "Address", "@storage": "flattened" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + var r = new MetaDataLoader().Load([new InMemoryStringSource(model, id: "prefix.json")]); + Assert.Empty(r.Errors); + var ctx = new GenContext + { + Entities = r.Root.Objects(), Root = r.Root, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", ColumnNamingStrategy = strategy }, + }; + var dbContext = new DbContextGenerator().Generate(ctx).Single().Content; + + Assert.Contains($"b.Property(p => p.Street).HasColumnName(\"{plain}\");", dbContext); + Assert.Contains($"b.Property(p => p.Street).HasColumnName(\"{prefixed}\");", dbContext); + Assert.Contains($"b.Property(p => p.Street).HasColumnName(\"{byDefault}\");", dbContext); + } + + // Each flattened member's column is core naming's, whole: the generator does not build the + // member half itself. A member whose @column is "" is the case that told them apart: core + // treats an empty @column as absent, so the column is `bill_street`, not `bill_`. + [Fact] + public void DbContext_flattened_member_columns_are_core_namings() + { + const string model = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.value": { "name": "Address", "children": [ { "field.string": { "name": "street", "@column": "" } } ]}}, + { "object.entity": { "name": "Customer", "children": [ + { "source.rdb": { "@table": "customers" } }, + { "field.long": { "name": "id" } }, + { "field.object": { "name": "billing", "@objectRef": "Address", "@storage": "flattened", "@column": "bill" } }, + { "identity.primary": { "@fields": "id" } } + ]}} + ]}} + """; + var r = new MetaDataLoader().Load([new InMemoryStringSource(model, id: "member.json")]); + Assert.Empty(r.Errors); + var ctx = new GenContext + { + Entities = r.Root.Objects(), Root = r.Root, + Config = new GenConfig { OutDir = "/tmp", Namespace = "Acme.Generated", ColumnNamingStrategy = ColumnNamingStrategy.SnakeCase }, + }; + var dbContext = new DbContextGenerator().Generate(ctx).Single().Content; + + var billing = r.Root.FindObject("Customer")!.Fields().Single(f => f.Name == "billing"); + var street = r.Root.FindObject("Address")!.Fields().Single(f => f.Name == "street"); + var expected = PhysicalNaming.ResolveFlattenedColumnName(billing, street, ColumnNamingStrategy.SnakeCase); + Assert.Equal("bill_street", expected); + Assert.Contains($"b.Property(p => p.Street).HasColumnName(\"{expected}\");", dbContext); + } + [Fact] public void DbContext_configures_owned_types_flattened_and_json() { diff --git a/server/csharp/MetaObjects.Codegen.Tests/ReportExportGeneratorTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ReportExportGeneratorTests.cs index 2c3fe6fe11..66313734d7 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/ReportExportGeneratorTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/ReportExportGeneratorTests.cs @@ -35,9 +35,25 @@ public class ReportExportGeneratorTests ] } } """; - private static MetaRoot Load() + // The same shop with a field.duration dimension on the served report. + private const string DurationModel = """ + { "metadata.root": { "package": "acme::shop", "children": [ + { "object.entity": { "name": "Line", "children": [ + { "source.rdb": { "@table": "lines" } }, + { "field.long": { "name": "id", "@required": true } }, + { "field.duration": { "name": "wait" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } }, + { "dimension.attribute": { "name": "wait", "@of": "Line.wait" } }, + { "measure.aggregate": { "name": "lines", "@agg": "count", "@of": "Line.id" } } + ] } }, + { "object.report": { "name": "WaitTotals", "@from": "Line", "@dimensions": ["wait"], "@measures": ["lines"], + "children": [ { "source.rdb": { "@kind": "view", "@view": "v_wait_totals" } } ] } } + ] } } + """; + + private static MetaRoot Load(string model = Model) { - var r = new MetaDataLoader().Load([new InMemoryStringSource(Model, id: "meta.shop.json")]); + var r = new MetaDataLoader().Load([new InMemoryStringSource(model, id: "meta.shop.json")]); Assert.True(r.Errors.Count == 0, string.Join("\n", r.Errors.Select(e => e.Message))); return r.Root; } @@ -130,9 +146,9 @@ public static string Xml(object?[][] rows, string[] cols) } """; - private static MethodInfo[] Compile(ReportExportGenerator gen, string? decimals = null) + private static MethodInfo[] Compile(ReportExportGenerator gen, string? decimals = null, string model = Model) { - var root = Load(); + var root = Load(model); if (decimals == "string") root.WireFormat = new WireFormat(DecimalWireForm.String, NullMemberForm.Native); var writer = gen.Generate(Ctx(root)).Single(f => f.Path == "ReportExportWriter.g.cs").Content; var tpa = ((string)AppContext.GetData("TRUSTED_PLATFORM_ASSEMBLIES")!).Split(Path.PathSeparator) @@ -150,6 +166,32 @@ private static MethodInfo[] Compile(ReportExportGenerator gen, string? decimals private static string Csv(MethodInfo[] h, object?[][] rows, params string[] cols) => (string)h[0].Invoke(null, [rows, cols, null])!; private static string Xml(MethodInfo[] h, object?[][] rows, params string[] cols) => (string)h[1].Invoke(null, [rows, cols])!; + private static string Writer(string model) => + new ReportExportGenerator().Generate(Ctx(Load(model))).Single(f => f.Path == "ReportExportWriter.g.cs").Content; + + // field.duration: the writer of a model with no duration column is the writer from before + // durations existed, byte for byte (the hash of that writer for this model, pinned). + [Fact] + public void The_writer_of_a_model_without_a_duration_column_is_unchanged() + { + var writer = Writer(Model); + Assert.DoesNotContain("TimeSpan ts", writer); + Assert.DoesNotContain("duration", writer, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("@DURATION", writer); + var hash = Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(writer.Replace("\r\n", "\n")))); + Assert.Equal("78913C645BB9D25C334F2285C210D0192510618A63C564AA7833C8AEA4A0B5AF", hash); + } + + [Fact] + public void A_duration_column_is_written_in_its_canonical_form_and_never_guarded() + { + Assert.Contains("case TimeSpan ts: return new(Duration(ts), false);", Writer(DurationModel)); + var h = Compile(new ReportExportGenerator(), model: DurationModel); + Assert.Equal("w\r\nPT26H\r\n-PT1M30S\r\nPT0.5S\r\nPT0.0000005S\r\nPT0S\r\n-PT256204778H48M5.4775808S\r\n\r\n", + Csv(h, [[TimeSpan.FromHours(26)], [TimeSpan.FromSeconds(-90)], [TimeSpan.FromMilliseconds(500)], [TimeSpan.FromTicks(5)], [TimeSpan.Zero], [TimeSpan.MinValue], [null]], "w")); + Assert.Contains("-PT1M30S", Xml(h, [[TimeSpan.FromSeconds(-90)]], "w")); + } + [Fact] public void Csv_quotes_ends_every_record_with_crlf_and_guards_text_cells_only() { diff --git a/server/csharp/MetaObjects.Codegen.Tests/ValidationConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/ValidationConformanceTests.cs index b2724518e0..88343c77af 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/ValidationConformanceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/ValidationConformanceTests.cs @@ -48,7 +48,7 @@ public static IEnumerable Cases() [Fact] public void Discovers_all_validation_conformance_cases() { - Assert.Equal(42, Cases().Count()); + Assert.Equal(54, Cases().Count()); } [Theory] @@ -117,6 +117,8 @@ private static Assembly Compile() new FileSource(Path.Combine(CorpusDir(), "meta.json")), ]); Assert.Empty(loadResult.Errors); + // The corpus cases state durations in ISO-8601, the cross-port wire form. + loadResult.Root.WireFormat = WireFormat.Default with { Durations = DurationWireForm.Iso }; var ctx = new GenContext { diff --git a/server/csharp/MetaObjects.Codegen.Tests/WireConformanceTests.cs b/server/csharp/MetaObjects.Codegen.Tests/WireConformanceTests.cs index a2f320e423..f06081b5b3 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/WireConformanceTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/WireConformanceTests.cs @@ -68,6 +68,7 @@ public void Resolves_each_wire_block(string name) var wire = ReadWire(c.GetProperty("wire")); Assert.Equal(c.GetProperty("decimals").GetString(), WireFormat.Spelling(wire.Decimals)); Assert.Equal(c.GetProperty("nullMembers").GetString(), WireFormat.Spelling(wire.NullMembers)); + Assert.Equal(c.GetProperty("durations").GetString(), WireFormat.Spelling(wire.Durations)); } [Theory] @@ -82,7 +83,7 @@ public void Refuses_invalid_wire_blocks(string name) [Fact] public void A_project_without_the_setting_loads_with_the_native_default() { - Assert.Equal(new WireFormat(DecimalWireForm.Native, NullMemberForm.Native), WireFormat.Default); + Assert.Equal(new WireFormat(DecimalWireForm.Native, NullMemberForm.Native, DurationWireForm.Native), WireFormat.Default); var r = new MetaObjects.Loader.MetaDataLoader().Load([new MetaObjects.Loader.InMemoryStringSource( """{ "metadata.root": { "package": "acme", "children": [] } }""", id: "empty.json")]); Assert.Equal(WireFormat.Default, r.Root.WireFormat); diff --git a/server/csharp/MetaObjects.Codegen/CSharpNaming.cs b/server/csharp/MetaObjects.Codegen/CSharpNaming.cs index 29e96e4ed4..d8e99fa656 100644 --- a/server/csharp/MetaObjects.Codegen/CSharpNaming.cs +++ b/server/csharp/MetaObjects.Codegen/CSharpNaming.cs @@ -18,8 +18,16 @@ namespace MetaObjects.Codegen; -/// Physical name + logical field name for one field (§A2/§A3 names artifact). -public sealed record FieldNames(string Name, string Column); +/// +/// Physical name + logical field name for one field (§A2/§A3 names artifact). +/// is set only on a @storage: "flattened" value object, +/// and there it is the real column information: such a field has no column of its own, and +/// each member of the value object is a column of the owning table, named by core naming +/// (, +/// the function meta migrate names them with), keyed by the member's field name. +/// is null on a flattened field: it names no column of the table. +/// +public sealed record FieldNames(string Name, string? Column, IReadOnlyDictionary? Members = null); /// /// One source.rdb child, under the ROLE it plays. @@ -203,11 +211,14 @@ public static class CSharpNaming // the Postgres-native inet type). Both are string-backed on the wire. [FIELD_SUBTYPE_URI] = "Uri", [FIELD_SUBTYPE_INET] = "IPAddress", + // field.duration binds to the native System.TimeSpan value type (DB column is the + // Postgres-native interval, a BIGINT count of microseconds elsewhere); string-backed (ISO-8601) on the wire. + [FIELD_SUBTYPE_DURATION] = "TimeSpan", }; /// Value types that take a ? suffix when nullable (vs. reference types). private static readonly HashSet ValueTypes = new(StringComparer.Ordinal) - { "int", "long", "double", "float", "decimal", "bool", "DateOnly", "TimeOnly", "DateTime", "DateTimeOffset", "Guid" }; + { "int", "long", "double", "float", "decimal", "bool", "DateOnly", "TimeOnly", "DateTime", "DateTimeOffset", "Guid", "TimeSpan" }; /// /// ADR-0036 Wave 3 — the CANONICAL hostname matcher for @stringFormat: hostname, @@ -442,6 +453,7 @@ public static string Column(MetaField field, ColumnNamingStrategy strategy = Col ColumnNamingStrategy.Literal => name, ColumnNamingStrategy.SnakeCase => ToSnakeCase(name), ColumnNamingStrategy.KebabCase => ToSnakeCase(name).Replace('_', '-'), + ColumnNamingStrategy.UpperFirst => PhysicalNaming.ToUpperFirstColumn(name), _ => name, }; @@ -513,11 +525,11 @@ public static bool HasPrimarySource(MetaObject obj) => // ADR-0039: Fields() is the RESOLVING accessor — an inherited @column must // resolve here, or the constant disagrees with the column EF actually binds. foreach (var f in obj.Fields()) - fields[f.Name] = new FieldNames(f.Name, Column(f, strategy)); + fields[f.Name] = FieldNamesOf(f, strategy); // ADR-0039's sanctioned own-accessor use: what this artifact DECLARES. var ownFields = new Dictionary(StringComparer.Ordinal); foreach (var f in obj.OwnFields()) - ownFields[f.Name] = new FieldNames(f.Name, Column(f, strategy)); + ownFields[f.Name] = FieldNamesOf(f, strategy); var superObj = NamesArtifactSuperOf(obj); // Identity of the resolved source NODE, not equality of the resolved strings: a @@ -719,10 +731,10 @@ private static bool DeclaresNamesContent(MetaObject obj) => var fields = new Dictionary(StringComparer.Ordinal); foreach (var f in obj.Fields()) - fields[f.Name] = new FieldNames(f.Name, Column(f, strategy)); + fields[f.Name] = FieldNamesOf(f, strategy); var ownFields = new Dictionary(StringComparer.Ordinal); foreach (var f in own) - ownFields[f.Name] = new FieldNames(f.Name, Column(f, strategy)); + ownFields[f.Name] = FieldNamesOf(f, strategy); return new ObjectNames { @@ -810,6 +822,28 @@ public static string SourceMemberName(string role, string member) => public static string KeyMemberName(string type, string keyName, string member) => Pascal(type) + PascalToken(keyName) + PascalToken(member); + /// The constant member naming a flattened member's column: ShipToStreetColumn. + public static string FlattenedColumnMemberName(string fieldName, string memberName) => + FieldMemberName(fieldName + Pascal(memberName), MEMBER_COLUMN); + + /// + /// One field's names. The flattened test is migrate's own: a field.object whose + /// resolved @storage is flattened. A flattened field whose @objectRef + /// does not resolve has no members; the loader refuses that model before codegen runs. + /// + public static FieldNames FieldNamesOf(MetaField f, ColumnNamingStrategy strategy) + { + if (f.SubType != FIELD_SUBTYPE_OBJECT || f.Storage != STORAGE_FLATTENED) + return new FieldNames(f.Name, Column(f, strategy)); + var members = new Dictionary(StringComparer.Ordinal); + if (NamingRefs.RefTarget(f, FIELD_ATTR_OBJECT_REF) is MetaObject vo) + { + foreach (var m in vo.Fields()) + members[m.Name] = new FieldNames(m.Name, PhysicalNaming.ResolveFlattenedColumnName(f, m, strategy)); + } + return new FieldNames(f.Name, null, members); + } + /// One field member: EmailField / EmailColumn. See /// for why this stays on . public static string FieldMemberName(string fieldName, string member) => diff --git a/server/csharp/MetaObjects.Codegen/CodegenCli.cs b/server/csharp/MetaObjects.Codegen/CodegenCli.cs index 8bb057b73a..a393994dcf 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenCli.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenCli.cs @@ -269,7 +269,7 @@ public static GenOutcome RunGen( // A bad template ref / wrong --template-root surfaces as a clean error, not a stack trace. return new GenOutcome([$"{RenderFailedPrefix}{ex.Message}"], null); } - catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or MetaModelException) { // An output-pattern error or a duplicate output-path collision surfaces lazily // during the walk — a clean error, not a stack trace. @@ -482,6 +482,7 @@ private static int Unknown(string cmd) { ColumnNamingStrategy.SnakeCase => "snake_case", ColumnNamingStrategy.KebabCase => "kebab-case", + ColumnNamingStrategy.UpperFirst => "upperFirst", _ => "literal", }; @@ -809,12 +810,12 @@ private static int RunGenArgs(string[] rest, IReadOnlyList declared) /// Every naming other than , in the order verify probes them (the /// Python port probes the same order): the used table rule first, so a different column /// rule alone is tried before a different table rule; within each, the used column rule - /// first, then snake_case, literal, kebab-case. + /// first, then snake_case, literal, kebab-case, upperFirst. /// private static IEnumerable NamingCandidates(PhysicalNaming used) { ColumnNamingStrategy[] columns = - [ColumnNamingStrategy.SnakeCase, ColumnNamingStrategy.Literal, ColumnNamingStrategy.KebabCase]; + [ColumnNamingStrategy.SnakeCase, ColumnNamingStrategy.Literal, ColumnNamingStrategy.KebabCase, ColumnNamingStrategy.UpperFirst]; TableNamingRule[] tables = [TableNamingRule.SnakeCase, TableNamingRule.Literal]; foreach (var t in tables.OrderBy(t => t == used.Tables ? 0 : 1)) foreach (var c in columns.OrderBy(c => c == used.Columns ? 0 : 1)) @@ -844,7 +845,7 @@ private static string NamingDiffers(PhysicalNaming committed, PhysicalNaming use /// /// The other wire setting (ADR-0019 Amendment 2) that reproduces the committed output - /// exactly, or null. Candidates are every combination of the two keys other than + /// exactly, or null. Candidates are every combination of the three keys other than /// , native before string and native before emit before omit; /// emit is never offered, because in C# it generates what native does. /// @@ -854,14 +855,15 @@ private static string NamingDiffers(PhysicalNaming committed, PhysicalNaming use var generated = used.NullMembers == NullMemberForm.Emit ? used with { NullMembers = NullMemberForm.Native } : used; foreach (var decimals in new[] { DecimalWireForm.Native, DecimalWireForm.String }) foreach (var nulls in new[] { NullMemberForm.Native, NullMemberForm.Omit }) - { - var candidate = new WireFormat(decimals, nulls); - if (candidate == generated) continue; - var root = load(); - root.WireFormat = candidate; - var probe = CodegenDrift.Compute(config, root, suite, narrowed); - if (probe.Error is null && probe.Clean) return candidate; - } + foreach (var durations in new[] { DurationWireForm.Native, DurationWireForm.Iso }) + { + var candidate = new WireFormat(decimals, nulls, durations); + if (candidate == generated) continue; + var root = load(); + root.WireFormat = candidate; + var probe = CodegenDrift.Compute(config, root, suite, narrowed); + if (probe.Error is null && probe.Clean) return candidate; + } return null; } @@ -871,7 +873,7 @@ private static string NamingDiffers(PhysicalNaming committed, PhysicalNaming use private static string WireDiffers(WireFormat committed, WireFormat used) => $"The committed output was generated with {committed.Block()}; this run used {used.Block()} " + "(.metaobjects/config.json; each key defaults to native). Do not regenerate: that changes the " + - "JSON your API writes for decimals or for a value object's null members. Declare " + + "JSON your API writes for decimals, durations or a value object's null members. Declare " + $"{committed.Block()} in .metaobjects/config.json, and gen and verify --codegen both read it."; private static int RunVerifyArgs(string[] rest, IReadOnlyList declared) diff --git a/server/csharp/MetaObjects.Codegen/CodegenDrift.cs b/server/csharp/MetaObjects.Codegen/CodegenDrift.cs index 29711710e4..98b698ab07 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenDrift.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenDrift.cs @@ -98,7 +98,7 @@ public static Result Compute(GenConfig config, MetaRoot root, IReadOnlyList temps[roots.FindIndex(r => r.Committed == Path.GetFullPath(realRootFor(g)))]); } - catch (Exception ex) when (ex is ArgumentException or InvalidOperationException) + catch (Exception ex) when (ex is ArgumentException or InvalidOperationException or MetaModelException) { return new Result { Clean = false, Error = $"verify --codegen: {ex.Message}" }; } diff --git a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs index aa604b2b1f..06b8fbb41f 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs @@ -151,6 +151,8 @@ public virtual IEnumerable Generate(GenContext ctx) // creates (ADR-0015). foreach (var f in p.Fields().Where(f => f.SubType == FIELD_SUBTYPE_MAP)) modelLines.Add(MapJsonbConfig(name, p, f, ctx)); + // A duration column on a view reads through the same provider-dependent mapping. + AddDurationConfig(name, p, p.Fields(), ctx, modelLines); } foreach (var e in objects.Where(o => o.IsEntity() && !o.IsReadOnlyProjection())) { @@ -275,6 +277,7 @@ public virtual IEnumerable Generate(GenContext ctx) // did not model jsonbObjectsOnly suppressing the flattened arm on a read model. if (modelLines.Any(l => l.Contains(MapJsonbHelperName))) EmitMapJsonbHelper(sb); sb.AppendLine("}"); + if (modelLines.Any(l => l.Contains(DurationIntervalHelperName + "."))) EmitDurationIntervalHelper(sb); return [new EmittedFile("AppDbContext.g.cs", sb.ToString())]; } @@ -926,7 +929,13 @@ private static bool NeedsUnmappedEnumHelper(IEnumerable objects, Met private static string EnumConversionCall(string owner, MetaObject entity, MetaField f, GenConfig config) { var intMap = IntValueMapOf(f); - if (intMap is null) return "HasConversion()"; + // A string-backed enum's column length (EnumLength.ResolveMaxLength: its @maxLength, else + // the project's enums.maxLength) is the VARCHAR(n) `meta migrate` creates. An array enum + // is a text[] column, whose elements carry no length. + if (intMap is null) + return !f.ResolvedIsArray() && EnumLength.ResolveMaxLength(f) is { } length + ? $"HasConversion().HasMaxLength({length})" + : "HasConversion()"; var members = f.EffectiveEnumValues ?? new List(); if (members.Count == 0) return "HasConversion()"; @@ -1081,10 +1090,220 @@ private void EmitFieldTypeConfig( $" modelBuilder.Entity<{className}>().Property(x => x.{prop}).HasColumnType(\"inet\");"); } + // field.duration — a System.TimeSpan. Npgsql maps it to the native `interval` (and an array + // to `interval[]`), reading an interval with a month or year part as an error. Every other + // provider stores the canonical ISO-8601 string in a text column (the TS-owned SQLite / MySQL + // schemas), through the entity namespace's DurationWire; EF's own TimeSpan text ("01:30:00") + // would not be the cross-port form, and MySQL's `time` cannot hold more than 838 hours. + AddDurationConfig(className, entity, fieldList, ctx, modelLines); + foreach (var f in fieldList.Where(f => !f.ResolvedIsArray() && f.DbColumnType is not null)) if (DbColumnTypeConfig(className, f) is { } cfg) modelLines.Add(cfg); } + // field.duration — a System.TimeSpan. Npgsql maps it to the native `interval` (and an array + // to `interval[]`), reading an interval with a month or year part as an error. Every other + // provider stores a 64-bit count of microseconds in an integer column (the TS-owned SQLite / + // MySQL schemas), through the entity namespace's DurationWire; EF's own TimeSpan text + // ("01:30:00") does not order as the span, and MySQL's `time` cannot hold more than 838 hours. + private static void AddDurationConfig( + string className, MetaObject entity, IEnumerable fields, GenContext ctx, List modelLines) + { + foreach (var f in fields.Where(f => f.SubType == FIELD_SUBTYPE_DURATION && f.DbColumnType is null)) + modelLines.Add(string.Join("\n", + DurationColumnLines($"modelBuilder.Entity<{className}>()", "x", CSharpNaming.Pascal(f.Name), f.ResolvedIsArray(), entity, ctx, " "))); + } + + // The provider-dependent mapping of one duration column (a top-level property, or a member of a + // flattened value object through its owned builder `target`): + // - Npgsql: the native `interval` (`interval[]` for an array), read through the generated + // DurationInterval type mapping, so a month or year part, or a day count past what a TimeSpan + // holds, is refused instead of read wrong (Npgsql's own TimeSpan read drops a negative month + // part and wraps such a day count). + // - any other provider (SQLite, MySQL): a 64-bit count of microseconds in an integer column + // (sub-microsecond ticks truncated toward zero; a count past a TimeSpan refused, not + // wrapped), which orders and compares as the span does. An array holds the counts. + private static IEnumerable DurationColumnLines( + string target, string v, string prop, bool isArray, MetaObject owner, GenContext ctx, string indent) + { + var micros = DurationMicrosConverter(owner, ctx); + yield return $"{indent}if (Database.ProviderName == \"{NpgsqlProviderName}\")"; + yield return isArray + ? $"{indent} {target}.PrimitiveCollection({v} => {v}.{prop}).Metadata.SetTypeMapping({DurationIntervalHelperName}.ArrayMapping);" + : $"{indent} {target}.Property({v} => {v}.{prop}).HasColumnType(\"interval\").Metadata.SetTypeMapping({DurationIntervalHelperName}.Mapping);"; + yield return $"{indent}else"; + yield return isArray + ? $"{indent} {target}.PrimitiveCollection({v} => {v}.{prop}).ElementType().HasConversion({micros});" + : $"{indent} {target}.Property({v} => {v}.{prop}).HasConversion({micros});"; + } + + // The namespace OWNER's DurationWire is emitted into (owner has a duration field, so it has one). + private static string DurationWireRef(MetaObject owner, GenContext ctx) => + "global::" + PackageBindingResolver.Resolve(ctx.Config, PackageBindingResolver.EffectivePackage(owner), owner.Name, fallbackContext: owner.Name) + + ".DurationWire"; + + // A TimeSpan <-> canonical ISO-8601 string converter: a duration inside a JSON document. + private static string DurationConverter(MetaObject owner, GenContext ctx) + { + var dw = DurationWireRef(owner, ctx); + return "new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter(" + + $"v => {dw}.Format(v), v => {dw}.Parse(v))"; + } + + // A TimeSpan <-> microsecond-count converter: a duration column on a database without an interval type. + private static string DurationMicrosConverter(MetaObject owner, GenContext ctx) + { + var dw = DurationWireRef(owner, ctx); + return "new Microsoft.EntityFrameworkCore.Storage.ValueConversion.ValueConverter(" + + $"v => {dw}.ToMicros(v), v => {dw}.FromMicros(v))"; + } + + private const string DurationIntervalHelperName = "DurationInterval"; + + // field.duration on Npgsql — the interval type mapping the duration columns are read through. + // Npgsql's own TimeSpan read guards the month part with `months > 0` (so '-1 mons' reads as zero) + // and adds the day part unchecked (so a day count past a TimeSpan wraps); this mapping reads the + // value as NpgsqlInterval instead and refuses both. Npgsql is reached by reflection only, so the + // generated context compiles, and runs, in a project with no Npgsql reference; the class is first + // touched in the Npgsql branch of OnModelCreating. Emitted after the context class: the two + // mapping types must be public and top-level for a compiled model (`dotnet ef dbcontext + // optimize`), which re-creates a property's mapping as `.Default.Clone(...)` and + // refuses a nested or non-public one. DurationIntervalEfCoreTests (integration) gates LINQ, + // interval arithmetic and a compiled model against a live Postgres. + private static void EmitDurationIntervalHelper(StringBuilder sb) => sb.AppendLine(DurationIntervalHelperSource); + + private const string DurationIntervalHelperSource = """ + +/// +/// field.duration on Npgsql: an interval column read through NpgsqlInterval, so a month +/// or year part, or a day count past what a TimeSpan holds, is refused rather than read wrong. +/// Npgsql is reached by reflection: a project on another provider needs no Npgsql reference. +/// +internal static class DurationInterval +{ + // The Npgsql shape this mapping reads, resolved and checked when the model is built (the + // first touch is SetTypeMapping in OnModelCreating, or the first query on a compiled model), + // so a missing or changed type fails there, naming what is missing, rather than on a row. + internal static readonly System.Type IntervalType = Resolve(); + private static readonly System.Reflection.PropertyInfo Months = IntervalType.GetProperty("Months")!; + private static readonly System.Reflection.PropertyInfo Days = IntervalType.GetProperty("Days")!; + private static readonly System.Reflection.PropertyInfo Time = IntervalType.GetProperty("Time")!; + internal static readonly System.Reflection.MethodInfo GetFieldValue = + typeof(System.Data.Common.DbDataReader).GetMethod(nameof(System.Data.Common.DbDataReader.GetFieldValue))!; + + internal static readonly Microsoft.EntityFrameworkCore.Storage.RelationalTypeMapping Mapping = DurationIntervalMapping.Default; + internal static readonly Microsoft.EntityFrameworkCore.Storage.RelationalTypeMapping ArrayMapping = DurationIntervalArrayMapping.Default; + + private static System.Type Resolve() + { + var type = System.Type.GetType(IntervalShape.TypeName, throwOnError: false); + if (IntervalShape.Problem(type) is { } problem) throw new System.InvalidOperationException(problem); + return type!; + } + + /// The check of the NpgsqlInterval shape, kept apart from the state above so it can run against any type. + internal static class IntervalShape + { + internal const string TypeName = "NpgsqlTypes.NpgsqlInterval, Npgsql"; + + /// Null when has the shape the mapping reads, else a message naming what is missing. + internal static string? Problem(System.Type? type) + { + const string Lead = "field.duration on Npgsql: the generated DurationInterval mapping reads an interval column as " + + "NpgsqlTypes.NpgsqlInterval (an Int32 Months, an Int32 Days and an Int64 Time in microseconds; " + + "tested with Npgsql 8.0.5), but "; + if (type is null) return Lead + "no type NpgsqlTypes.NpgsqlInterval was found in the loaded Npgsql assembly."; + var missing = new System.Collections.Generic.List(); + foreach (var (name, want) in new[] { ("Months", typeof(int)), ("Days", typeof(int)), ("Time", typeof(long)) }) + { + var property = type.GetProperty(name); + if (property is null || property.PropertyType != want) + missing.Add(want.Name + " " + name); + } + return missing.Count == 0 + ? null + : Lead + type.FullName + " has no " + string.Join(", no ", missing) + " property. Check the Npgsql version the project references."; + } + } + + /// An NpgsqlInterval as a TimeSpan: refused when it has a month or year part, or a span a TimeSpan cannot hold. + public static System.TimeSpan FromInterval(object interval) + { + if ((int)Months.GetValue(interval)! != 0) + throw new System.InvalidCastException("field.duration: the interval has a month or year part, so it is not a fixed-length span"); + var days = (int)Days.GetValue(interval)!; + var micros = (long)Time.GetValue(interval)!; + return System.TimeSpan.FromTicks(checked(days * System.TimeSpan.TicksPerDay + micros * 10)); + } + + /// An interval[] (an NpgsqlInterval array) as the list a duration array property holds. + public static System.Collections.Generic.IList FromIntervals(object intervals) + { + var list = new System.Collections.Generic.List(); + foreach (var interval in (System.Array)intervals) list.Add(FromInterval(interval!)); + return list; + } + + // A span as Postgres interval input: [-]HH:MM:SS[.f], hours unbounded, no day part. The column + // holds microseconds, so sub-microsecond ticks are truncated toward zero. + internal static string Literal(System.TimeSpan value) + { + long micros = value.Ticks / 10; + bool negative = micros < 0; + ulong rest = negative ? (ulong)(-(micros + 1)) + 1UL : (ulong)micros; + ulong frac = rest % 1_000_000UL, s = rest / 1_000_000UL % 60, m = rest / 60_000_000UL % 60, h = rest / 3_600_000_000UL; + var text = h.ToString("00", System.Globalization.CultureInfo.InvariantCulture) + ":" + m.ToString("00", System.Globalization.CultureInfo.InvariantCulture) + + ":" + s.ToString("00", System.Globalization.CultureInfo.InvariantCulture); + if (frac > 0) text += "." + frac.ToString("D6", System.Globalization.CultureInfo.InvariantCulture).TrimEnd('0'); + return "INTERVAL '" + (negative ? "-" : "") + text + "'"; + } +} + +/// The type mapping of a field.duration column on Npgsql (an interval, read through ). +public sealed class DurationIntervalMapping : Microsoft.EntityFrameworkCore.Storage.RelationalTypeMapping +{ + public static DurationIntervalMapping Default { get; } = new(); + + public DurationIntervalMapping() : base("interval", typeof(System.TimeSpan)) { } + private DurationIntervalMapping(RelationalTypeMappingParameters parameters) : base(parameters) { } + protected override Microsoft.EntityFrameworkCore.Storage.RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) => new DurationIntervalMapping(parameters); + public override System.Reflection.MethodInfo GetDataReaderMethod() => DurationInterval.GetFieldValue.MakeGenericMethod(DurationInterval.IntervalType); + public override System.Linq.Expressions.Expression CustomizeDataReaderExpression(System.Linq.Expressions.Expression expression) => + System.Linq.Expressions.Expression.Call(typeof(DurationInterval).GetMethod(nameof(DurationInterval.FromInterval))!, + System.Linq.Expressions.Expression.Convert(expression, typeof(object))); + protected override string GenerateNonNullSqlLiteral(object value) => DurationInterval.Literal((System.TimeSpan)value); +} + +/// The type mapping of an @isArray field.duration column on Npgsql (an interval[]). +public sealed class DurationIntervalArrayMapping : Microsoft.EntityFrameworkCore.Storage.RelationalTypeMapping +{ + private static readonly Microsoft.EntityFrameworkCore.ChangeTracking.ValueComparer> ListComparer = new( + (a, b) => a == null ? b == null : b != null && System.Linq.Enumerable.SequenceEqual(a, b), + v => System.Linq.Enumerable.Aggregate(v, 0, (h, e) => System.HashCode.Combine(h, e)), + v => System.Linq.Enumerable.ToList(v)); + + // After ListComparer: static initializers run in declaration order. The element mapping is the + // scalar one, which is what lets EF query the column as a primitive collection (Contains, Count, + // Any): a compiled model carries only what the mapping itself names. + public static DurationIntervalArrayMapping Default { get; } = new(); + + public DurationIntervalArrayMapping() : base(new RelationalTypeMappingParameters( + new CoreTypeMappingParameters(typeof(System.Collections.Generic.IList), comparer: ListComparer, keyComparer: ListComparer, + elementMapping: DurationIntervalMapping.Default), "interval[]")) { } + private DurationIntervalArrayMapping(RelationalTypeMappingParameters parameters) : base(parameters) { } + protected override Microsoft.EntityFrameworkCore.Storage.RelationalTypeMapping Clone(RelationalTypeMappingParameters parameters) => new DurationIntervalArrayMapping(parameters); + public override System.Reflection.MethodInfo GetDataReaderMethod() => DurationInterval.GetFieldValue.MakeGenericMethod(DurationInterval.IntervalType.MakeArrayType()); + public override System.Linq.Expressions.Expression CustomizeDataReaderExpression(System.Linq.Expressions.Expression expression) => + System.Linq.Expressions.Expression.Call(typeof(DurationInterval).GetMethod(nameof(DurationInterval.FromIntervals))!, + System.Linq.Expressions.Expression.Convert(expression, typeof(object))); + protected override string GenerateNonNullSqlLiteral(object value) => + "ARRAY[" + string.Join(",", System.Linq.Enumerable.Select((System.Collections.Generic.IEnumerable)value, DurationInterval.Literal)) + "]::interval[]"; +} +"""; + + // The EF Core provider name Npgsql registers (DbContext.Database.ProviderName). + private const string NpgsqlProviderName = "Npgsql.EntityFrameworkCore.PostgreSQL"; + // True when a value object declares a field.map, or holds a value object that does. private static bool HoldsMap(MetaObject vo, MetaRoot root, HashSet seen) { @@ -1099,7 +1318,7 @@ private static bool HoldsMap(MetaObject vo, MetaRoot root, HashSet s } // Owned-type config for an object-typed entity field. @storage flattened maps each - // nested scalar to "{parentCol}_{nestedCol}"; every other storage collapses to one + // nested scalar to its core-naming flattened column (PhysicalNaming.ResolveFlattenedColumnName); every other storage collapses to one // json column (.ToJson) — matching the TS-owned schema DDL. null (with a warning) // when @objectRef can't be resolved. is the emitted CLR class // name (the write entity, or a write-through entity's View read model — #214). @@ -1118,7 +1337,6 @@ private static bool HoldsMap(MetaObject vo, MetaRoot root, HashSet s } var strategy = ctx.Config.ColumnNamingStrategy; var nav = CSharpNaming.Pascal(field.Name); - var parentCol = CSharpNaming.Column(field, strategy); // A6 -- the field itself belongs to `entity` (the JSON column's own physical // name, not the nested value object's), so it hits entity's own names artifact // unconditionally in practice — a lookup, not an assumption, per ColumnRef. @@ -1164,14 +1382,14 @@ private static bool HoldsMap(MetaObject vo, MetaRoot root, HashSet s ? $" modelBuilder.Entity<{owner}>().OwnsOne(x => x.{nav}, b => b.ToJson({parentColRef}));" : OwnedJsonBlock("OwnsOne", owner, nav, parentColRef, jsonEnums); - // Flattened-column prefix for each nested member: `_`. This is - // migrate-ts's rule (expected-schema.ts flattenObjectField), and TS owns the schema - // (ADR-0015) — so it is the convention, NOT EF's, whose own owned-type default is - // `` + // A NESTED value object: migrate gives it ONE json column at `` // (subtypeToSqlType returns { kind: "json" } for field.object — a nested VO does // not flatten recursively). EF models it as a nested owned type, so it needs its // own ToJson pinned to that column, plus the enum conversions its own members @@ -1241,7 +1459,7 @@ private static bool HoldsMap(MetaObject vo, MetaRoot root, HashSet s } // A `field.map` member: the migration flattens it to a jsonb column at - // `_`, so it takes the SAME converter/comparer pair a top-level map + // ``, so it takes the SAME converter/comparer pair a top-level map // takes, pinned to that name. Previously this fell to a warning whose stated // reason was that the port configured a top-level map nowhere either — that is // no longer true, and an unmapped dictionary here binds EF's `
_pkey` records their names (not compared; + nothing churns). See `docs/features/migrations-and-drift.md`, "Schema conventions". +- **`migrate.checks.skipRedundant`: leave out the CHECKs a column type already enforces.** Set in + `.metaobjects/config.json` (default `false`), `meta migrate` and `meta verify --db` leave out a + `validator.length` maximum the column's `VARCHAR(n)` enforces (not on SQLite or D1, which do not + enforce one) and a length minimum of 0 or less, for a database whose CHECKs were written by hand + only where the column type does not already enforce the rule. A validator with a declared + `@constraintName` keeps its CHECK. A malformed `checks` block is refused when the config loads. + See `docs/features/migrations-and-drift.md`, "CHECKs from validators". - **A recipe for checking an API change against the last release.** Emit the document at the last tag and at `HEAD`, then run oasdiff. See `docs/recipes/check-api-against-last-release.md`. - **A `pydantic` extra on the Python package.** `pip install "metaobjects[pydantic]"` installs @@ -164,7 +207,6 @@ here.** The proof is a test file that `tsc` or pytest runs, so nothing reads your source and there is no new command. `examples/brownfield/` holds a runnable project for each, and the recipe ships in the `metaobjects-authoring` skill. - - **A project's own provider class, wired in every port's config, loaded by every command.** A project that declares its own vocabulary (a new subtype, or attributes on a core type) writes a provider class in each language whose tools load the metadata, and names it once: @@ -189,6 +231,37 @@ here.** `ERR_UNKNOWN_SUBTYPE` and then each `ERR_UNKNOWN_ATTR`. Java reported the attribute errors before the subtype error; it now reports them after it, as TypeScript and C# do. A load that failed still fails; only the list of errors differs. +- **`meta migrate` refuses a foreign key that references columns the target does not hold unique.** + Behaviour change: an `identity.reference` whose dotted `@references` names target fields that are + not the target's primary key, a plain `identity.secondary` (no `@where`, no `@expr`) or a single + `@unique` field used to emit an FK the database refuses at apply time ("there is no unique + constraint matching given keys"), after earlier statements had already run. `meta migrate` and + `meta verify --db` now fail first with `FkTargetNotUniqueError`, which lists every offending + reference at once (entity, reference, target and fields); both report it as a metadata refusal + with a hint. The check is against the table the model actually builds: a key declared on a TPH + subtype is not emitted, so it does not count (the message says to declare it on the base), and + a bare reference into a composite primary key with fewer FK fields is caught too. An FK on a + table this run does not govern (imported, or outside `migrate.scope`) is warned about, not + refused, and an FK into a table the model does not build (`@unmanaged`) is not checked. Postgres + refuses the DDL and D1 always enforces foreign keys, so both refuse. SQLite only warns: it accepts + the `CREATE TABLE`, and checks foreign keys only with `PRAGMA foreign_keys` on, which is off by + default, so the FK fails (`foreign key mismatch`) on a write with enforcement on. `meta verify + --db` gives the same verdict. Fix: declare an `identity.secondary` on those fields of the target. + This is a `meta migrate` correction; it moves no metamodel version. +- **A constraint or index whose only change is its name is renamed in place on Postgres.** A + drop plus an add of the same definition under another name, on a table that is not itself + renamed (switching schema conventions renames every FK), used to be a gated `drop-fk` / + `drop-index` that dropped and rebuilt the object. It is now one `rename-constraints` change: + `ALTER TABLE … RENAME CONSTRAINT` / `ALTER INDEX … RENAME`, and the down renames it back. SQLite + cannot rename a constraint and keeps the rebuild. The primary key's name is now compared too, + so a convention switch, or a table rename (which keeps the old `
_pkey`), renames it, and + `meta verify --db` reports a mismatch it used to pass. A unique key that changes only between + plain index and UNIQUE constraint is promoted in place (`ADD CONSTRAINT … UNIQUE USING INDEX`); + the reverse, when the conventions ask for a plain index, drops the constraint, re-creates the + index, and drops and re-adds each FK onto it, behind `--allow drop-index` and `--allow drop-fk`. + The default conventions accept either kind, so a database built with Drizzle's `unique()` + constraints is left as it is. A key that changes both its name and its kind + is renamed, then promoted or demoted, in the same migration. - **A prompt or output template that names a variable not on its payload now raises at render time.** Behaviour change: a generated render helper used to render `Hi {{nmae}}` as `Hi ` with no error when the payload declares `name`; it now raises `ERR_VAR_NOT_ON_PAYLOAD (nmae)` (`RenderError` in @@ -206,6 +279,106 @@ here.** ### Fixed +- **TypeScript: every `meta` command now loads the providers in `metaobjects.config.ts`.** + `meta export`, `meta deps`, `meta fmt`, `meta types` and `meta upgrade` loaded the core + vocabulary only, so a project's own subtype or attribute failed there while `meta gen` accepted + it. A config that exists but fails to load is now reported as its own error by `meta verify` + (exit 2, in every mode) and `meta prompt-snapshot`, instead of continuing without its providers + and reporting an unknown subtype. +- **Python: `metaobjects fmt` reads the configured providers, and `--provider` adds to them.** + `fmt` ignored `providers:` in `metaobjects.config.yaml` (it also gains `--config`), and + `metaobjects verify --provider` replaced the configured list instead of adding to it. +- **`ERR_COLUMN_NAME_COLLISION` no longer rejects a read-only view whose columns come from `@sql`.** + The 1.2.0 check treated every field of a sourced object as a column MetaObjects creates. For a + `source.rdb @kind: view` with an `@sql` body, the author's SQL defines the columns and the fields + only read them, so two fields on one column are legal and nothing can be written. That object is + now exempt in all four ports (TypeScript, Java, C#, Python), provided every source of the object + is read-only. A derived view (no `@sql`), which emits a select list from its fields, and an object + with a writable table source plus an `@sql` view replica are still checked. When both colliding fields already carry an + explicit `@column`, the message now says to rename one field or point one `@column` at a different + 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. +- **Two `field.duration` fields order by length in every port, and a comparison inside `@condition` + follows the same kind rule.** The kind rule above listed no durations, so a `validator.comparison` + between two durations emitted no rule in any port. Durations now order with their own subtype, by + length rather than by their ISO-8601 text (`PT45M` is shorter than `PT1H`). A comparison inside an + `@condition` that reads two fields of different kinds (a date against a timestamp, or an enum + against a string) now leaves the whole validator to the database, as a field it cannot see already + did, where TypeScript, Java and Kotlin had compared the two as strings and C# had compared an enum + with a string. An `eq` / `ne` inside `@condition` on two uuid, uri or inet fields now tests + equality in every port: Python ordered the two values, so an IPv4 against an IPv6 address read as + unknown and the rule was skipped, and C# skipped those comparisons altogether. C# also gave + `field.uri` and `field.inet` no filter operators, so `@filterable` on either, or any `@condition` + that reads one, failed to load; they now take the same bands as in the other ports (uri the string + band, inet the uuid band), and the shared `filter-ops-matrix` fixture lists both. New shared cases + `duration-comparison-rejects-a-longer-span`, `duration-comparison-orders-by-length-not-text`, + `condition-comparing-across-kinds-leaves-rule-to-database`, + `condition-ne-on-ipv4-and-ipv6-addresses-enforces-the-rule` and + `condition-ne-on-equal-addresses-lets-the-record-through`. +- **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 + column. Before, the migration was written and failed at apply on both Postgres (`cannot drop + column … other objects depend on it`) and SQLite (`error in view … after drop column`). A view with + no recorded body is not matched, and an offline run cannot see live views. Pass + `--allow drop-column-read-by-view` (config token `drop-column-read-by-view`) to proceed. +- **The SQLite and D1 down migration for `drop-column` re-adds the column.** It wrote a TODO comment + and still recreated the view with the old SQL, so the view failed with `no such column` after a + rollback. It now runs `ADD COLUMN` from the recorded column, as Postgres does; the data is not + restored, and a `NOT NULL` column with no default comes back nullable, which is all SQLite allows. +- **Docs: edit an `@sql` body when you remove a column it reads.** The tool never parses the body; + Postgres fails at apply and SQLite applies and leaves a broken view. + +- **Postgres introspection reads a composite foreign key's columns once each, in order.** It joined + `information_schema.key_column_usage` to `constraint_column_usage`, which has no column position, so + every FK column was paired with every referenced column: a two-column FK read back as four columns and + a three-column one as nine. `meta migrate` reads its expected side back from the shadow database, so + the doubled columns reached the emitted DDL, which Postgres refused ("foreign key referenced-columns + list must not contain duplicates"); `verify --db` misreported every composite FK. It now reads + `pg_constraint`, pairing `conkey` with `confkey` by position. As before, only same-schema foreign + keys are read (the FK descriptor carries no referenced schema). + - **Python: an array field's `default` generated a string.** A model with `field.string[]: { default: "{}" }` generated `tags: list[str] = '{}'`, which fails on first use. It now generates `tags: list[str] = []`, from the same four spellings TypeScript accepts, or diff --git a/agent-context/skills/metaobjects-audit/SKILL.md b/agent-context/skills/metaobjects-audit/SKILL.md index 21a2973750..525029b69c 100644 --- a/agent-context/skills/metaobjects-audit/SKILL.md +++ b/agent-context/skills/metaobjects-audit/SKILL.md @@ -186,8 +186,8 @@ When folding hand validators into metadata, apply human judgment per constraint. A constraint enters **shared metadata** only if it is a **true cross-language domain invariant**; a one-consumer preference stays in a thin local refinement layer. -Cross-field rules **are** modelable (`comparison` / `atLeastOne` / `requiredWhen` / -`presentIff`); ratification decides *which* belong in shared metadata. Output a +Cross-field rules **are** modelable (`comparison` / `atLeastOne` / `exactlyOne` / +`requiredWhen` / `presentIff`); ratification decides *which* belong in shared metadata. Output a **ratification table**: KEEP-IN-METADATA / LOCAL-REFINEMENT / DROP + rationale — human-approved, never applied silently. Distinguish `required` from has-a-safe-default (`@default` often fixes the over-requiring bug). A core attr ripples cross-port; for a diff --git a/agent-context/skills/metaobjects-audit/references/capability-checklist.md b/agent-context/skills/metaobjects-audit/references/capability-checklist.md index e145d1ccf6..71672d2a81 100644 --- a/agent-context/skills/metaobjects-audit/references/capability-checklist.md +++ b/agent-context/skills/metaobjects-audit/references/capability-checklist.md @@ -182,7 +182,8 @@ soft-delete / status / type view it models. `validator.regex`** — hunt hand field-level validation (presence, length, numeric range, array bounds, `@pattern` regex) the validator subtypes model. - **Cross-field validators** — `validator.comparison` (`@left`/`@op`/`@right`, e.g. - "end ≥ start"), `validator.atLeastOne` (one-of-N present), `validator.requiredWhen` + "end ≥ start"), `validator.atLeastOne` (one-of-N present), `validator.exactlyOne` (exactly one of N + present), `validator.requiredWhen` (conditional-required), `validator.presentIff` (mutual presence). Hunt these as hand-coded multi-field rules — they ARE modelable (see the Semantic-constraint ratification section in `SKILL.md` to decide what belongs in shared metadata vs port-local). diff --git a/agent-context/skills/metaobjects-audit/references/drift-signatures.md b/agent-context/skills/metaobjects-audit/references/drift-signatures.md index 351893deb6..5148b26db4 100644 --- a/agent-context/skills/metaobjects-audit/references/drift-signatures.md +++ b/agent-context/skills/metaobjects-audit/references/drift-signatures.md @@ -35,7 +35,7 @@ Per finding: `file:line` → what → generated-equivalent exists? → recommend **Hunt from the declaration outward, never from code shapes inward.** (1) Build the value list from the generated artifacts, because they already carry every derived value after every transform — naming strategy, pluralized path, humanized label: read `Names` and, on TypeScript, the `.meta.ts` twin. Where the port emits no names artifact yet, derive the list from the metadata directly (`@table` / `@schema` / `@column`, else the naming strategy applied to the field name; `@values`; `@title`) and record the un-wired generator as the first finding (below). (2) Grep the non-generated tree once — code AND checked-in config, fixtures, YAML, JSON, `.env`, OpenAPI fragments — with an alternation of those VALUES as literals; distinctive values (`"email_address"`, `"/subscribers"`, `"edited_approved"`) score on the hit, generic ones (`"id"`, `"name"`, `255`) only by reading the consumer, never by count. This step is what finds the topic name, the index field, the `COPY` header and the flag key without knowing they exist. (3) Trace each hit to its CONSUMER and ask what that consumer matches it against. Matched against the schema, the generated route table, a generated wire shape or a generated label → the metadata owns it, apply the discriminator. Matched against something the app chose — its own page-route table, its own copy deck — → not owned, not this finding. (4) Only then run the shape greps — raw SQL, `fetch(` / `app.get(` / a `queryKey`, a hand validator — as the second pass, for what a value grep cannot see: a literal that has ALREADY drifted from the declared value, a concatenated or interpolated name, a case-transformed one. - **Do not flag — one rule, and its consequences.** A literal is exempt when (a) it is resolved, not compared: a typed ORM handle (a Drizzle column object, an Exposed `Column`, an EF property, a Pydantic attribute), a typed property access (`row.email`, `form.input.email`), a typed filter object, an enum member narrowed by the generated union — rewriting any of these to `Subscriber.email.name` or a string lookup swaps a checked reference for an unchecked one and is a regression (the emitted `options: [...] as const` populates a dropdown; it does not replace checked literals); (b) the metadata does not OWN it — the value coincides with a derived one but the app may change it without the metadata being wrong: a client-side page route or link (``, ``) is a navigation URL the app chose, measured on an estate where all 11 apparent `$path` hits were exactly this and the true count was 0, and converting one couples the site's URL structure to the API's; likewise the deployment prefix of a fetch URL, which the constants surface deliberately does not carry — `/api` is supplied at runtime through the client provider's `baseUrl`, never frozen at `meta gen` — so `fetch("/api/subscribers")` decomposes into a prefix with no constant by design and a `/subscribers` that is `$path`; (c) no generated constant exists for it — next paragraph; (d) it sits where a constant cannot be imported or names something no single declaration owns: a `source.rdb` `@sql` body is metadata, fingerprinted precisely because it can reference nothing; a flattened value-object composite column belongs to no single field of either object; a relationship-synthesized FK column is derived, never declared; a write-through entity's replica view name is not in the artifact, which holds the PRIMARY source's. A hand-written repository that imports `Names` is the gold standard — call it out as good. + **Do not flag — one rule, and its consequences.** A literal is exempt when (a) it is resolved, not compared: a typed ORM handle (a Drizzle column object, an Exposed `Column`, an EF property, a Pydantic attribute), a typed property access (`row.email`, `form.input.email`), a typed filter object, an enum member narrowed by the generated union — rewriting any of these to `Subscriber.email.name` or a string lookup swaps a checked reference for an unchecked one and is a regression (the emitted `options: [...] as const` populates a dropdown; it does not replace checked literals); (b) the metadata does not OWN it — the value coincides with a derived one but the app may change it without the metadata being wrong: a client-side page route or link (``, ``) is a navigation URL the app chose, measured on an estate where all 11 apparent `$path` hits were exactly this and the true count was 0, and converting one couples the site's URL structure to the API's; likewise the deployment prefix of a fetch URL, which the constants surface deliberately does not carry — `/api` is supplied at runtime through the client provider's `baseUrl`, never frozen at `meta gen` — so `fetch("/api/subscribers")` decomposes into a prefix with no constant by design and a `/subscribers` that is `$path`; (c) no generated constant exists for it — next paragraph; (d) it sits where a constant cannot be imported or names something no single declaration owns: a `source.rdb` `@sql` body is metadata, fingerprinted precisely because it can reference nothing; a relationship-synthesized FK column is derived, never declared. (A flattened value object's member column and a write-through entity's replica view name are NOT exempt: the artifact carries both, as the field's member column constant and the source in role `replica`.) A hand-written repository that imports `Names` is the gold standard — call it out as good. **Score only where a constant actually exists.** A fact declared in the project's CODEGEN CONFIG rather than in metadata — a bespoke route mount path, a hand-chosen JSON envelope key — is genuinely spelled twice when a client re-types it, but no generated constant holds it. That is a GENERATOR GAP, reported as one, never a literal-site finding: never invent a constant that the emitters do not emit. diff --git a/agent-context/skills/metaobjects-audit/references/vocabulary-hygiene.md b/agent-context/skills/metaobjects-audit/references/vocabulary-hygiene.md index 0457d764eb..bad20b4e25 100644 --- a/agent-context/skills/metaobjects-audit/references/vocabulary-hygiene.md +++ b/agent-context/skills/metaobjects-audit/references/vocabulary-hygiene.md @@ -39,6 +39,10 @@ such, surfaced in the roadmap, but **non-failing** (the code works; the form is URL validation) — a distinct native type + behavior is a subtype, not a validated string. - A `field.string` that **holds an IP address** → recommend **`field.inet`** (native IP type; Postgres `inet` column). +- A `field.string` (or a CLR/JVM type override) that **holds a time span** (`TimeSpan`, an + `interval` column, "seconds until…") → recommend **`field.duration`** (native duration type; + Postgres `interval` column; ISO-8601 on the wire). Not a calendar span: months and years + are not durations. **Custom-provider vocabulary (adopters who register their own types/attrs):** check new/custom vocab against the ADR-0037 procedure (advisory) — e.g. *a custom subtype diff --git a/agent-context/skills/metaobjects-authoring/SKILL.md b/agent-context/skills/metaobjects-authoring/SKILL.md index 01fb198e0d..555033850b 100644 --- a/agent-context/skills/metaobjects-authoring/SKILL.md +++ b/agent-context/skills/metaobjects-authoring/SKILL.md @@ -245,6 +245,7 @@ Reach for these before inventing anything. When no row fits, run the decision pr | Open JSON, no reader narrows it | `field.string` + `@dbColumnType: jsonb` | the LAST rung — see "A JSON column" | | URL / URI | `field.uri` | native `URI`/`Uri`; `text` column; strict absolute-URI validation (add `@lenient: true` to store any string) — a real native type + behavior, so a subtype (not a validated string) | | IP address | `field.inet` | native IP type; Postgres `inet` column; strict IPv4/IPv6-literal validation (add `@lenient: true` for a plain-string `text` column) | +| Elapsed time / timeout / delay | `field.duration` | native `TimeSpan`/`Duration`/`timedelta`; Postgres `interval` column; ISO-8601 on the wire (`PT1H30M`). Not for a calendar span ("every 3 months"): that is an int and a unit. Never a string or a count of seconds | | Validated plain string (email / hostname) | `field.string` + `@stringFormat` | `@stringFormat: email` or `@stringFormat: hostname` — idiomatic per-port validation; don't hand-write the `validator.regex` | Four rules behind those rows, in short (full text: `references/choosing-a-shape.md`): @@ -285,10 +286,10 @@ Reuse a constraint set across entities with an abstract `field.enum` + `extends` `field.object` embeds another `object` declaration. `@objectRef` names it; `@storage` controls persistence: -- `flattened` — one DB column per sub-field (`address_street`, `address_city`, …). - Illegal on array fields. -- `jsonb` — one `jsonb` column. -- `subdocument` (default, back-compat) — single jsonb column. +- `flattened` — one DB column per sub-field (`address_street`, …); illegal on arrays. `@flattenedColumnPrefix` + replaces the `_` prefix verbatim (`""` keeps the members' own columns). +- `jsonb` — one `jsonb` column; also the default when `@storage` is not set. +- `subdocument` — single jsonb column (a hint for document-store targets). ```json { "field.object": { "name": "address", "@objectRef": "Address", "@storage": "flattened" } } @@ -434,16 +435,21 @@ 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`). 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.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings, temporals and durations 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` | +| `validator.exactlyOne` | exactly one of `@fields` (2+) is present | `@fields` | + +All five take an optional `@condition` (the `origin.computed` grammar): the rule holds only where it does. +Every validator that becomes a CHECK takes an optional `@constraintName`. ```json { "validator.comparison": { "name": "hpInRange", "@left": "currentHp", "@op": "lte", "@right": "maxHp" } } { "validator.requiredWhen": { "name": "reasonIfRejected", "@field": "rejectReason", "@when": "status", "@equals": "rejected" } } { "validator.presentIff": { "name": "usedAtWhenUsed", "@field": "usedAt", "@when": "isUsed", "@equals": "true" } } { "validator.atLeastOne": { "name": "emailOrPhone", "@fields": ["email", "phone"] } } +{ "validator.exactlyOne": { "name": "oneOwner", "@fields": ["customerId", "accountId"] } } ``` These are children of `object.entity`, alongside its fields and identities. diff --git a/agent-context/skills/metaobjects-codegen/references/physical-names.md b/agent-context/skills/metaobjects-codegen/references/physical-names.md index 83f8364241..27bf59a63b 100644 --- a/agent-context/skills/metaobjects-codegen/references/physical-names.md +++ b/agent-context/skills/metaobjects-codegen/references/physical-names.md @@ -89,14 +89,26 @@ literally. **Java and Python generate no SQL at all**: their DTOs and models car names, and persistence is the repository interface/`Protocol` you implement. There the artifact exists *for your code*, which is the only place a physical name appears. -Two categories stay literal, deliberately, and the gate pins them as such rather than -exempting them: - -- a **flattened value-object column** (`@storage: flattened`) is a composite — - `_` — belonging to no single field of either object, - so there is no one constant to reference; -- a **write-through entity's replica view name**: the artifact holds the object's PRIMARY - source's name (its table), and a write-through entity has two physical names. +**A flattened value object's columns are constants of the OWNING object's artifact.** A +`@storage: flattened` field has no column of its own: each member of the value object is a +column of the owner's table, named by core naming (the field's `@flattenedColumnPrefix`, +else `_`, then the member's column). The artifact carries one constant per +member, and the field itself has none, because the table has no column of that name: + +| Port | Reads as | +|---|---| +| TypeScript | `ShipmentNames.fields.shipTo.members.street.column` | +| C# | `ShipmentNames.ShipToStreetColumn` | +| Java / Kotlin | `ShipmentNames.SHIP_TO_STREET_COLUMN` | +| Python | `SHIPMENT_SHIP_TO_STREET_COLUMN` | + +The column-by-field maps (`ColumnsByField`, `COLUMNS_BY_FIELD`) key these `shipTo.street`. +A member constant that would share its name with another constant (a field `shipToStreet` +beside a flattened `shipTo.street` with a custom prefix) is left out with a warning; rename +one to get it. The generated EF Core and Exposed bindings still spell these columns as +literals, because an owned copy of an older names generator does not emit the member +constants. Without the artifact, compute the name with core naming's +`resolveFlattenedColumnName` (or its port equivalent), never by concatenating. **But prefer a typed handle where one exists — this rule has a real limit.** If the ORM gives you a type-checked object for the same thing, use that. Replacing a Drizzle column diff --git a/agent-context/skills/metaobjects-verify/references/migration.md b/agent-context/skills/metaobjects-verify/references/migration.md index 6828d995d3..f589708490 100644 --- a/agent-context/skills/metaobjects-verify/references/migration.md +++ b/agent-context/skills/metaobjects-verify/references/migration.md @@ -283,6 +283,6 @@ emitted, created, diffed and dropped** — derived from the model, never written SQL. `field.enum` membership (`IN (…)`, the mapped integers for an `@intValueMap` enum), `validator.numeric` min/max, `validator.length` min/max, `validator.regex` (Postgres only — SQLite has no regex operator), and the entity-level cross-field -validators (`comparison`, `requiredWhen`, `presentIff`, `atLeastOne`) each become a +validators (`comparison`, `requiredWhen`, `presentIff`, `atLeastOne`, `exactlyOne`) each become a named `CHECK`. Array columns get none (the element rules are enforced app-side). (Partial + descending **indexes** are supported too — see Index modeling above.) diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index cf6542d422..8695cca7ce 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -1,6 +1,6 @@ # Conformance coverage -The MetaObjects standard ships **36 shared conformance corpora** under +The MetaObjects standard ships **38 shared conformance corpora** under [`fixtures/`](../fixtures/). Every port runs every corpus that is *applicable to it* and asserts the same expected behaviour against the same fixtures. **This page is the inverse index**: fixture → feature doc + per-port pass status, and it is the @@ -26,7 +26,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | Corpus | Fixtures | TS | Java | Kotlin | C# | Python | |---|---|---|---|---|---|---| -| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 428 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | +| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 448 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | | [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 16 | 16 / 16 | 15 / 16 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 15 / 16 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 16 / 16 | | [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 45 | ✓ | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ | @@ -36,9 +36,10 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/extract-conformance/`](../fixtures/extract-conformance/) | 58 | ✓ | ✓ | inherits the shared JVM engine | ✓ | ✓ | | [`fixtures/output-prompt-conformance/`](../fixtures/output-prompt-conformance/) | 17 | ✓ | ✓ | ✓ | ✓ | ✓ | | [`fixtures/persistence-conformance/`](../fixtures/persistence-conformance/) | 59 (53 query + 6 migration) | all 59; the 24 `report-*` and the `-15` decimal scenarios also on SQLite, MySQL and D1, `decimal-exact` also on MySQL (a scenario's `engines:` key is exact) | 53 query (migrations TS-only, ADR-0015); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (via Exposed); the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query; the `report-*` and `engines:` scenarios also on SQLite and MySQL | 53 query (pg8000); the `report-*` and `engines:` scenarios also on SQLite (`sqlite3`) and MySQL (PyMySQL) | -| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 119 (31 core + 10 tph + 9 m2m + 5 jsonb + 2 write-through + 2 secret-fields + 12 projection + 37 report + 11 decimal) | ✓ (Fastify reference + generated lane); `report/` and `decimal/` also on SQLite, MySQL and D1 | ✓ (embedded HTTP + JDBC); `report/` and `decimal/` generated controllers over OMDB on Postgres, SQLite and MySQL | ✓ (embedded HTTP + Exposed); `report/` and `decimal/` also on SQLite and MySQL | ✓ (Kestrel + Npgsql); `report/` and `decimal/` also on SQLite and MySQL | ✓ (FastAPI + pg8000); `report/` and `decimal/` also on SQLite and MySQL | +| [`fixtures/api-contract-conformance/`](../fixtures/api-contract-conformance/) | 121 (31 core + 10 tph + 9 m2m + 5 jsonb + 2 write-through + 2 secret-fields + 12 projection + 37 report + 11 decimal + 2 duration) | ✓ (Fastify reference + generated lane); `report/`, `decimal/` and `duration/` also on SQLite, MySQL and D1 | ✓ (embedded HTTP + JDBC); `report/`, `decimal/` and `duration/` generated controllers over OMDB on Postgres, SQLite and MySQL | ✓ (embedded HTTP + Exposed); `report/`, `decimal/` and `duration/` also on SQLite and MySQL | ✓ (Kestrel + Npgsql); `report/`, `decimal/` and `duration/` also on SQLite and MySQL | ✓ (FastAPI + pg8000); `report/`, `decimal/` and `duration/` also on SQLite and MySQL | | [`fixtures/decimal-wire/`](../fixtures/decimal-wire/) | 1 vector file (format, parse, filter operand, 15-digit read) | ✓ (`runtime-ts` and the generated helper) | ✓ (`CanonicalDecimal`) | ✓ (the generated wire file) | ✓ (`DecimalWire`) | ✓ (`decimal_wire`) | -| [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases + 20 cross-field cases (`cross-field-cases.json`, generated record model only) | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) | +| [`fixtures/duration-wire/`](../fixtures/duration-wire/) | 1 vector file: the `wire` vectors (parse, precision, refused) every port passes, and the `codecs` groups (Postgres text read and write, stored microseconds) each port passes only for the codecs it has | ✓ (`metadata` / `runtime-ts` and the generated helper) | ✓ | ✓ | ✓ | ✓ | +| [`fixtures/validation-conformance/`](../fixtures/validation-conformance/) | 42 cases + 43 cross-field cases (`cross-field-cases.json`, generated record model only) | ✓ (generated Zod + run-time `runValidators`) | ✓ | ✓ | ✓ | ✓ (generated Pydantic + run-time `run_validators`) | | [`fixtures/registry-conformance/`](../fixtures/registry-conformance/) | 1 canonical manifest | ✓ (reference emitter) | ✓ | ✓ | ✓ | ✓ | | [`fixtures/object-model-conformance/`](../fixtures/object-model-conformance/) | 1 shared metadata fixture (per-port scenarios) | ✓ | ✓ | ✓ | ✓ | ✓ | | [`fixtures/codegen-conformance/`](../fixtures/codegen-conformance/) | 6 shared inputs; `shared-provided-enum` carries the enum-type decision table and `cross-package-binding` the reference-binding decision table (ADR-0059) every port answers | ✓ | ✓ | ✓ | ✓ | ✓ | @@ -58,6 +59,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | [`fixtures/requirement-test-identity-conformance/`](../fixtures/requirement-test-identity-conformance/) (the identity, skip state and digest of each generated requirement test, and the filter seam, ADR-0057) | 26 cases | ✓ (reference) | ✓ | identity function inherits via Java; emitted names asserted by its own generator test | ✓ | ✓ | | [`fixtures/naming-conformance/`](../fixtures/naming-conformance/) | 8 cases | ✓ | ✓ | inherits via Java (`RouteNaming.pluralize`) | ✓ | ✓ | | [`fixtures/wire-conformance/`](../fixtures/wire-conformance/) (the `wire` setting, ADR-0019 Amendment 2) | 6 cases + 8 refusals | ✓ | ✓ | inherits via Java (one config reader) | ✓ | ✓ | +| [`fixtures/enum-length-conformance/`](../fixtures/enum-length-conformance/) (a string-stored enum's column length: `@maxLength`, else `enums.maxLength`) | 5 cases + 1 overflow + 5 refusals; run by a port that sizes the column | ✓ | ✓ | inherits via Java (one config reader, one resolver) | ✓ | — (sizes no column: emits no DDL or column type; the config reader accepts and shape-checks the `enums` block) | | [`fixtures/codegen-noop/`](../fixtures/codegen-noop/) (FR-044 — reporting vocabulary: what is lowered, what stays inert) | 1 model pair (`reporting/with` vs `reporting/without`) | ✓ (codegen + migrate) | ✓ | ✓ | ✓ | ✓ | | [`fixtures/cube-model/`](../fixtures/cube-model/) (FR-044 Plan 4 — the `cube-model` generator's golden Cube files, plus the canonical model's golden) | 46 cases (36 trees + 10 errors) + 1 canonical golden | ✓ (the exporter is TypeScript-only, spec R6; its `cube` lane also loads the files into a real Cube) | — | — | — | — | | [`fixtures/contract-conformance/`](../fixtures/contract-conformance/) (FR-022 — the `contract-schema` and `openapi` generators' golden trees and refusals) | 19 cases (14 trees + 5 errors) | ✓ (one neutral engine, ADR-0034 Amendment 10; the generated API lanes also hold real traffic to the documents) | — | — | — | — | @@ -286,7 +288,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker. ## Fixture-to-doc mapping -### `fixtures/conformance/` — metamodel loader + canonical serializer (428) +### `fixtures/conformance/` — metamodel loader + canonical serializer (448) | Fixture prefix | Feature doc | |---|---| @@ -382,7 +384,7 @@ The corpus also covers the 9 cross-port filter operators (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `like`, `isNull`) plus the implicit-AND combinator and 2 error shapes (`invalid_filter_field` / `invalid_filter_op`) under the URL grammar `?filter[][]=` (FR-009). On top of the 31 core -scenarios the corpus carries eight sub-corpora — `tph/` (10, single-table +scenarios the corpus carries nine sub-corpora — `tph/` (10, single-table inheritance; a subtype decimal filters and refuses extra digits), `m2m/` (9 — 3 plain, 5 gating TPH x M:N together, the combination each corpus alone could not reach, and 1 pinning the collection-URL spelling), `jsonb/` (5, typed value-object columns; numbers inside them: a double stays a JSON number, a decimal is the canonical string), @@ -390,13 +392,15 @@ each corpus alone could not reach, and 1 pinning the collection-URL spelling), read-only view answers reads and refuses writes with 405; a projection with no declared identity has no item route; a projection keyed on a field not named `id` is addressed by it, whether its identity names `@fields` or derives them; decimal and float fields filter), `decimal/` (11, ADR-0019 Amendment 1: a decimal, array element and undeclared-precision column included, is one -canonical string, read exactly, filtered exactly and refused past its declared digits) and `report/` (37, +canonical string, read exactly, filtered exactly and refused past its declared digits), `duration/` (2, a +`field.duration` under `"wire": { "durations": "iso" }` is one canonical ISO-8601 string, refused +in a calendar or clock spelling, and filtered and sorted as a span on every engine) and `report/` (37, FR-044: a view-backed `object.report` is listed, filtered, sorted and paged on its derived fields, answers `POST` with 405 and mounts no `/{id}`; a `@spine` report keeps a row with no facts, and a measure with `@default` filters and sorts as its default). All 5 ports — TS, Java, Kotlin, C#, Python — run it in BOTH lanes: a hand-rolled reference server and the port's own GENERATED API artifact booted over HTTP. `write-through/`, -`secret-fields/`, `projection/`, `decimal/` and `report/` run the generated lane only, on all five ports: what +`secret-fields/`, `projection/`, `decimal/`, `duration/` and `report/` run the generated lane only, on all five ports: what they test is whether a port's generator emits the routes. The `report/` sub-corpus is the one that reads real views, so it runs full stack on Postgres, SQLite and MySQL (`report/schema.sqlite.sql`, `report/schema.mysql.sql`, both TypeScript-produced) in every @@ -629,8 +633,8 @@ JSON and verify payload feeds it unchanged. ## Orphaned fixtures (tested but not yet documented) -The fixtures in the nine corpora mapped above (metamodel 428 + yaml 16 + verify 45 -+ render 18 + persistence 59 + api-contract 119 + source-resolution 25 + scope 10 + +The fixtures in the nine corpora mapped above (metamodel 448 + yaml 16 + verify 45 ++ render 18 + persistence 59 + api-contract 121 + source-resolution 25 + scope 10 + dependency 37) each map to a feature doc, and so do the two requirement corpora, `shared-model-conformance`, the `cube-model` corpus and `explorer-conformance`, whose case lists live in their own READMEs. None are orphaned today. The remaining corpora in the totals table gate tooling contracts (registry manifests, provider diff --git a/docs/features/api-contract.md b/docs/features/api-contract.md index 4f3f91f9f3..a4d965895e 100644 --- a/docs/features/api-contract.md +++ b/docs/features/api-contract.md @@ -431,24 +431,24 @@ compares equal: ### The wire setting -What a generated API writes for a decimal and for a value object's null member is a project +What a generated API writes for a decimal, a duration and a value object's null member is a project setting, `wire` in `.metaobjects/config.json`, read by every port's codegen the way `naming` is ([ADR-0019 Amendment 2](../../spec/decisions/ADR-0019-runtime-return-type-contract.md)): ```json -{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native" } } +{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native", "durations": "native" } } ``` -Both keys default to `native`: **each port writes what its language's standard serializer +Every key defaults to `native`: **each port writes what its language's standard serializer writes**, so a C# API looks like a C# API and a Python API like a Python API. Identical output across ports is something you opt into: ```json -{ "schema_version": 1, "wire": { "decimals": "string", "nullMembers": "omit" } } +{ "schema_version": 1, "wire": { "decimals": "string", "nullMembers": "omit", "durations": "iso" } } ``` -That is the form 1.1.0 and 1.1.1 wrote, and the form the cross-port conformance corpora pin in -their own config. Choose it when clients written in several languages share one contract, or when +For decimals and null members that is the form 1.1.0 and 1.1.1 wrote, and it is the form the +cross-port conformance corpora pin in their own config. Choose it when clients written in several languages share one contract, or when a browser client must read a decimal exactly: `JSON.parse` turns a JSON number into a double before your code sees it. @@ -466,6 +466,20 @@ before your code sees it. exactly. - `nullMembers` covers the members of an `object.value` only, and so also the jsonb that a typed serializer writes. A top-level entity field that is null is always written as `null`. +- `durations` covers `field.duration` in a request and response body, both ways: + +| Port | `durations: native` (default) | `durations: iso` | +|---|---|---| +| C# | System.Text.Json's `TimeSpan` form, both ways: `"01:30:00"`, `"1.02:00:00"`, `"-00:01:30"` | the canonical ISO-8601 form: `"PT1H30M"`, `"PT26H"`, `"-PT1M30S"` | +| Java | `Duration.toString()` and `Duration.parse`, as Spring Boot configures Jackson (`WRITE_DURATIONS_AS_TIMESTAMPS` off): `"PT1H30M"`, `"PT26H"`, `"PT-1M-30S"` | the canonical form | +| Kotlin | same as Java | the canonical form | +| Python | ISO-8601 as Pydantic v2 writes a `timedelta`, which folds 24 hours into a day: `"PT1H30M"`, `"P1DT2H"`, `"-PT1M30S"`; it also reads `"01:30:00"` | the canonical form | +| TypeScript | the canonical form: a duration is a `string` in TypeScript, so there is no other standard spelling | the canonical form | + + A filter operand (`?filter[timeout][gt]=PT1H`) is an ISO-8601 duration in every mode, because it + is the query grammar, not the body serializer. The canonical form and the accepted one are in + [`fixtures/duration-wire/`](../../fixtures/duration-wire/). Choose `iso` when clients written in + several languages share one contract. - There is no `"number"` value for `decimals`. In C#, Java and Kotlin, `native` already is a number; in Python and TypeScript, a number would be a lossy spelling those languages do not use. diff --git a/docs/features/entities.md b/docs/features/entities.md index d0f0d2f728..bde41e9586 100644 --- a/docs/features/entities.md +++ b/docs/features/entities.md @@ -83,6 +83,10 @@ YAML is desugared to canonical JSON at load time. See > an `ERR_TOO_MANY_OCCURRENCES` load error. `identity.secondary` / `identity.reference` > are not singletons and DO require an explicit `name`. +An `@generation: increment` key's sequence can be given a start value (`@sequenceStart`) and +a physical name (`@sequenceName`); see +[migrations-and-drift.md](migrations-and-drift.md#an-increment-keys-sequence-sequencestart-and-sequencename). + ## `extends:` for shared abstract bases Common base fields (`id`, `createdAt`, `updatedAt`) live on an abstract entity that @@ -321,9 +325,9 @@ found = om.find_by_id("Author", row["id"]) ## Cross-field validators -`validator.comparison`, `validator.requiredWhen`, `validator.presentIff` and `validator.atLeastOne` -are declared as children of the entity and reference sibling fields by name. They are enforced in -two places: +`validator.comparison`, `validator.requiredWhen`, `validator.presentIff`, `validator.atLeastOne` +and `validator.exactlyOne` are declared as children of the entity and reference sibling fields by +name. They are enforced in two places: - **The generated full record (create) model**, in every port, with the language's native mechanism: a Zod refinement on the insert schema (TypeScript), a class-level Bean Validation @@ -335,15 +339,35 @@ two places: The two share one set of semantics: absent or null is SQL `NULL`; `comparison` passes when either side is null; `requiredWhen` is violated only when the gate equals `@equals` and the target is null; `presentIff` requires the target to be present exactly when the gate equals `@equals`; `atLeastOne` -needs one non-null field. `@equals` is read per the gating field's subtype. The shared cases in -`fixtures/validation-conformance/cross-field-cases.json` gate all five ports. +needs one non-null field; `exactlyOne` needs exactly one. `@equals` is read per the gating field's +subtype. + +Any of the five can carry a `@condition`, an expression over the entity's fields that says when the +rule applies (the grammar and its load-time checks are in +[migrations-and-drift.md](migrations-and-drift.md#checks-from-validators-names-conditions-how-many)). The rule is checked only +when the condition is true. A condition that is false, or unknown because a field it reads is null, +lets the record through, exactly as the `CHECK`'s `NOT (condition) OR (rule)` does in SQL. + +```json +{ "object.entity": { "name": "Shift", "children": [ + { "field.int": { "name": "startDay" } }, + { "field.int": { "name": "endDay" } }, + { "field.int": { "name": "startMin" } }, + { "field.int": { "name": "endMin" } }, + { "validator.comparison": { "name": "sameDayTimes", "@left": "endMin", "@op": "gt", "@right": "startMin", + "@condition": { "op": "eq", "left": { "field": "startDay" }, "right": { "field": "endDay" } } } } +] } } +``` + +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 +strings, temporals and durations 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`. +covers it where the database can compare the two columns. A comparison inside an `@condition` follows +the same rule: when two fields it reads are of different kinds, the generated model leaves the whole +validator to the database. 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. diff --git a/docs/features/field-types.md b/docs/features/field-types.md index 6623784a89..8c847d42b1 100644 --- a/docs/features/field-types.md +++ b/docs/features/field-types.md @@ -24,14 +24,15 @@ across ports — a `field.currency` is integer minor units everywhere; a | `field.uuid` | `string` | `UUID` | `UUID` | `Guid` | `UUID` | `uuid` | | `field.uri` | `string` | `java.net.URI` | `java.net.URI` | `Uri` | `AnyUrl` | `text` | | `field.inet` | `string` | `InetAddress` | `InetAddress` | `IPAddress` | `IPvAnyAddress` | `inet` | +| `field.duration` | `string` (ISO-8601) | `java.time.Duration` | `java.time.Duration` | `TimeSpan` | `timedelta` | `interval` | | `field.enum` | union + `z.enum` | `Enum` | `enum class` | `enum` | `Literal` inline, `Enum` with `UPPER_CASE` members when shared by `extends` | `varchar` + `CHECK` | | `field.object` | nested type | nested class | nested data class | nested class | nested Pydantic model | per `@storage` | | `field.map` | `Record` | `Map` | `Map` | `Dictionary` | `dict[str, V]` | `jsonb` | -That is the whole registered vocabulary — 17 concrete subtypes. `field.base` is an +That is the whole registered vocabulary — 18 concrete subtypes. `field.base` is an abstract registry anchor, never authored (`ERR_ABSTRACT_SUBTYPE_AUTHORED`). -Four rows need a footnote: +Five rows need a footnote: - **`field.decimal`** is exact in every port's own type. TypeScript's type is a string. Postgres and MySQL keep the declared `numeric(@precision, @scale)` exactly; SQLite and D1 have @@ -48,6 +49,29 @@ Four rows need a footnote: which has none (the same reason `field.uuid` is a `string` there). `@lenient: true` degrades both to a plain string with no validator, and takes the `inet` column down to `text`. +- **`field.duration`** is a fixed-length span: days, hours, minutes and seconds. A calendar + span ("every 3 months") is not one, because a month has no fixed length: model it as an int + and a unit. On the wire each port writes its own language's standard form by default (a C# + `TimeSpan` is `"01:30:00"`), and `"wire": { "durations": "iso" }` in `.metaobjects/config.json` + makes every port write one ISO-8601 form ([the wire setting](api-contract.md#the-wire-setting)). + That form is `[-]PT[nH][nM][n[.f]S]`: hours are never folded into days (`PT26H`), and zero is `PT0S`. + It is one form at each port's own precision: a port writes as many fractional digits as its + own type holds (6 in TypeScript and Python, 7 in C#, 9 in Java and Kotlin) and truncates a + longer fraction toward zero. A Postgres `interval` and the integer column below both hold + microseconds, so a C#, Java or Kotlin server can answer a POST with more digits than the + database kept: `PT0.1234567S` is answered as sent and read back as `PT0.123456S`. Read the row + back when the stored value matters. The Java types above are generated code's: Java's + metadata-driven runtime (OMDB) holds a duration as its canonical ISO-8601 `String`, and + `java.time.Duration` appears only inside its JDBC codec. Every port reads that form and also leading + week and day counts (`P1W` is 7 days, `P1DT2H` is 26 hours). Years and months are refused, and + so are lowercase designators and fractional hours. The column is a Postgres `interval`; SQLite, D1 and MySQL store a 64-bit + integer count of microseconds, the way Django stores a `DurationField` outside Postgres. Both + order and compare as the span does, so every filter operator a number has (`eq`, `ne`, `gt`, + `gte`, `lt`, `lte`, `in`, `isNull`) and `sort` work on every database. There is no shared upper + bound: each port is limited only by its own type (a .NET `TimeSpan`, a Python `timedelta`) and + each database by its own column, and refuses what it cannot hold rather than wrapping it. The + rule and its cross-port vectors are in + [`fixtures/duration-wire/`](../../fixtures/duration-wire/). - **`field.map`** is the typed dict: string keys, and a value type set by exactly one of `@valueType` (a scalar subtype) or `@objectRef` (a value object) — `V` above. It is one jsonb column holding the JSON object; `isArray` does not apply, so no port wraps the map @@ -111,7 +135,8 @@ A field has **two names**: the one your code calls it (`createdAt`, the metadata database calls it. `@column` sets the second. It never changes the first. With no `@column`, the physical name comes from the project's **column rule**: -`snake_case` (the default), `literal` (the field name verbatim) or `kebab-case`. That +`snake_case` (the default), `literal` (the field name verbatim), `kebab-case` or +`upperFirst` (the first letter upper-cased, the rest verbatim). That is CONFIG, not metadata, on purpose: the same model has to be able to drive a snake_case Postgres schema and a literal-column one. It is ONE setting for the whole project, `naming` in `.metaobjects/config.json` (ADR-0058): @@ -189,6 +214,26 @@ must be a non-empty set of unique members matching `^[A-Za-z_][A-Za-z0-9_]*$`. The loader enforces members own-only and emits `ERR_BAD_ATTR_VALUE` on a bad member or `ERR_MISSING_REQUIRED_ATTR` on missing `@values`. +**Column length:** a string-stored enum is an unbounded text column unless it has a length. +`@maxLength` on the field gives it one, and `"enums": { "maxLength": n }` in +`.metaobjects/config.json` gives every enum without its own `@maxLength` a default: + +```json +{ "field.enum": { "name": "status", "@values": ["DRAFT", "PUBLISHED", "ARCHIVED"], "@maxLength": 16 } } +``` + +The column is then `VARCHAR(n)`: `meta migrate` creates it, and the generated column bindings +that carry a length name the same one: TypeScript's Drizzle and Prisma, C#'s EF Core +(`HasMaxLength(n)`) and Kotlin's Exposed. Java and Python generate no such binding; their loaders +apply the same member-length refusals, and the database column holds the length. Every member must fit. A member +longer than `@maxLength` is refused at load, and one longer than the project default is refused +when the column is sized, both as `ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH`, with the member named. +`@maxLength` beside `@intValueMap` is refused (`ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP`): an +int-backed enum's column is an integer. An array of enums stays a `text[]` column. +Turning a length on for a column that already exists (text to `VARCHAR(n)`) fails on any longer +row, so `meta migrate` gates it behind `--allow type-change`; turning it off widens the column and +is allowed. SQLite does not enforce the length, so there is nothing to change there. + **Int-backed storage:** `@intValueMap` switches the DB column from string to integer while preserving the string wire format and generated enum type. Keys must match `@values` exactly; values must be unique integers. Display labels and native Postgres `ENUM` types remain @@ -258,9 +303,10 @@ declaration. The `@storage` attr controls how the embedded shape persists. | `@storage` | Persistence shape | Use case | |---|---|---| -| `flattened` | One DB column per sub-field: `_` | EF Core `OwnsOne` / DDD value-objects | +| `flattened` | One DB column per sub-field: ``, the prefix `_` by default | EF Core `OwnsOne` / DDD value-objects | | `jsonb` | One `jsonb` column | Structured-but-evolvable user-defined shapes | -| `subdocument` (default — back-compat) | Single jsonb column | Pre-`@storage` projects | +| `subdocument` | Single jsonb column | A hint for document-store targets | +| not set (the default) | One `jsonb` column, as `jsonb` | Pre-`@storage` projects | Example: an `Author` with an embedded `Address` flattened to per-sub-field columns: @@ -275,6 +321,35 @@ Example: an `Author` with an embedded `Address` flattened to per-sub-field colum For `flattened`, the generated table gets `address_street`, `address_city`, `address_postal_code` columns instead of one `address` jsonb. +Each member's column is a **prefix** followed by the member's own column (its `@column`, else +its name under the project's column rule). The prefix defaults to the embedding field's column +and `_` (`address_`; with `@column: "bill"`, `bill_`; under the `upperFirst` rule, `Address_`, +EF Core's own default). **`@flattenedColumnPrefix`** on the embedding field replaces it, +verbatim: + +```json +{ "field.object": { "name": "homeAddress", "@objectRef": "Address", "@storage": "flattened", + "@flattenedColumnPrefix": "" } } +``` + +gives `street`, `city`, `postal_code`; `"@flattenedColumnPrefix": "home_"` gives `home_street`, and +so on. The prefix belongs to the embedding field, not the value object, because it is a fact +about the owning table: one `Address` embedded twice needs two prefixes. Every port names these +columns through the same core function (`resolveFlattenedColumnName` and its per-port +equivalents), so `meta migrate`, the Prisma schema, the C# EF mapping and the Kotlin Exposed +tables agree, and the owning object's names artifact carries each one as a constant +(`ShipmentNames.ShipToStreetColumn` in C#; see the codegen skill's physical-names reference +for every port). The field itself has no column constant, because the table has no column of +that name. On a field whose `@storage` is not `flattened` the attribute is refused at load +(`ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE`): no other storage gives the members +columns of their own. + +Two member columns that come out the same are refused at load (`ERR_COLUMN_NAME_COLLISION`): +two embeddings that share a prefix (two `""` embeddings of one `Address` both give `street`), or +a `""` member beside a field of the same name. When two files both declare the same field (a +merge, not an `overlay`) with different prefixes, `""` included, the loader reports +`ERR_MERGE_CONFLICT`. + ## What each port generates ### TypeScript @@ -447,6 +522,8 @@ The following conformance fixtures gate this feature's behavior across ports: - [`fixtures/conformance/field-object-storage-jsonb-single/`](../../fixtures/conformance/field-object-storage-jsonb-single/) — `@storage=jsonb` single-record column - [`fixtures/conformance/field-object-storage-jsonb-array/`](../../fixtures/conformance/field-object-storage-jsonb-array/) — `@storage=jsonb` on array field - [`fixtures/conformance/error-field-object-storage-flattened-array/`](../../fixtures/conformance/error-field-object-storage-flattened-array/) — `@storage=flattened` on array field is illegal +- [`fixtures/conformance/error-field-object-flattened-column-prefix-without-flattened-storage/`](../../fixtures/conformance/error-field-object-flattened-column-prefix-without-flattened-storage/) and [`…-default-storage/`](../../fixtures/conformance/error-field-object-flattened-column-prefix-default-storage/) — `@flattenedColumnPrefix` without `@storage=flattened` is illegal +- [`fixtures/naming-conformance/physical-names/`](../../fixtures/naming-conformance/physical-names/) — flattened member columns under every column rule, with and without `@flattenedColumnPrefix` - [`fixtures/conformance/error-field-object-storage-no-object-ref/`](../../fixtures/conformance/error-field-object-storage-no-object-ref/) — `field.object` without `@objectRef` rejected Cross-port runner coverage: TS / Java / Kotlin / C# / Python all execute these diff --git a/docs/features/metadata-sources.md b/docs/features/metadata-sources.md index 6dfda56930..690ce0155a 100644 --- a/docs/features/metadata-sources.md +++ b/docs/features/metadata-sources.md @@ -352,6 +352,20 @@ out-of-scope table that already exists would become a proposed `DROP TABLE`. declaring object's FQN, and an object whose FQN is unknown was never proven to be anyone else's. +### `migrate.conventions` — names for objects no code addresses + +Beside `scope`, the `migrate` block takes `conventions`: the name of a schema-conventions preset +that decides primary-key and foreign-key constraint names, whether a foreign key gets an index, +and whether a unique key is an index or a constraint. `default` (absent) is what migrate has +always emitted; `efcore` is a reference preset that reproduces EF Core's naming. A project can +supply its own conventions from `metaobjects.config.ts` instead. Table, view and column names are +not conventions: they come from `naming`. See +[Schema conventions](migrations-and-drift.md#schema-conventions-names-for-objects-no-code-addresses). + +```jsonc +"migrate": { "scope": ["acme::billing::**"], "conventions": "efcore" } +``` + ### Put the `migrate` block where the ledger lives **Whoever holds `.metaobjects/migrations/` and the schema snapshot owns the schema.** diff --git a/docs/features/migrations-and-drift.md b/docs/features/migrations-and-drift.md index 7e38e0aea7..652e909524 100644 --- a/docs/features/migrations-and-drift.md +++ b/docs/features/migrations-and-drift.md @@ -282,6 +282,47 @@ sequence, or pass `--allow drop-identity-default` if removing auto-increment is intentional. An identity that *does* declare `@generation: increment` never reaches this gate (its default diff is skipped), so only the undeclared case fires. +#### An increment key's sequence: `@sequenceStart` and `@sequenceName` + +An `@generation: increment` key draws from a sequence. Two attributes on the +`identity.primary` declare the sequence's first value and its physical name, so the model can +state both instead of taking the database's defaults: + +```json +{ "identity.primary": { "@fields": "id", "@generation": "increment", + "@sequenceStart": 1000000000, "@sequenceName": "Ticket_Id_seq" } } +``` + +- `@sequenceStart` is the first value handed out. Absent: 1. For example, a table that keeps a + low id range for rows it seeds itself. +- `@sequenceName` is the sequence's physical name. Absent: the database's own default + (`
__seq` on Postgres). For example, a sequence that kept its name when its + table was renamed. + +On Postgres `meta migrate` creates the column as +`GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME "…" START WITH n)` (and adds an identity to an +existing column with the same options), and `meta verify --db` reads the live sequence back +from the catalog. A live sequence whose start or name differs from the declaration is altered +in place, never dropped: + +- a different start sets it (`ALTER SEQUENCE … START WITH n`) and moves the counter up to it + when it has not got there (`setval(…, n, false)`), so the next insert takes the declared + start. It never moves the counter down, which could hand out an id already in use; the down + migration restores the old start value and leaves the counter where it is. +- a different name renames it (`ALTER SEQUENCE … RENAME TO …`); the down renames it back. + +Only what the model declares is compared, on every path (a live database, a shadow database, +a committed snapshot): a database whose sequence kept an old name or a non-default start sees +no change until the model names it, and a model that declares neither writes the same SQL and +snapshots it always did. SQLite and D1 have no sequences, so both attributes change nothing +there; `meta migrate` and `meta verify --db` print an advisory when they create such a column +there. At load, either attribute without `@generation: increment` is refused +(`ERR_SEQUENCE_WITHOUT_INCREMENT`), and so is a `@sequenceStart` below 1 or past what the key +can hold, 2147483647 for a `field.int` (`ERR_SEQUENCE_START_OUT_OF_RANGE`). A declared name +shares the namespace of tables, views and indexes, so two tables declaring one (an abstract +base's identity extended twice, say) is refused when the schema is built +(`ERR_DUPLICATE_SQL_NAME`). + ### Java Schema migrations for Java projects are owned by the **TypeScript toolchain** @@ -370,6 +411,135 @@ opened by an older SQLite (Python 3.10's stdlib, Ubuntu 22.04's `sqlite3`). The behind `--allow drop-check` and raises no data hazard, because the rule is the same — and the re-diff is empty afterwards. Postgres keeps `IS [NOT] DISTINCT FROM`. +### CHECKs from validators: names, conditions, how many + +A `validator.*` node and a `field.enum`'s member list each become a CHECK constraint. Three +attributes shape it, all database-only (no generated code reads them): + +- **`@constraintName`** names the CHECK, for a database whose CHECKs were named by hand. On the + validators that generate a CHECK (`numeric`, `length`, `regex`, `comparison`, `requiredWhen`, + `presentIff`, `atLeastOne`, `exactlyOne`; `required` is a NOT NULL, not a CHECK), and on a + `field.enum` for its + membership CHECK. Absent, the name is derived (`
___chk`, + `
__chk` for an enum), as before. A declared name is refused (`ERR_CHECK_NAME`) when + another constraint on the table already has it, or, on Postgres, when it is over 63 bytes + (Postgres would truncate it, and the CHECK would never match its declaration). A CHECK with a + declared name is always emitted, even where `migrate.checks.skipRedundant` (below) would leave + it out. +- **`@condition`** on a cross-field validator (`comparison`, `requiredWhen`, `presentIff`, + `atLeastOne`, `exactlyOne`) is an expression, in the grammar `origin.computed` uses, over the entity's fields. + The rule holds only where the condition does: the CHECK is `(NOT condition) OR (rule)`, with + the negation folded in (`kind = 'Owned'` becomes `kind <> 'Owned'`). It is checked at load + (`ERR_BAD_ATTR_VALUE`): every field it names is a field of the entity, it is a boolean + expression, a literal compared with a field is of that field's kind (and, for an enum, one of + its `@values`), and no comparison has a `null` literal (`x = NULL` is never true: use `isNull` + or `isNotNull`). An int-backed enum's member is compared as its stored integer. +- **`validator.exactlyOne`** says exactly one of its `@fields` is present: + `num_nonnulls(a, b) = 1` on Postgres, a sum of `IS NOT NULL` tests on SQLite and D1. Its CHECK + is `
__exactlyone_chk` after the first field. It must name two or more fields + (`ERR_BAD_ATTR_VALUE`): exactly one of one field is a required field. + +```json +{ "object.entity": { "name": "Note", "children": [ + { "source.rdb": { "@table": "Notes" } }, + { "field.long": { "name": "id" } }, + { "field.enum": { "name": "kind", "@values": ["Owned", "Shared"], "@constraintName": "CK_Notes_Kind" } }, + { "field.long": { "name": "customerId" } }, + { "field.long": { "name": "accountId" } }, + { "identity.primary": { "name": "pk", "@fields": "id", "@generation": "increment" } }, + { "validator.exactlyOne": { "name": "oneOwner", "@fields": ["customerId", "accountId"], + "@constraintName": "CK_Notes_Owner", + "@condition": { "op": "eq", "left": { "field": "kind" }, "right": { "value": "Owned" } } } } +] } } +``` + +On Postgres the last one is `CK_Notes_Owner CHECK (("kind" <> 'Owned') OR (num_nonnulls("customer_id", "account_id") = 1))`. + +Renaming a CHECK this way is a drop and an add of the same rule on Postgres, under +`--allow drop-check`. On SQLite and D1 a CHECK is matched by its expression when the name does not +match, so a rename alone proposes nothing and the database keeps the old name. + +**Leaving out the CHECKs a column type already enforces.** By default every validator that has +a SQL form becomes a CHECK, including one the column already guarantees. For a database whose +CHECKs were written by hand only where the column type does not already enforce the rule, set: + +```json +{ "schema_version": 1, "migrate": { "checks": { "skipRedundant": true } } } +``` + +`meta migrate` and `meta verify --db` then leave out a `validator.length` maximum the column's +`VARCHAR(n)` enforces (n at most the maximum; not on SQLite or D1, which store a longer value in a +`VARCHAR(n)` column anyway) and a length minimum of 0 or less; the rest of the validator's CHECK +stands. A validator with a declared `@constraintName` keeps its CHECK whole: an explicit +declaration beats a project default, as a declared name beats a convention. Default `false`. A +`checks` that is not an object, a key other than `skipRedundant`, or a non-boolean value is +refused when the config loads. Turning it on for a database migrate already built proposes a +`drop-check` for each CHECK it now leaves out, which is gated behind `--allow drop-check`. + +## Schema conventions: names for objects no code addresses + +Some database objects have names that no program ever reads: a primary-key constraint, most +foreign-key constraints, the index on a foreign key. Their names, and a few physical choices, +come from the project's **schema conventions**, selected in `.metaobjects/config.json`: + +```json +{ "schema_version": 1, "migrate": { "conventions": "efcore" } } +``` + +| Decides | `default` (absent) | `efcore` | +|---|---|---| +| Primary-key constraint name (Postgres; SQLite and D1 emit an unnamed primary key) | `
_pkey` | `PK_
` | +| Foreign-key constraint name | `
__fk` | `FK_
__` | +| Index on a foreign key | none | `IX_
_`, unless the primary key or another index (a partial one included) already starts with the FK's columns; an FK index another FK index covers is dropped, so FK order does not matter | +| Unique key (`identity.secondary`, a `@unique` field) | a unique index; a live UNIQUE constraint is also accepted | a UNIQUE constraint when a foreign key references it, else a unique index | + +`efcore` is a reference preset. It reproduces one ORM's documented naming to show what the +mechanism does; the mechanism is general, and a project whose database was built by another tool +supplies its own conventions (below). `efcore` follows EF Core's own conventions, including, on +PostgreSQL (Npgsql), its truncation of a +name longer than 63 characters to 62 characters and `~`. EF Core's SQLite provider truncates +nothing, so on SQLite and D1 neither does `efcore`. A database first built by EF Core +migrations and one built by `meta migrate` then agree on these objects. Where the EF Core model +carried something its conventions do not (a hand-named key, an index dropped by raw SQL), declare +it in metadata. A unique key keeps the name its metadata node declares (EF Core would call an +alternate key `AK_…`; name the node that if you want EF's). A key with a `DESC` column or a +non-btree `@using` stays an index, since a UNIQUE constraint carries neither. `default` is what +migrate has always emitted, and it has no preference between the two kinds of unique key: a +database whose keys are UNIQUE constraints (Drizzle's `unique()` makes them) is left as it is. +A conventions object says which kind it wants with `uniqueKeyKind`: `index`, `constraint`, or +`either`, which is `default`'s answer. + +Names code does address are not conventions. Tables, views and columns resolve in core +naming (`naming`, and `@table` / `@column`), the same in every port. A constraint or index a +program compares against (a unique-violation handler checking the constraint name) is +declared in metadata: `@constraintName` on an `identity.reference`, or the index node's own +name. A declared name always wins over a convention. + +A project can supply its own conventions from `metaobjects.config.ts`, which wins over the +preset name. Start from a preset so every member is present; an object missing one is refused +by name: + +```ts +import { defineConfig } from "@metaobjectsdev/cli"; +import { efcoreConventions } from "@metaobjectsdev/migrate-ts"; + +export default defineConfig({ + generators: [/* … */], + schemaConventions: { ...efcoreConventions, id: "acme", primaryKeyName: ({ table }) => `pk_${table}` }, +}); +``` + +**Changing the conventions of an existing database.** On Postgres, a primary key, FK, CHECK or +index whose definition is unchanged and whose name differs is renamed in place (`RENAME +CONSTRAINT` / `ALTER INDEX … RENAME`), never dropped and re-created; new FK indexes are created. +A unique key that is now wanted as a UNIQUE constraint instead of a plain index is promoted in +place (`ADD CONSTRAINT … UNIQUE USING INDEX`), so an FK that relies on it keeps working. A key the +conventions want as a plain index (`uniqueKeyKind` answers `index`, as `efcore` does for a key +no FK references) is converted the other way, which has no in-place form: the constraint is dropped and the index re-created, and each FK +onto that key is dropped first and added back after, so the plan is gated behind `--allow +drop-index` and `--allow drop-fk`. `meta verify --db` reports each of these as drift. SQLite +cannot rename a constraint and rebuilds the table instead. + ## How CHECK constraints and expression indexes are compared Postgres rewrites an expression before it stores it (`"k" IN ('x')` becomes `k = 'x'::text`, diff --git a/docs/features/migrations/physical-naming.md b/docs/features/migrations/physical-naming.md index dbfbcd34ac..e5576c84f0 100644 --- a/docs/features/migrations/physical-naming.md +++ b/docs/features/migrations/physical-naming.md @@ -25,11 +25,18 @@ every runtime `ObjectManager` and `verify` read it, so they always produce the s | Key | Values | Applies to | Default | |---|---|---|---| -| `columns` | `snake_case`, `literal`, `kebab-case` | a field with no `@column` | `snake_case` | +| `columns` | `snake_case`, `literal`, `kebab-case`, `upperFirst` | a field with no `@column` | `snake_case` | | `tables` | `snake_case`, `literal` | a source with no `@table`/`@view`/`@proc`/`@function`: its `name`, else the object's name made plural | `snake_case` | An unknown key or value fails when the file is read. Index and constraint names are unchanged. +`upperFirst` upper-cases the field name's first letter and changes nothing +else: `createdAt` becomes `CreatedAt`, `requestURL` becomes `RequestURL` and `address_line1` +becomes `Address_line1`. It is not a word split, so it is not PascalCase. A schema whose columns +are the field names with a capital first letter (EF Core's default, for one) then needs `@column` +only where the database spells a name differently. It is a column rule only; `literal` tables +already keep an object name verbatim. + **Upgrade every port of an estate together.** A 1.0.x TypeScript tool refuses the new `naming` key, but a 1.0.x C#, Java or Python tool ignores an unknown top-level key silently and keeps the old column names, so a mixed estate disagrees about the schema without any error. diff --git a/docs/ports/csharp.md b/docs/ports/csharp.md index 02f28069b3..0c1de34c9c 100644 --- a/docs/ports/csharp.md +++ b/docs/ports/csharp.md @@ -381,6 +381,25 @@ generator copies `DecimalWire.cs` and `DecimalRangeEndpointFilter.cs`. Do not st attributes or delete `DecimalWire.g.cs` to change the output: they also check input. Set `wire` and regenerate. +A `field.duration` is a `TimeSpan` carrying `[DurationWire.Iso]`, which reads and writes the +ISO-8601 form every port shares (`PT1H30M`, and on input `P1W`, `P2DT3H`) instead of +System.Text.Json's `01:30:00`. It writes up to seven fractional digits, a `TimeSpan` tick, and +truncates a longer input fraction toward zero. On Npgsql it is an `interval` column. EF reads an +interval through Npgsql's own `TimeSpan` conversion, which (as of Npgsql 8.0.5) silently drops a +negative month or year part (`'-1 mons'` reads as `PT0S`) and wraps a day count past `TimeSpan`'s +range, so the generated context maps each duration column through `NpgsqlInterval` instead +(`DurationIntervalMapping`, `DurationIntervalArrayMapping`, emitted beside `AppDbContext`) and +refuses either one on read. LINQ still translates as it does on Npgsql's own mapping: `TimeSpan` +members (`TotalSeconds`, `Hours`, ...), a timestamp plus a duration, comparison, ordering, `Min`, +`Max`, `EF.Functions.Sum`, and `Contains` / `Count` / `Any` on a duration array. A compiled model +(`dotnet ef dbcontext optimize`) keeps the mapping and the refusal; EF Core 8's compiled model +carries no element type, so there a duration array reads and writes but is not queryable as a +collection. On any other provider a value converter stores the 64-bit microsecond count, so sorting +and range filters follow the span. Both columns hold microseconds, so a tick below one is dropped +on write. `TimeSpan`'s own range is the only limit: a longer span is refused, never wrapped. +`DurationWire.g.cs` is emitted once per namespace that has a duration, and an owned `routes` +generator also copies `DurationWire.cs`. + ### `Names` — the physical names, as constants `names` is opt-in — list `new MetaObjects.Codegen.Generators.NamesGenerator()` in @@ -443,6 +462,11 @@ no port's codegen names a primary key (Postgres migrations hardcode `
_pke emits an unnamed PK), and carrying that would restate a migrate-only formula in the artifact built to stop exactly that. +A `@storage: flattened` value-object field gets one constant per member column +(`ShipToStreetColumn`, keyed `shipTo.street` in `ColumnsByField`). Its own `ShipToColumn` is still +emitted but `[Obsolete]`: the table has no column by that name. Declaring it raises no warning; +reading it does, so a project that never reads it builds clean under `TreatWarningsAsErrors`. + `const`, not `static readonly`: a `[Table("...")]`/`[Column("...")]` attribute argument must be a compile-time constant, and those two attributes are the whole reason this artifact can replace a literal there rather than sit beside @@ -495,7 +519,9 @@ complete on every class, inherited entries included. Column names come from the project's `naming` setting in `.metaobjects/config.json` (ADR-0058), the one every port reads, default `snake_case` (what `meta migrate` creates). A database whose columns are named exactly like the fields declares -`"naming": { "columns": "literal" }`; through 1.0 that was this port's default. `dotnet meta gen` +`"naming": { "columns": "literal" }`; through 1.0 that was this port's default. One whose columns +are the field names with the first letter upper-cased (`requestURL` → `RequestURL`) declares +`"upperFirst"`. `dotnet meta gen` and `dotnet meta verify --codegen` both read the setting, so a plain run and the drift gate resolve the identical column strings; `--column-naming` is refused ([physical-naming.md](../features/migrations/physical-naming.md)). `verify --codegen` says so diff --git a/examples/advanced-modeling/codegen/runtime/duration-wire.ts b/examples/advanced-modeling/codegen/runtime/duration-wire.ts new file mode 100644 index 0000000000..732659f9b0 --- /dev/null +++ b/examples/advanced-modeling/codegen/runtime/duration-wire.ts @@ -0,0 +1,7 @@ +// The field.duration wire rule lives in @metaobjectsdev/metadata (shared with migrate-ts); +// re-exported here so the runtime's callers import it beside the decimal wire helpers. +export { + DURATION_PATTERN, DURATION_RE, parseDurationLiteral, formatDuration, formatPostgresInterval, + canonicalDuration, parseDurationInput, parsePostgresInterval, durationToWire, durationToStoredMicros, + DURATION_MICROS_MIN, DURATION_MICROS_MAX, +} from "@metaobjectsdev/metadata"; diff --git a/examples/showcase/codegen/runtime/duration-wire.ts b/examples/showcase/codegen/runtime/duration-wire.ts new file mode 100644 index 0000000000..732659f9b0 --- /dev/null +++ b/examples/showcase/codegen/runtime/duration-wire.ts @@ -0,0 +1,7 @@ +// The field.duration wire rule lives in @metaobjectsdev/metadata (shared with migrate-ts); +// re-exported here so the runtime's callers import it beside the decimal wire helpers. +export { + DURATION_PATTERN, DURATION_RE, parseDurationLiteral, formatDuration, formatPostgresInterval, + canonicalDuration, parseDurationInput, parsePostgresInterval, durationToWire, durationToStoredMicros, + DURATION_MICROS_MIN, DURATION_MICROS_MAX, +} from "@metaobjectsdev/metadata"; diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index 7a98e03d80..231e25c150 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -4,11 +4,11 @@ "pypi": "1.2.0", "nuget": "1.2.0", "maven": "8.2.0", - "metamodel": "1.2" + "metamodel": "1.3" }, "counts": { - "fixtures": 428, - "corpora": 36, + "fixtures": 448, + "corpora": 38, "baseTypes": 17 }, "snippets": { diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index ff940062ae..555033850b 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -286,13 +286,10 @@ Reuse a constraint set across entities with an abstract `field.enum` + `extends` `field.object` embeds another `object` declaration. `@objectRef` names it; `@storage` controls persistence: -- `flattened` — one DB column per sub-field (`address_street`, `address_city`, …). - Illegal on array fields. The prefix is the field's column and `_`; - `@flattenedColumnPrefix` on the field replaces it verbatim (`""` keeps the members' - own columns). Refused on any other storage. -- `jsonb` — one `jsonb` column. +- `flattened` — one DB column per sub-field (`address_street`, …); illegal on arrays. `@flattenedColumnPrefix` + replaces the `_` prefix verbatim (`""` keeps the members' own columns). +- `jsonb` — one `jsonb` column; also the default when `@storage` is not set. - `subdocument` — single jsonb column (a hint for document-store targets). -- not set (the default) — one `jsonb` column, as `jsonb`. ```json { "field.object": { "name": "address", "@objectRef": "Address", "@storage": "flattened" } } @@ -438,15 +435,14 @@ 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`). 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.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings, temporals and durations 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` | | `validator.exactlyOne` | exactly one of `@fields` (2+) is present | `@fields` | -All five take an optional `@condition`, an expression over the entity's fields (the -`origin.computed` grammar): the rule then holds only where the condition does. And every validator -that becomes a CHECK takes an optional db-provider `@constraintName` naming it. +All five take an optional `@condition` (the `origin.computed` grammar): the rule holds only where it does. +Every validator that becomes a CHECK takes an optional `@constraintName`. ```json { "validator.comparison": { "name": "hpInRange", "@left": "currentHp", "@op": "lte", "@right": "maxHp" } } diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md index ff940062ae..555033850b 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -286,13 +286,10 @@ Reuse a constraint set across entities with an abstract `field.enum` + `extends` `field.object` embeds another `object` declaration. `@objectRef` names it; `@storage` controls persistence: -- `flattened` — one DB column per sub-field (`address_street`, `address_city`, …). - Illegal on array fields. The prefix is the field's column and `_`; - `@flattenedColumnPrefix` on the field replaces it verbatim (`""` keeps the members' - own columns). Refused on any other storage. -- `jsonb` — one `jsonb` column. +- `flattened` — one DB column per sub-field (`address_street`, …); illegal on arrays. `@flattenedColumnPrefix` + replaces the `_` prefix verbatim (`""` keeps the members' own columns). +- `jsonb` — one `jsonb` column; also the default when `@storage` is not set. - `subdocument` — single jsonb column (a hint for document-store targets). -- not set (the default) — one `jsonb` column, as `jsonb`. ```json { "field.object": { "name": "address", "@objectRef": "Address", "@storage": "flattened" } } @@ -438,15 +435,14 @@ 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`). 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.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings, temporals and durations 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` | | `validator.exactlyOne` | exactly one of `@fields` (2+) is present | `@fields` | -All five take an optional `@condition`, an expression over the entity's fields (the -`origin.computed` grammar): the rule then holds only where the condition does. And every validator -that becomes a CHECK takes an optional db-provider `@constraintName` naming it. +All five take an optional `@condition` (the `origin.computed` grammar): the rule holds only where it does. +Every validator that becomes a CHECK takes an optional `@constraintName`. ```json { "validator.comparison": { "name": "hpInRange", "@left": "currentHp", "@op": "lte", "@right": "maxHp" } } diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md index ff940062ae..555033850b 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -286,13 +286,10 @@ Reuse a constraint set across entities with an abstract `field.enum` + `extends` `field.object` embeds another `object` declaration. `@objectRef` names it; `@storage` controls persistence: -- `flattened` — one DB column per sub-field (`address_street`, `address_city`, …). - Illegal on array fields. The prefix is the field's column and `_`; - `@flattenedColumnPrefix` on the field replaces it verbatim (`""` keeps the members' - own columns). Refused on any other storage. -- `jsonb` — one `jsonb` column. +- `flattened` — one DB column per sub-field (`address_street`, …); illegal on arrays. `@flattenedColumnPrefix` + replaces the `_` prefix verbatim (`""` keeps the members' own columns). +- `jsonb` — one `jsonb` column; also the default when `@storage` is not set. - `subdocument` — single jsonb column (a hint for document-store targets). -- not set (the default) — one `jsonb` column, as `jsonb`. ```json { "field.object": { "name": "address", "@objectRef": "Address", "@storage": "flattened" } } @@ -438,15 +435,14 @@ 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`). 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.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings, temporals and durations 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` | | `validator.exactlyOne` | exactly one of `@fields` (2+) is present | `@fields` | -All five take an optional `@condition`, an expression over the entity's fields (the -`origin.computed` grammar): the rule then holds only where the condition does. And every validator -that becomes a CHECK takes an optional db-provider `@constraintName` naming it. +All five take an optional `@condition` (the `origin.computed` grammar): the rule holds only where it does. +Every validator that becomes a CHECK takes an optional `@constraintName`. ```json { "validator.comparison": { "name": "hpInRange", "@left": "currentHp", "@op": "lte", "@right": "maxHp" } } diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index ff940062ae..555033850b 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -286,13 +286,10 @@ Reuse a constraint set across entities with an abstract `field.enum` + `extends` `field.object` embeds another `object` declaration. `@objectRef` names it; `@storage` controls persistence: -- `flattened` — one DB column per sub-field (`address_street`, `address_city`, …). - Illegal on array fields. The prefix is the field's column and `_`; - `@flattenedColumnPrefix` on the field replaces it verbatim (`""` keeps the members' - own columns). Refused on any other storage. -- `jsonb` — one `jsonb` column. +- `flattened` — one DB column per sub-field (`address_street`, …); illegal on arrays. `@flattenedColumnPrefix` + replaces the `_` prefix verbatim (`""` keeps the members' own columns). +- `jsonb` — one `jsonb` column; also the default when `@storage` is not set. - `subdocument` — single jsonb column (a hint for document-store targets). -- not set (the default) — one `jsonb` column, as `jsonb`. ```json { "field.object": { "name": "address", "@objectRef": "Address", "@storage": "flattened" } } @@ -438,15 +435,14 @@ 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`). 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.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings, temporals and durations 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` | | `validator.exactlyOne` | exactly one of `@fields` (2+) is present | `@fields` | -All five take an optional `@condition`, an expression over the entity's fields (the -`origin.computed` grammar): the rule then holds only where the condition does. And every validator -that becomes a CHECK takes an optional db-provider `@constraintName` naming it. +All five take an optional `@condition` (the `origin.computed` grammar): the rule holds only where it does. +Every validator that becomes a CHECK takes an optional `@constraintName`. ```json { "validator.comparison": { "name": "hpInRange", "@left": "currentHp", "@op": "lte", "@right": "maxHp" } } diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md index ff940062ae..555033850b 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -286,13 +286,10 @@ Reuse a constraint set across entities with an abstract `field.enum` + `extends` `field.object` embeds another `object` declaration. `@objectRef` names it; `@storage` controls persistence: -- `flattened` — one DB column per sub-field (`address_street`, `address_city`, …). - Illegal on array fields. The prefix is the field's column and `_`; - `@flattenedColumnPrefix` on the field replaces it verbatim (`""` keeps the members' - own columns). Refused on any other storage. -- `jsonb` — one `jsonb` column. +- `flattened` — one DB column per sub-field (`address_street`, …); illegal on arrays. `@flattenedColumnPrefix` + replaces the `_` prefix verbatim (`""` keeps the members' own columns). +- `jsonb` — one `jsonb` column; also the default when `@storage` is not set. - `subdocument` — single jsonb column (a hint for document-store targets). -- not set (the default) — one `jsonb` column, as `jsonb`. ```json { "field.object": { "name": "address", "@objectRef": "Address", "@storage": "flattened" } } @@ -438,15 +435,14 @@ 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`). 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.comparison` | two fields stand in a relational order (`@left @op @right`). Numerics compare with each other; strings, temporals and durations 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` | | `validator.exactlyOne` | exactly one of `@fields` (2+) is present | `@fields` | -All five take an optional `@condition`, an expression over the entity's fields (the -`origin.computed` grammar): the rule then holds only where the condition does. And every validator -that becomes a CHECK takes an optional db-provider `@constraintName` naming it. +All five take an optional `@condition` (the `origin.computed` grammar): the rule holds only where it does. +Every validator that becomes a CHECK takes an optional `@constraintName`. ```json { "validator.comparison": { "name": "hpInRange", "@left": "currentHp", "@op": "lte", "@right": "maxHp" } } diff --git a/fixtures/validation-conformance/README.md b/fixtures/validation-conformance/README.md index 59c43ebbd6..a13a58e731 100644 --- a/fixtures/validation-conformance/README.md +++ b/fixtures/validation-conformance/README.md @@ -133,7 +133,8 @@ can compare the two column types: strings, temporals and durations 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). Two durations order by length, not by their text (`Shift.actual lte planned` accepts `PT45M` against `PT1H`); the duration - payloads are ISO-8601, so a runner whose port defaults to another form pins `wire.durations: iso`. + payloads are ISO-8601, and the corpus pins `wire.durations: iso` in its own `.metaobjects/config.json`, + which a runner whose port defaults to another form reads. - `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 @@ -146,7 +147,8 @@ can compare the two column types: and `and` / `or` / `ne` / `isNull` over a `coalesce` with a literal default. A comparison inside a condition follows the `comparison` rule above: when two fields it reads are of different kinds, the whole rule is left to the database (`lateCallNeedsPhone` compares a date with a timestamp, so a `CALL` - without a phone is valid). + without a phone is valid). `eq` / `ne` on two equality-only fields test equality and never order them + (`offSiteCallNeedsPhone` compares an IPv4 with an IPv6 address, which have no order, and is enforced). - A rule naming a field the create body never carries (`reviewedBy`, `@mutability: readOnly`; likewise a derived field or a store-generated key) is skipped, so `review = DONE` without a reviewer is valid. diff --git a/scripts/check-doc-examples.ts b/scripts/check-doc-examples.ts index 97ea0ee1a6..239c65f50d 100644 --- a/scripts/check-doc-examples.ts +++ b/scripts/check-doc-examples.ts @@ -154,6 +154,11 @@ const FAIL_CODES = new Set([ "ERR_MUTABILITY_DOWNGRADE", "ERR_READONLY_ASSIGNED_PRIMARY", "ERR_STORAGE_FLATTENED_ARRAY", + "ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE", + "ERR_ENUM_VALUE_EXCEEDS_MAX_LENGTH", + "ERR_ENUM_MAX_LENGTH_WITH_INT_VALUE_MAP", + "ERR_SEQUENCE_WITHOUT_INCREMENT", + "ERR_SEQUENCE_START_OUT_OF_RANGE", "ERR_PHYSICAL_NAME_KIND_MISMATCH", "ERR_PHYSICAL_NAME_MULTIPLE", "ERR_SQL_BODY_ON_WRITABLE_KIND", diff --git a/scripts/check-requirements-ledger.ts b/scripts/check-requirements-ledger.ts index 7c0506bebd..838533a25e 100644 --- a/scripts/check-requirements-ledger.ts +++ b/scripts/check-requirements-ledger.ts @@ -48,7 +48,7 @@ const EXPECTED_WARNING = "WARN_REQUIREMENT_NOTHING_IMPLEMENTS"; * the neighbour — became `Number("metaobjects")`, i.e. NaN. Every comparison against * NaN is unequal, so the gate failed reporting "expected NaN" and a remedy paragraph * about updating the pin. */ -const DEFAULT_EXPECTED_WARNING_COUNT = 148; +const DEFAULT_EXPECTED_WARNING_COUNT = 150; function expectedWarnings(argv: readonly string[]): number { const flag = argv.find((a) => a.startsWith("--expect-warnings=")); diff --git a/scripts/prerelease-version.mjs b/scripts/prerelease-version.mjs index ac359d4f11..e54510abf0 100644 --- a/scripts/prerelease-version.mjs +++ b/scripts/prerelease-version.mjs @@ -46,7 +46,8 @@ export function releasedVersion({ npmLatest, tags, major }) { * The version this cut is FOR: `{ base, source }`, or throws an Error whose message says why not. * * - A CHANGELOG section newer than the release is unpublished work; the lowest one is the next - * release, and the cut is for it. + * release, and the cut is for it. Only sections on the released major line or the next one + * count: the historical Maven-only [7.x] sections are not this line's work. * - `--base` may name that version, or any version when every section is published. It may not * name an already released version, and it may not SKIP an unpublished section: that is the * mis-numbering this exists to refuse, reached by hand instead of by guess. @@ -61,7 +62,13 @@ export function decideBase({ released, changelog, explicit }) { "Pass --base (the CHANGELOG section being prepared).", ); } - const unpublished = changelog.filter((v) => cmpVer(v, released) > 0).sort(cmpVer); + // Only sections on the released major line or the next one (a major being prepared): the + // CHANGELOG also carries the old Maven-only [7.x] sections, which sort above every 1.x + // number and were proposed as the next base. + const major = Number(released.split(".")[0]); + const unpublished = changelog + .filter((v) => Number(v.split(".")[0]) <= major + 1 && cmpVer(v, released) > 0) + .sort(cmpVer); if (explicit != null) { if (cmpVer(explicit, released) <= 0) throw new Error(`--base ${explicit} is not newer than the released ${released}`); diff --git a/scripts/prerelease.mjs b/scripts/prerelease.mjs index e9a1b284da..db5c71a18f 100644 --- a/scripts/prerelease.mjs +++ b/scripts/prerelease.mjs @@ -9,7 +9,8 @@ // bun run prerelease:publish --iter 5 # pin the iteration number // bun run prerelease:publish --base 0.25.0 # override the in-development version // -// The base version is the lowest CHANGELOG.md `## [x.y.z]` section that is not released yet — +// The base version is the lowest CHANGELOG.md `## [x.y.z]` section on the released major line (or +// the next major) that is not released yet — // the repository's own declaration of what is being worked on — falling back to minor+1 when // every section is released. "Released" is read from public npm and the release tags, never // from package.json (release prep bumps that first). `--base` may override, but never to skip @@ -394,7 +395,11 @@ if (wants("java")) { "examples/showcase/jvm/pom.xml", ]; touch(...poms); - await run(`sed -i 's/${javaReleased.replace(/\./g, "\\.")}/${V.maven}/g' ${poms.map((f) => `'${f}'`).join(" ")}`); + // In JS rather than `sed -i`, whose in-place flag GNU and BSD (macOS) sed spell differently. + for (const f of poms) { + const path = join(ROOT, f); + writeFileSync(path, readFileSync(path, "utf8").replaceAll(javaReleased, V.maven)); + } await run("scripts/check-pom-versions.sh"); ok(`java poms → ${V.maven}`); const settings = join(scratch("m2"), "settings.xml"); diff --git a/scripts/test-prerelease-version.mjs b/scripts/test-prerelease-version.mjs index d417c7a0e5..0b07f458bd 100644 --- a/scripts/test-prerelease-version.mjs +++ b/scripts/test-prerelease-version.mjs @@ -9,6 +9,8 @@ // P2 the iteration counter reads every page of the registry's listing. // P3 a SIGTERM restores the version files, as exit and SIGINT already did, and stops a step that // is still running instead of letting the run publish the remaining registries first. +// P4 only CHANGELOG sections on the released major line or the next one count; the old Maven-only [7.x] sections +// made a cut after 1.2.0 propose 7.20.12. import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; @@ -36,6 +38,22 @@ await test("the release tag is the highest plain tag on the npm major line", () assert.equal(latestReleaseTag(["v7.20.12"], 1), null); }); +await test("P4: a section from another major line (the old Maven-only [7.x]) is never the base", () => { + // 1.2.0 released; the CHANGELOG still carries historical Maven-only sections such as [7.20.12], + // which sort above every 1.x number. The cut must be 1.3.0 (nothing on 1.x is unpublished), not 7.20.12. + const md = "## [Unreleased]\n\n## [1.2.0] — 2026-10-30\n\n## [1.1.4]\n\n## [7.20.12] — 2026-08-02\n"; + assert.deepEqual(decideBase({ released: "1.2.0", changelog: changelogVersions(md), explicit: null }), + { base: "1.3.0", source: "guessed from released 1.2.0" }); + // and an explicit --base is not refused for "skipping" a section on another line + assert.equal(decideBase({ released: "1.2.0", changelog: changelogVersions(md), explicit: "1.2.1" }).base, "1.2.1"); +}); + +await test("P4: the NEXT major's section, being prepared, is still the base", () => { + const md = "## [Unreleased]\n\n## [2.0.0]\n\n## [1.2.0] — 2026-10-30\n\n## [7.20.12] — 2026-08-02\n"; + assert.deepEqual(decideBase({ released: "1.2.0", changelog: changelogVersions(md), explicit: null }), + { base: "2.0.0", source: "CHANGELOG.md" }); +}); + await test("P1: release prep's package.json bump does not make 1.1.0 'released'", () => { // package.json says 1.1.0 (the prep PR); npm and the tags say 1.0.13. const released = releasedVersion({ npmLatest: "1.0.13", tags: ["v1.0.13", "v1.1.0-rc.2"], major: 1 }); diff --git a/site-reference/index.html b/site-reference/index.html index 243b50bef9..49cb609caa 100644 --- a/site-reference/index.html +++ b/site-reference/index.html @@ -156,6 +156,11 @@ + + + + + @@ -396,6 +401,11 @@ + + + + + diff --git a/site-reference/providers.html b/site-reference/providers.html index 6668e2ed3b..47ac13f1c5 100644 --- a/site-reference/providers.html +++ b/site-reference/providers.html @@ -51,13 +51,13 @@ INDEX.md lists by type.

metaobjects-core-types

Core metaobjects metamodel types and subtypes.

-

Owns (registers): attr.base, attr.boolean, attr.class, attr.double, attr.expression, attr.filter, attr.int, attr.intMap, attr.long, attr.properties, attr.string, field.base, field.boolean, field.currency, field.date, field.decimal, field.double, field.enum, field.float, field.inet, field.int, field.long, field.map, field.object, field.string, field.time, field.timestamp, field.uri, field.uuid, identity.primary, identity.reference, identity.secondary, layout.base, layout.dataGrid, object.base, object.entity, object.projection, object.report, object.value, origin.aggregate, origin.base, origin.computed, origin.first, 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

+

Owns (registers): attr.base, attr.boolean, attr.class, attr.double, attr.expression, attr.filter, attr.int, attr.intMap, attr.long, attr.properties, attr.string, field.base, field.boolean, field.currency, field.date, field.decimal, field.double, field.duration, field.enum, field.float, field.inet, field.int, field.long, field.map, field.object, field.string, field.time, field.timestamp, field.uri, field.uuid, identity.primary, identity.reference, identity.secondary, layout.base, layout.dataGrid, object.base, object.entity, object.projection, object.report, object.value, origin.aggregate, origin.base, origin.computed, origin.first, 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.exactlyOne, validator.length, validator.numeric, validator.presentIff, validator.regex, validator.required, validator.requiredWhen, view.base, view.currency

Contributes attributes:

  • field.base: @default, @mutability, @required, @unique
  • field.currency: @currency
  • field.decimal: @precision, @scale
  • -
  • field.enum: @intValueMap, @provided, @values
  • +
  • field.enum: @intValueMap, @maxLength, @provided, @values
  • field.inet: @lenient
  • field.map: @objectRef, @valueType
  • field.object: @objectRef
  • @@ -81,14 +81,15 @@

    metaobjects-core-types

  • template.prompt: @format, @maxChars, @maxTokens, @model, @owner, @payloadRef, @promptStyle, @requiredSlots, @requiredTags, @responseFormat, @responseRef, @since, @textRef
  • template.toolcall: @maxTokens, @owner, @payloadRef, @since, @toolName
  • validator.array: @max, @min
  • -
  • validator.atLeastOne: @fields
  • +
  • validator.atLeastOne: @condition, @fields
  • validator.base: @max, @min
  • -
  • validator.comparison: @left, @op, @right
  • +
  • validator.comparison: @condition, @left, @op, @right
  • +
  • validator.exactlyOne: @condition, @fields
  • validator.length: @max, @min
  • validator.numeric: @max, @min
  • -
  • validator.presentIff: @equals, @field, @when
  • +
  • validator.presentIff: @condition, @equals, @field, @when
  • validator.regex: @max, @min, @pattern
  • -
  • validator.requiredWhen: @equals, @field, @when
  • +
  • validator.requiredWhen: @condition, @equals, @field, @when

metaobjects-db

DB-domain attributes — @column / @db.indexed / @dbColumnType on every field, @storage on field.object, @autoSet on temporal fields, @table/@kind/@role/@schema on source.rdb.

@@ -100,22 +101,32 @@

metaobjects-db

  • field.date: @autoSet, @column, @db.indexed, @dbColumnType
  • field.decimal: @column, @db.indexed, @dbColumnType
  • field.double: @column, @db.indexed, @dbColumnType
  • -
  • field.enum: @column, @db.indexed, @dbColumnType
  • +
  • field.duration: @column, @db.indexed, @dbColumnType
  • +
  • field.enum: @column, @constraintName, @db.indexed, @dbColumnType
  • field.float: @column, @db.indexed, @dbColumnType
  • field.inet: @column, @db.indexed, @dbColumnType
  • field.int: @column, @db.indexed, @dbColumnType
  • field.long: @column, @db.indexed, @dbColumnType
  • field.map: @column, @db.indexed, @dbColumnType
  • -
  • field.object: @column, @db.indexed, @dbColumnType, @storage
  • +
  • field.object: @column, @db.indexed, @dbColumnType, @flattenedColumnPrefix, @storage
  • field.string: @column, @db.indexed, @dbColumnType
  • field.time: @autoSet, @column, @db.indexed, @dbColumnType
  • field.timestamp: @autoSet, @column, @db.indexed, @dbColumnType, @localTime
  • field.uri: @column, @db.indexed, @dbColumnType
  • field.uuid: @column, @db.indexed, @dbColumnType
  • +
  • identity.primary: @sequenceName, @sequenceStart
  • identity.reference: @constraintName, @onDelete, @onUpdate
  • identity.secondary: @expr, @orders, @using, @where
  • index.lookup: @expr, @orders, @using, @where
  • source.rdb: @function, @kind, @materializedView, @parameterRef, @proc, @role, @schema, @sql, @table, @unmanaged, @view
  • +
  • validator.atLeastOne: @constraintName
  • +
  • validator.comparison: @constraintName
  • +
  • validator.exactlyOne: @constraintName
  • +
  • validator.length: @constraintName
  • +
  • validator.numeric: @constraintName
  • +
  • validator.presentIff: @constraintName
  • +
  • validator.regex: @constraintName
  • +
  • validator.requiredWhen: @constraintName
  • metaobjects-documentation

    Universal documentation common attrs (description / title / notes / deprecated / replacedBy / seeAlso / aliases) accepted on every metatype.

    @@ -130,6 +141,7 @@

    metaobjects-prompt

  • field.date: @example, @instruction, @xmlText
  • field.decimal: @example, @instruction, @xmlText
  • field.double: @example, @instruction, @xmlText
  • +
  • field.duration: @example, @instruction, @xmlText
  • field.enum: @coerceDefault, @enumAlias, @enumDoc, @example, @instruction, @normalize, @xmlText
  • field.float: @example, @instruction, @xmlText
  • field.inet: @example, @instruction, @xmlText
  • @@ -154,6 +166,7 @@

    metaobjects-ui

  • field.date: @filterable, @formExclude, @sortable, @sortableDefaultOrder
  • field.decimal: @filterable, @formExclude, @sortable, @sortableDefaultOrder
  • field.double: @filterable, @formExclude, @sortable, @sortableDefaultOrder
  • +
  • field.duration: @filterable, @formExclude, @sortable, @sortableDefaultOrder
  • field.enum: @filterable, @formExclude, @sortable, @sortableDefaultOrder
  • field.float: @filterable, @formExclude, @sortable, @sortableDefaultOrder
  • field.inet: @filterable, @formExclude, @sortable, @sortableDefaultOrder
  • diff --git a/site-reference/types/field.html b/site-reference/types/field.html index 0b53d4a050..e8ff866b9f 100644 --- a/site-reference/types/field.html +++ b/site-reference/types/field.html @@ -987,6 +987,156 @@

    field.double

  • validator.* — 0..*
  • view.* — 0..*
  • +

    field.duration

    +

    A fixed-length span of time: days, hours, minutes and seconds (ADR-0036/0037 — a concept with a native type + behavior). Binds to TS string (TS has no native duration type); other ports bind to their native type (C# System.TimeSpan, Java and Kotlin java.time.Duration, Python datetime.timedelta). DB column is the Postgres-native interval type; SQLite, D1 and MySQL store a 64-bit integer count of microseconds, which orders and compares as the span does. The wire form is an ISO-8601 duration: every port writes [-]PT[nH][nM][n[.f]S] (hours never folded into days, as many fractional digits as its native type holds, zero is PT0S) and reads that form plus leading week and day counts (nW, exactly 7 days; nD, exactly 24 hours) and a fraction of any length, truncated toward zero. Years and months are refused: a calendar span is not a fixed length. Justified in ADR-0023 Amendment 1.

    +

    Owning provider: metaobjects-core-types

    +

    When to use: A field holds an elapsed time, a timeout or a delay. Use it for a native Postgres interval column and a native duration type instead of a string or a number of seconds. A calendar span ("every 3 months") is not a duration: model it as an int and a unit.

    +

    Attributes

    +
    types/field.md#fielddouble
    field.durationA fixed-length span of time: days, hours, minutes and seconds (ADR-0036/0037 — a concept with a native type + behavior). Binds to TS string (TS has no native duration type); other ports bind to their native type (C# System.TimeSpan, Java and Kotlin java.time.Duration, Python datetime.timedelta). DB column is the Postgres-native interval type; SQLite, D1 and MySQL store a 64-bit integer count of microseconds, which orders and compares as the span does. The wire form is an ISO-8601 duration: every port writes [-]PT[nH][nM][n[.f]S] (hours never folded into days, as many fractional digits as its native type holds, zero is PT0S) and reads that form plus leading week and day counts (nW, exactly 7 days; nD, exactly 24 hours) and a fraction of any length, truncated toward zero. Years and months are refused: a calendar span is not a fixed length. Justified in ADR-0023 Amendment 1.types/field.md#fieldduration
    field.enum String-backed enumeration constrained to a closed set of member symbols (@values). Each member is its own stored string with no name/value divergence. types/field.md#fieldenumtypes/validator.md#validatorcomparison
    validator.exactlyOneCardinality of presence: exactly one of the named fields (@fields) is present (NOT NULL), and the others are null. Entity-scoped; references fields by name (same @fields-by-name pattern as identity.*). Justified in ADR-0023 Amendment 1.types/validator.md#validatorexactlyone
    validator.length Bounds string length / collection size via @min/@max. types/validator.md#validatorlength
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    AttributeTypeRequiredDefaultAllowed valuesProviderDescription
    @columnstringnometaobjects-dbPhysical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json).
    @db.indexedbooleannometaobjects-dbWhen true, suppress the @filterable-without-index Loader warning (the field is indexed by other means).
    @dbColumnTypestringnouuid, jsonbmetaobjects-dbPhysical DB column-type override (ADR-0013 escape hatch). Legal values are uuid | jsonb, both on field.string (uuid = native Postgres uuid column over a string-typed field; jsonb = genuinely-open JSON column). The logical field type and its native binding are unchanged. Native SQL arrays (uuid[]/text[]) are NOT declared here — they are derived from a field subtype + isArray (ADR-0036 Wave 1). The retired timestamp_with_tz value is gone — timezone-awareness lives in field.timestamp (instant by default) + @localTime (the naive opt-out), per ADR-0036 Wave 2.
    @defaultanynometaobjects-core-typesDefault value applied to the column when no value is supplied. Its type follows the field's own subtype (string / boolean / number / ...). Converted at consumption time via MetaField.defaultValue().
    @examplestringnometaobjects-promptFR-010: an example value for this field, shown in the generated output-format prompt fragment.
    @filterablebooleannometaobjects-uiWhen true, the field is exposed in generated CRUD filter allowlists (Project D filter layer).
    @formExcludebooleannometaobjects-uiWhen true, the field is omitted from generated forms, and the generated create, PATCH and PUT routes ignore a value sent for it (reads of it are unchanged); inert on fields for which no form is generated (e.g. projection/derived fields).
    @instructionstringnometaobjects-promptFR-010: a short instruction for this field, shown in the generated output-format prompt fragment.
    @mutabilitystringnoreadWrite, writeOnce, readOnlymetaobjects-core-typesFR-037 R1: who may write this field, and when. 'readWrite' (the default when absent) — the caller may set it on create and change it on update. 'writeOnce' — the caller sets it on create; it is excluded from the update shape thereafter, so a value presented on PATCH is ignored rather than rejected. 'readOnly' — nobody writes it: codegen emits no setter / writable property, the persistence layer skips the column on INSERT/UPDATE, and Zod/Pydantic/class-validator schemas omit it from input variants; the value is populated by the database (computed column, default expression, trigger), by replication, or by another external owner. The three are mutually exclusive modes of ONE axis — who may write, and when — so the illegal pair is unrepresentable and inheritance has a total order: a subtype may TIGHTEN an inherited mode (readWrite < writeOnce < readOnly) and never loosen it (ERR_MUTABILITY_DOWNGRADE). Pairing a non-readWrite mode with @autoSet is ERR_MUTABILITY_AUTOSET_CONFLICT: @autoSet already says the SERVER supplies the value, which is a different axis from who may write it.
    @requiredbooleannometaobjects-core-typesWhen true, the field is NOT NULL. Equivalent to attaching a validator.required child. On a non-array string, generated wire-tier input validation (create/patch) additionally rejects the empty string by default — whitespace is accepted — unless an explicit validator.length @min: 0 opts back to presence-only. In-process read models never enforce this at construction.
    @sortablebooleannometaobjects-uiWhen true, the field is exposed in generated CRUD sort allowlists. Inherits from @filterable by default; set false to opt out.
    @sortableDefaultOrderstringnoasc, descmetaobjects-uiDirection a sort takes when it names this field but omits the order: a ?sort= with no :order, and a layout.dataGrid whose @defaultSortField names it with no @defaultSortOrder. Defaults to 'asc'.
    @uniquebooleannometaobjects-core-typesWhen true, the field gets a column-level UNIQUE constraint.
    @xmlTextbooleannometaobjects-promptWhen true, this field receives its element's XML TEXT CONTENT during tolerant extract (JAXB @XmlValue / Jackson @JacksonXmlText / .NET [XmlText]) instead of a same-named child. No effect when the eliciting prompt's @responseFormat is json.
    +

    Allowed children

    +
      +
    • origin.* — 0..*
    • +
    • validator.* — 0..*
    • +
    • view.* — 0..*
    • +

    field.enum

    String-backed enumeration constrained to a closed set of member symbols (@values). Each member is its own stored string with no name/value divergence.

    Owning provider: metaobjects-core-types

    @@ -1024,6 +1174,15 @@

    field.enum

    Physical column name for this field on an rdb source. Defaults to the field name under the project's naming.columns rule (the "naming" setting in .metaobjects/config.json). +@constraintName +string +no + + +metaobjects-db +The database name of the CHECK constraint this enum's member list generates. Absent: __chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. + + @@ -1114,6 +1273,15 @@

    field.enum

    + + + + + + + + + @@ -2051,6 +2219,15 @@

    field.object

    + + + + + + + + + diff --git a/site-reference/types/identity.html b/site-reference/types/identity.html index 7f0fd6698c..1b2266494c 100644 --- a/site-reference/types/identity.html +++ b/site-reference/types/identity.html @@ -85,6 +85,24 @@

    identity.primary

    + + + + + + + + + + + + + + + + + + + + + + + +
    @db.indexed boolean no Optional per-member int values ({member: int}) switching this enum field's DB persistence from string+CHECK to integer+CHECK. Keys must exactly match @values; values must be unique integers. The generated native type and wire format are unchanged in every language.
    @maxLengthintnometaobjects-core-typesColumn length of a string-stored enum (drives VARCHAR(n)); every member must fit. Overrides the project's enums.maxLength. Refused with @intValueMap, whose column is an integer. Justified in ADR-0023 Amendment 1.
    @mutability string no When true, the field is exposed in generated CRUD filter allowlists (Project D filter layer).
    @flattenedColumnPrefixstringnometaobjects-dbOn a field.object with @storage "flattened": the text put before each member's column, verbatim ("" allowed, so the members keep their own column names). Absent: this field's resolved column name and "" (with @column "bill", the members are bill). A fact about the owning table, so it lives on the embedding field, never on the value object. Refused on any other storage (ERR_FLATTENED_COLUMN_PREFIX_WITHOUT_FLATTENED_STORAGE). Justified in ADR-0023 Amendment 1.
    @formExclude boolean no metaobjects-core-types Primary-key value generation strategy: 'increment' (auto-increment), 'uuid', or 'assigned' (caller-supplied).
    @sequenceNamestringnometaobjects-dbThe physical name of an @generation "increment" key's sequence (Postgres: GENERATED BY DEFAULT AS IDENTITY (SEQUENCE NAME ...)). Absent: the database's own default, __seq on Postgres. Lets a model adopt a sequence that kept its name across a table rename. Refused without @generation "increment" (ERR_SEQUENCE_WITHOUT_INCREMENT). RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. + + + + + + + + + +
    @sequenceStartlongnometaobjects-dbThe first value an @generation "increment" key's sequence hands out (Postgres: GENERATED BY DEFAULT AS IDENTITY (START WITH n)). Absent: 1. Lets a model reproduce a table that keeps a low id range for seeded rows. Refused without @generation "increment" (ERR_SEQUENCE_WITHOUT_INCREMENT). RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1.

    Allowed children

    No structural children.

    diff --git a/site-reference/types/validator.html b/site-reference/types/validator.html index 50f22a081d..8f9383a3a8 100644 --- a/site-reference/types/validator.html +++ b/site-reference/types/validator.html @@ -107,6 +107,24 @@

    validator.atLeastOne

    @conditionexpressionnometaobjects-core-typesThe case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1.
    @constraintNamestringnometaobjects-dbThe database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived _chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. + + @@ -174,6 +192,24 @@

    validator.comparison

    + + + + + + + + + + + + + + + +
    @fields string[] yes
    @conditionexpressionnometaobjects-core-typesThe case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1.
    @constraintNamestringnometaobjects-dbThe database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived _chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. + + @@ -203,6 +239,54 @@

    validator.comparison

    @left string yes

    Allowed children

    No structural children.

    +

    validator.exactlyOne

    +

    Cardinality of presence: exactly one of the named fields (@fields) is present (NOT NULL), and the others are null. Entity-scoped; references fields by name (same @fields-by-name pattern as identity.*). Justified in ADR-0023 Amendment 1.

    +

    Owning provider: metaobjects-core-types

    +

    Rules: @fields names two or more fields of the owning entity (ERR_BAD_ATTR_VALUE): one field would just be a required field. Satisfied when exactly one of them is non-null.

    +

    When to use: A row belongs to exactly one of several alternatives (a store or a kiosk, never both, never neither). Declare it instead of hand-written count-of-non-nulls checks.

    +

    Attributes

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    AttributeTypeRequiredDefaultAllowed valuesProviderDescription
    @conditionexpressionnometaobjects-core-typesThe case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1.
    @constraintNamestringnometaobjects-dbThe database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived _chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. + + + + + + + + + + +
    @fieldsstring[]yesmetaobjects-core-typesNames of the alternative fields; exactly one must be present.
    +

    Allowed children

    +

    No structural children.

    validator.length

    Bounds string length / collection size via @min/@max.

    Owning provider: metaobjects-core-types

    @@ -221,6 +305,15 @@

    validator.length

    @constraintNamestringnometaobjects-dbThe database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived _chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. + + @@ -259,6 +352,15 @@

    validator.numeric

    + + + + + + +
    @max int no
    @constraintNamestringnometaobjects-dbThe database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived _chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. + + @@ -298,6 +400,24 @@

    validator.presentIff

    + + + + + + + + + + + + + + + +
    @max int no
    @conditionexpressionnometaobjects-core-typesThe case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1.
    @constraintNamestringnometaobjects-dbThe database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived _chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. + + @@ -345,6 +465,15 @@

    validator.regex

    + + + + + + +
    @equals string yes
    @constraintNamestringnometaobjects-dbThe database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived _chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. + + @@ -401,6 +530,24 @@

    validator.requiredWhen

    + + + + + + + + + + + + + + + +
    @max int no
    @conditionexpressionnometaobjects-core-typesThe case this rule applies in: an expression over the owning entity's fields (the attr.expression grammar). Absent: always. The database CHECK is NOT (condition) OR (rule), so a row the condition does not hold for, or holds NULL for, is not checked. Enforced like the rule itself: by the database CHECK and, on create, by the generated record model in every port. Justified in ADR-0023 Amendment 1.
    @constraintNamestringnometaobjects-dbThe database name of the CHECK constraint this validator generates (numeric, length, regex and the cross-field validators; the others generate no CHECK). Absent: the derived _chk. Lets a model adopt a database whose CHECKs were named by hand. RDB-physical — contributed by the db provider. Justified in ADR-0023 Amendment 1. + + diff --git a/tools/prerelease/prerelease-link.sh b/tools/prerelease/prerelease-link.sh index 9e19558d37..a6e74a61a1 100755 --- a/tools/prerelease/prerelease-link.sh +++ b/tools/prerelease/prerelease-link.sh @@ -176,11 +176,19 @@ ends_in_newline() { # ends_in_newline # it afterward (a plain `&&`/`||` can't survive being stored and read back later). had_newline() { ends_in_newline "$1" && echo 1 || echo 0; } +# In-place extended-regex sed that works on GNU and BSD (macOS) sed alike. Both accept the +# backup suffix only ATTACHED to -i (-i.suffix); a bare `-i -E` makes BSD sed take "-E" as the +# suffix, leaving -E behind and running the expression as a basic regex, whose ( ) groups +# do not capture. Neither sed adds a missing trailing newline here (verified on both). +sed_inplace() { # sed_inplace + sed -E -i.mo-sed-bak "$1" "$2" && rm -f "$2.mo-sed-bak" +} + # Every in-place rewrite in this script writes to a tmp file and moves it over the original. # awk's `print` unconditionally terminates the last line with a newline, even when the file # being rewritten had none — so a no-op "link, then unlink" round trip left every pom.xml that # lacked a trailing newline with one, which `git status` then reported as modified. sed -# does not have this problem (verified: `sed -i` preserves a missing trailing newline whether +# does not have this problem (verified: `sed_inplace` above preserves a missing trailing newline whether # or not anything matched), so only the awk call sites route through this. is # `had_newline` captured on immediately before the awk call that produced ; a # caller that already knows nothing needs preserving can pass "1", which is a no-op here. @@ -443,7 +451,7 @@ repin_py() { # repin_py while IFS= read -r root; do for f in "$root/pyproject.toml" "$root"/requirements*.txt "$root"/constraints*.txt; do [ -f "$f" ] || continue - sed -i -E "s/(${PY_DIST}[[:space:]]*)(==|>=|~=)[[:space:]]*[0-9][^\"',[:space:]]*/\\1==${pv}/g" "$f" + sed_inplace "s/(${PY_DIST}[[:space:]]*)(==|>=|~=)[[:space:]]*[0-9][^\"',[:space:]]*/\\1==${pv}/g" "$f" done done < <( { echo "$PROJECT"; py_roots; } | sort -u) say "repinned $PY_DIST to $pv" @@ -452,7 +460,7 @@ repin_py() { # repin_py repin_nuget() { # repin_nuget local v="$1"; [ -n "$v" ] || return 0 while IFS= read -r f; do - sed -i -E "s|(]*Include=\"${NUGET_PREFIX}[^\"]*\"[^>]*Version=\")[^\"]*(\")|\\1${v}\\2|g" "$f" + sed_inplace "s|(]*Include=\"${NUGET_PREFIX}[^\"]*\"[^>]*Version=\")[^\"]*(\")|\\1${v}\\2|g" "$f" done < <(project_find \( -name '*.csproj' -o -name '*.fsproj' -o -name 'Directory.Packages.props' \) \ -not -path '*/obj/*' -not -path '*/bin/*') say "repinned ${NUGET_PREFIX}* package references to $v" @@ -486,7 +494,7 @@ repin_pom() { # repin_pom if grep -q '' "$pom"; then local before after before="$(cat "$pom")" - sed -i -E "s|()[^<]*()|\\1${mv}\\2|g" "$pom" + sed_inplace "s|()[^<]*()|\\1${mv}\\2|g" "$pom" after="$(cat "$pom")" # Only when the property's value actually changed — a second `link` at the same version # (or a pom already pinned to $mv) must not claim a repin that did not happen. diff --git a/tools/prerelease/test-prerelease-link.sh b/tools/prerelease/test-prerelease-link.sh index 040b5dbfbc..c0578b6cc0 100755 --- a/tools/prerelease/test-prerelease-link.sh +++ b/tools/prerelease/test-prerelease-link.sh @@ -329,6 +329,8 @@ check "a literal dependency version is repinned" 'grep -q "8.1.0-rc.90.61.0" "$poly/ports/kotlin/pom.xml" && grep -q "2.0.21" "$poly/ports/kotlin/pom.xml"' check "every metaobjects version in the pom is repinned, a plugin's after its configuration included" '[ "$(grep -c "8.1.0-rc.9" "$poly/ports/kotlin/pom.xml")" = 3 ] && ! grep -q "8.0.9" "$poly/ports/kotlin/pom.xml"' check "the package reference is repinned" 'grep -q "Version=\"1.1.0-rc.9\"" "$poly/ports/csharp/App.csproj"' +check "the in-place repins leave no sed backup files (BSD sed reads a bare -i -E as suffix -E)" \ + '[ -z "$(find "$poly" \( -name "*-E" -o -name "*.mo-sed-bak" \) -print)" ]' check "the local tool manifest is repinned" 'grep -q "\"metaobjects.cli\": { \"version\": \"1.1.0-rc.9\"" "$poly/.config/dotnet-tools.json"' check "another tool in the manifest is left alone" 'grep -q "\"dotnet-ef\": { \"version\": \"8.0.0\"" "$poly/.config/dotnet-tools.json"' # R3: a second `link` at the same version changes no pom, so it reports no repin, a pom pinned @@ -372,7 +374,7 @@ printf '%s' ' ' > "$mvnproj/app/pom.xml" check "the fixture poms have no trailing newline" \ - '[ "$(tail -c1 "$mvnproj/pom.xml" | wc -l)" = 0 ] && [ "$(tail -c1 "$mvnproj/app/pom.xml" | wc -l)" = 0 ]' + '[ "$(tail -c1 "$mvnproj/pom.xml" | wc -l | tr -d " ")" = 0 ] && [ "$(tail -c1 "$mvnproj/app/pom.xml" | wc -l | tr -d " ")" = 0 ]' git init -q "$mvnproj" git -C "$mvnproj" add -A && git -C "$mvnproj" commit -q -m init cp "$mvnproj/pom.xml" "$WORK/pom.xml.orig" From 1725b06d87b07d42baa1e56b2925f3ba15de7cfd Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 06:40:16 -0500 Subject: [PATCH 05/12] no-mistakes(review): apply reviewer inline fixes --- spec/decisions/ADR-0023-strict-metadata-provenance.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/decisions/ADR-0023-strict-metadata-provenance.md b/spec/decisions/ADR-0023-strict-metadata-provenance.md index 2f43b0bf2b..bf4b55718b 100644 --- a/spec/decisions/ADR-0023-strict-metadata-provenance.md +++ b/spec/decisions/ADR-0023-strict-metadata-provenance.md @@ -74,7 +74,8 @@ so a model that declares none of them emits what it always did. `field.enum`). The derived name (`
    @equals string yes
    ___chk`) stays the default. The declared one is a name an adopter chose: a database whose CHECKs were named by hand, or a program that compares a violation against the constraint name. A chosen name cannot be derived. -- **`@condition`** (on the validators that generate a CHECK). A rule that holds only in some case +- **`@condition`** (on the cross-field validators: `comparison`, `requiredWhen`, `presentIff`, + `atLeastOne`, `exactlyOne`). A rule that holds only in some case ("a refund needs a reason") is a guard the author states; no other metadata says when a rule applies. It uses the existing `attr.expression` grammar, so no new expression language comes with it. From 66b4067c5b67a6da771344b8f12ff28856ba23e0 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 06:41:59 -0500 Subject: [PATCH 06/12] no-mistakes(review): Refit FKs under their renamed names during key demotion --- .../packages/migrate-ts/src/diff/index.ts | 14 ++++++++++---- .../test/unit/diff-unique-key-kind.test.ts | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/server/typescript/packages/migrate-ts/src/diff/index.ts b/server/typescript/packages/migrate-ts/src/diff/index.ts index d90771fa3f..bdd995519d 100644 --- a/server/typescript/packages/migrate-ts/src/diff/index.ts +++ b/server/typescript/packages/migrate-ts/src/diff/index.ts @@ -284,6 +284,7 @@ export async function diff( // Pass 2: tables in both → compare columns/indexes/FKs const demotedKeys: { table: TableDescriptor; columns: readonly string[] }[] = []; + const renamedConstraints = new Map>(); for (const [id, expectedTable] of expectedTables) { const actualTable = actualTables.get(id); if (!actualTable) continue; @@ -305,6 +306,7 @@ export async function diff( // demoted, in one migration. The rename runs first, so the demotion uses the new name. const { promotions, demotions } = uniqueKindChanges( expectedTable, withRenamedIndexes(actualTable, [...constraintRenames, ...indexRenames]), canon); + if (constraintRenames.length > 0) renamedConstraints.set(id, new Map(constraintRenames.map((r) => [r.from, r.to]))); if (constraintRenames.length > 0 || indexRenames.length > 0 || promotions.length > 0) { changes.push({ kind: "rename-constraints", table: expectedTable.name, ...schemaSpread(expectedTable.schema), @@ -324,7 +326,7 @@ export async function diff( } // A demoted UNIQUE constraint is dropped, and Postgres refuses that while a foreign key // relies on it, so each such FK is dropped first and added back once the index exists. - if (demotedKeys.length > 0) refitForeignKeysOnto(demotedKeys, actualTables, expectedTables, changes); + if (demotedKeys.length > 0) refitForeignKeysOnto(demotedKeys, actualTables, expectedTables, renamedConstraints, changes); // Pass 2b: views. Identity is (schema, name). How "changed" is decided is // DIALECT-DEPENDENT — see diffViews. @@ -820,13 +822,15 @@ function uniqueKindChanges( /** * Drop and re-add every live foreign key that targets a demoted key's columns, unless the - * diff already drops it. The re-added FK is the expected one when the model still declares + * diff already drops it. An FK the same migration renames is dropped under its new name, since + * the rename runs first. The re-added FK is the expected one when the model still declares * it, else the live one. */ function refitForeignKeysOnto( keys: readonly { table: TableDescriptor; columns: readonly string[] }[], actualTables: ReadonlyMap, expectedTables: ReadonlyMap, + renamedConstraints: ReadonlyMap>, changes: Change[], ): void { const dropped = new Set(changes.flatMap((c) => @@ -838,8 +842,10 @@ function refitForeignKeysOnto( && fk.refColumns.length === k.columns.length && fk.refColumns.every((c, i) => c === k.columns[i])); if (target === undefined || dropped.has(`${id}\u0000${fk.name}`)) continue; const sx = schemaSpread(t.schema); - const want = expectedTables.get(id)?.foreignKeys.find((f) => f.name === fk.name) ?? fk; - changes.push({ kind: "drop-fk", table: t.name, ...sx, fk: fk.name, restore: fk, status: ALLOWED }); + const name = renamedConstraints.get(id)?.get(fk.name) ?? fk.name; + const live = name === fk.name ? fk : { ...fk, name }; + const want = expectedTables.get(id)?.foreignKeys.find((f) => f.name === name) ?? live; + changes.push({ kind: "drop-fk", table: t.name, ...sx, fk: name, restore: live, status: ALLOWED }); if (!changes.some((c) => c.kind === "add-fk" && idOfChange(c) === id && c.fk.name === want.name)) { changes.push({ kind: "add-fk", table: t.name, ...sx, fk: want, status: ALLOWED }); } diff --git a/server/typescript/packages/migrate-ts/test/unit/diff-unique-key-kind.test.ts b/server/typescript/packages/migrate-ts/test/unit/diff-unique-key-kind.test.ts index 6f03d888b6..624b2acda8 100644 --- a/server/typescript/packages/migrate-ts/test/unit/diff-unique-key-kind.test.ts +++ b/server/typescript/packages/migrate-ts/test/unit/diff-unique-key-kind.test.ts @@ -92,6 +92,23 @@ describe("a unique key that changes only between index and constraint", () => { expect(at("CREATE UNIQUE INDEX")).toBeLessThan(at("ADD CONSTRAINT \"FK_")); }); + test("constraint to index while the FK onto it is renamed: the refit drops and re-adds it under the new name", async () => { + const { asIndex, asConstraint } = await schemas(); + const ledgers = (s: SchemaSnapshot) => s.tables.find((t) => t.name === "Ledgers")!; + const wanted = ledgers(asIndex).foreignKeys[0]!.name; + ledgers(asConstraint).foreignKeys[0]!.name = "ledgers_account_fkey"; + const { changes } = await diff(asIndex, asConstraint, { dialect: "postgres", allow: { dropIndex: true, dropFk: true } }); + const rename = changes.find((c) => c.kind === "rename-constraints" && c.table === "Ledgers"); + expect(rename?.kind === "rename-constraints" ? rename.constraintRenames : undefined) + .toEqual([{ from: "ledgers_account_fkey", to: wanted }]); + expect(changes.flatMap((c) => (c.kind === "drop-fk" ? [c.fk] : []))).toEqual([wanted]); + expect(changes.flatMap((c) => (c.kind === "add-fk" ? [c.fk.name] : []))).toEqual([wanted]); + const { up, down } = emit(changes, { dialect: "postgres" }); + expect(up).not.toContain("DROP CONSTRAINT IF EXISTS \"ledgers_account_fkey\""); + expect(up.indexOf("RENAME CONSTRAINT \"ledgers_account_fkey\"")).toBeLessThan(up.indexOf(`DROP CONSTRAINT IF EXISTS "${wanted}"`)); + expect(down.indexOf(`ADD CONSTRAINT "${wanted}"`)).toBeLessThan(down.indexOf(`RENAME CONSTRAINT "${wanted}" TO "ledgers_account_fkey"`)); + }); + test("no preference (the default conventions): a live constraint is left as it is", async () => { const { asEither, asConstraint } = await schemas(); const { changes } = await diff(asEither, asConstraint, { dialect: "postgres" }); From ffa854e1ee0b48bb3efaf2838d928c2ef56b316c Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 12:50:00 -0500 Subject: [PATCH 07/12] no-mistakes(review): apply reviewer inline fixes --- server/csharp/MetaObjects/Loader/ValidationPasses.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs index 963fe6dce7..d175914b8a 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs @@ -4715,7 +4715,8 @@ void Walk(MetaData node) // compared (PhysicalNaming.ResolveColumnName), so an explicit @column, an inherited // field and the project's column strategy are honoured. Scope: a concrete object // declaring at least one source (a source-less object generates nothing); derived - // fields and field.object (may flatten to several columns) are skipped. + // fields are skipped. A flattened field.object contributes one column per member of its + // value object (PhysicalColumnsOf below); any other field.object is one jsonb column. // ADR-0039: effective children, so an inherited field collides like an own one. // Exempt: an object whose every source is read-only and at least one is a // source.rdb @kind: view with an @sql body. The author's SQL defines that view's columns From e07700182aef0dc5f4a1284764e69bd4a0895361 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 12:56:01 -0500 Subject: [PATCH 08/12] no-mistakes(review): Refuse non-ISO duration literals in conditions and @equals at load --- docs/CONFORMANCE.md | 2 +- .../expected-errors.json | 45 +++++++++++++ .../input/meta.timers.json | 65 +++++++++++++++++++ .../MetaObjects/Loader/ValidationPasses.cs | 30 ++++++++- .../metaobjects/loader/ValidationPhase.java | 31 ++++++++- .../metaobjects/loader/validation_passes.py | 30 ++++++++- .../metadata/src/loader/validation-passes.ts | 32 ++++++++- 7 files changed, 227 insertions(+), 8 deletions(-) create mode 100644 fixtures/conformance/error-validator-condition-duration-literal/expected-errors.json create mode 100644 fixtures/conformance/error-validator-condition-duration-literal/input/meta.timers.json diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 8695cca7ce..56eeee277a 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -26,7 +26,7 @@ regenerate with `ls -d fixtures//*/ | wc -l` for directory-shaped corpor | Corpus | Fixtures | TS | Java | Kotlin | C# | Python | |---|---|---|---|---|---|---| -| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 448 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | +| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 449 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | | [`fixtures/yaml-conformance/`](../fixtures/yaml-conformance/) | 16 | 16 / 16 | 15 / 16 (1 ledgered: `yaml-quoted-leading-zero` — Java pipeline strips quotes off `"007"`) | inherits via Java | 15 / 16 (1 ledgered: `error-yaml-coerced-hex-in-string` — YamlDotNet doesn't coerce `0xFF`) | 16 / 16 | | [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 45 | ✓ | ✓ | inherits via Java | ✓ | ✓ | | [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ | diff --git a/fixtures/conformance/error-validator-condition-duration-literal/expected-errors.json b/fixtures/conformance/error-validator-condition-duration-literal/expected-errors.json new file mode 100644 index 0000000000..4b21bda427 --- /dev/null +++ b/fixtures/conformance/error-validator-condition-duration-literal/expected-errors.json @@ -0,0 +1,45 @@ +{ + "errors": [ + { + "code": "ERR_BAD_ATTR_VALUE", + "source": { + "format": "json", + "files": [ + "meta.timers.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[6]['validator.atLeastOne']" + } + }, + { + "code": "ERR_BAD_ATTR_VALUE", + "source": { + "format": "json", + "files": [ + "meta.timers.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[8]['validator.atLeastOne']" + } + }, + { + "code": "ERR_BAD_ATTR_VALUE", + "source": { + "format": "json", + "files": [ + "meta.timers.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[9]['validator.requiredWhen']" + } + }, + { + "code": "ERR_BAD_ATTR_VALUE", + "source": { + "format": "json", + "files": [ + "meta.timers.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[10]['validator.presentIff']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-validator-condition-duration-literal/input/meta.timers.json b/fixtures/conformance/error-validator-condition-duration-literal/input/meta.timers.json new file mode 100644 index 0000000000..001f62c8c7 --- /dev/null +++ b/fixtures/conformance/error-validator-condition-duration-literal/input/meta.timers.json @@ -0,0 +1,65 @@ +{ + "metadata.root": { + "package": "acme", + "children": [ + { + "object.entity": { + "name": "Timer", + "children": [ + { "source.rdb": {} }, + { "field.long": { "name": "id" } }, + { "field.long": { "name": "storeId" } }, + { "field.long": { "name": "kioskId" } }, + { "field.duration": { "name": "timeout" } }, + { "identity.primary": { "name": "pk", "@fields": "id" } }, + { + "validator.atLeastOne": { + "name": "conditionNotIso", + "@fields": ["storeId", "kioskId"], + "@condition": { "op": "gt", "left": { "field": "timeout" }, "right": { "value": "1 hour" } } + } + }, + { + "validator.atLeastOne": { + "name": "conditionIso", + "@fields": ["storeId", "kioskId"], + "@condition": { "op": "gte", "left": { "field": "timeout" }, "right": { "value": "P1DT2H" } } + } + }, + { + "validator.atLeastOne": { + "name": "conditionLiteralLeft", + "@fields": ["storeId", "kioskId"], + "@condition": { "op": "lt", "left": { "value": "PT" }, "right": { "field": "timeout" } } + } + }, + { + "validator.requiredWhen": { + "name": "requiredWhenNotIso", + "@field": "storeId", + "@when": "timeout", + "@equals": "90 minutes" + } + }, + { + "validator.presentIff": { + "name": "presentIffMonths", + "@field": "kioskId", + "@when": "timeout", + "@equals": "P1M" + } + }, + { + "validator.requiredWhen": { + "name": "requiredWhenIso", + "@field": "storeId", + "@when": "timeout", + "@equals": "-PT1M30S" + } + } + ] + } + } + ] + } +} diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs index d175914b8a..ba9f66003a 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs @@ -2094,6 +2094,7 @@ private static void ValidateAttrSchemaNode( // Cross-field validators (comparison / requiredWhen / presentIff / atLeastOne / exactlyOne), // which the database enforces as CHECKs (ERR_BAD_ATTR_VALUE): exactlyOne @fields names two or // more fields (one is just a required field); every field a @condition names is a field of the owning entity; + // a requiredWhen / presentIff @equals gating a field.duration is an ISO-8601 duration; // and the @condition can become a CHECK guard (ConditionProblem). Read RESOLVING // (ADR-0039). Mirrors TS validateCrossFieldValidators. // --------------------------------------------------------------------------- @@ -2130,6 +2131,21 @@ public static IReadOnlyList ValidateCrossFieldValidators(MetaData roo continue; } } + if (v.SubType == VALIDATOR_SUBTYPE_REQUIRED_WHEN || v.SubType == VALIDATOR_SUBTYPE_PRESENT_IFF) + { + var whenName = v.Attr(VALIDATOR_ATTR_WHEN) as string; + var when = obj.Fields().FirstOrDefault(f => f.Name == whenName); + var equals = v.Attr(VALIDATOR_ATTR_EQUALS); + if (when?.SubType == FIELD_SUBTYPE_DURATION && !(equals is string e && DurationLiteral.IsMatch(e))) + { + errors.Add(new MetaError( + $"validator.{v.SubType} \"{obj.Name}.{v.Name}\" @equals {JsonStringify(equals)} gates field.duration " + + $"\"{when.Name}\"; it needs {DurationLiteralForm}.", + ErrorCode.ERR_BAD_ATTR_VALUE, + Envelope: v.Source)); + continue; + } + } var condition = v.Attr(VALIDATOR_ATTR_CONDITION); if (condition is null) continue; var label = $"validator.{v.SubType} \"{obj.Name}.{v.Name}\" @condition"; @@ -2154,6 +2170,13 @@ public static IReadOnlyList ValidateCrossFieldValidators(MetaData roo return errors; } + /// The accepted ISO-8601 duration form, the same pattern as TS DURATION_PATTERN. + private static readonly Regex DurationLiteral = + new(@"^[-+]?P(?!$)([0-9]+W)?([0-9]+D)?(T(?=[0-9])([0-9]+H)?([0-9]+M)?([0-9]+(\.[0-9]+)?S)?)?$", RegexOptions.CultureInvariant); + + /// What a literal compared with a field.duration must be. + private const string DurationLiteralForm = "an ISO-8601 duration (such as PT1H30M; no years or months)"; + /// Field subtypes whose values a condition compares with a number literal. private static readonly HashSet ConditionNumberSubtypes = new(StringComparer.Ordinal) { @@ -2167,7 +2190,7 @@ public static IReadOnlyList ValidateCrossFieldValidators(MetaData roo /// it types cleanly (the origin.computed inference) and its root is boolean, since a CHECK /// negates it; a literal compared with a field is of that field's kind (a boolean for /// field.boolean, a number for a numeric field, a string otherwise) and, for field.enum, one - /// of its @values; and no comparison has a null literal (x = NULL is never true, so + /// of its @values, for field.duration, an ISO-8601 duration; and no comparison has a null literal (x = NULL is never true, so /// use isNull / isNotNull). Mirrors TS conditionProblem. /// private static string? ConditionProblem(object condition, MetaObject obj) @@ -2221,6 +2244,11 @@ void Visit(object? node) problem = $"compares \"{field.Name}\" with {JsonStringify(value)}, which is not one of its @values ({string.Join(", ", members)})."; return; } + if (field.SubType == FIELD_SUBTYPE_DURATION && !DurationLiteral.IsMatch((string)value)) + { + problem = $"compares \"{field.Name}\" with {JsonStringify(value)}, which is not {DurationLiteralForm}."; + return; + } } } n.TryGetValue("left", out var l); diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java index 521602f4a8..3b6d30c504 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java @@ -26,6 +26,8 @@ import com.metaobjects.field.DateField; import com.metaobjects.field.DecimalField; import com.metaobjects.field.DoubleField; +import com.metaobjects.field.DurationField; +import com.metaobjects.field.DurationWire; import com.metaobjects.field.EnumField; import com.metaobjects.field.FloatField; import com.metaobjects.field.IntegerField; @@ -2391,7 +2393,8 @@ private static List junctionReferenceFkFields(MetaObject junction) { // ========================================================================= // Cross-field validators (comparison / requiredWhen / presentIff / atLeastOne / exactlyOne), // which the database enforces as CHECKs: exactlyOne names two or more @fields; - // every field a @condition names is a field of the owning entity; and the @condition can + // every field a @condition names is a field of the owning entity; a requiredWhen / + // presentIff @equals gating a field.duration is an ISO-8601 duration; and the @condition can // become a CHECK guard (see conditionProblem). ERR_BAD_ATTR_VALUE, one per validator, // every violation collected. Mirrors TS validateCrossFieldValidators. // ========================================================================= @@ -2422,6 +2425,23 @@ static void validateCrossFieldValidators(MetaRoot root, List continue; } } + if (com.metaobjects.validator.RequiredWhenValidator.SUBTYPE_REQUIRED_WHEN.equals(v.getSubType()) + || com.metaobjects.validator.PresentIffValidator.SUBTYPE_PRESENT_IFF.equals(v.getSubType())) { + Object whenName = v.hasMetaAttr(com.metaobjects.validator.RequiredWhenValidator.ATTR_WHEN) + ? v.getMetaAttr(com.metaobjects.validator.RequiredWhenValidator.ATTR_WHEN).getValue() : null; + MetaField when = whenName instanceof String w ? fields.get(w) : null; + Object equals = v.hasMetaAttr(com.metaobjects.validator.RequiredWhenValidator.ATTR_EQUALS) + ? v.getMetaAttr(com.metaobjects.validator.RequiredWhenValidator.ATTR_EQUALS).getValue() : null; + if (when != null && DurationField.SUBTYPE_DURATION.equals(when.getSubType()) + && !(equals instanceof String e && DurationWire.ACCEPTED.matcher(e).matches())) { + out.add(new MetaDataException( + ErrorMessageConstants.ERR_BAD_ATTR_VALUE + ": validator." + label + " @equals " + + conditionJsonLiteral(equals) + " gates field.duration \"" + when.getShortName() + + "\"; it needs " + DURATION_LITERAL_FORM + ".", + ErrorCode.ERR_BAD_ATTR_VALUE, v.getSource())); + continue; + } + } if (!v.hasMetaAttr(com.metaobjects.validator.MetaValidator.ATTR_CONDITION)) continue; Object condition = v.getMetaAttr(com.metaobjects.validator.MetaValidator.ATTR_CONDITION).getValue(); java.util.TreeSet unknown = new java.util.TreeSet<>(); @@ -2447,6 +2467,9 @@ static void validateCrossFieldValidators(MetaRoot root, List } } + /** What a literal compared with a field.duration must be. */ + private static final String DURATION_LITERAL_FORM = "an ISO-8601 duration (such as PT1H30M; no years or months)"; + /** Field subtypes whose values a condition compares with a number literal. */ private static final java.util.Set CONDITION_NUMBER_SUBTYPES = java.util.Set.of( IntegerField.SUBTYPE_INT, LongField.SUBTYPE_LONG, DoubleField.SUBTYPE_DOUBLE, @@ -2460,7 +2483,7 @@ static void validateCrossFieldValidators(MetaRoot root, List * CHECK negates it; * - a literal compared with a field is of that field's kind (a boolean for field.boolean, * a number for a numeric field, a string otherwise) and, for field.enum, one of its - * @values; + * @values, for field.duration, an ISO-8601 duration; * - no comparison has a null literal: {@code x = NULL} is never true, so use isNull / * isNotNull. * Mirrors TS conditionProblem. @@ -2514,6 +2537,10 @@ private static String conditionLiteralProblem(Object node, java.util.Map None # Cross-field validators (comparison / requiredWhen / presentIff / atLeastOne / exactlyOne), which # the database enforces as CHECKs: exactlyOne @fields names two or more fields, -# every field a @condition names a field of the owning entity, and a @condition that can +# every field a @condition names a field of the owning entity, a requiredWhen / presentIff +# @equals gating a field.duration an ISO-8601 duration, and a @condition that can # become a CHECK guard (see _condition_problem) — all ERR_BAD_ATTR_VALUE. Read RESOLVING # (ADR-0039). Mirrors TS validateCrossFieldValidators. def _expr_field_refs(node: object, out: set[str]) -> None: @@ -3817,6 +3819,9 @@ def _expr_field_refs(node: object, out: set[str]) -> None: _expr_field_refs(a, out) +# What a literal compared with a field.duration must be. +_DURATION_LITERAL_FORM = "an ISO-8601 duration (such as PT1H30M; no years or months)" + # Field subtypes whose values a condition compares with a number literal. _CONDITION_NUMBER_SUBTYPES = frozenset({ FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, @@ -3847,7 +3852,7 @@ def _condition_problem(condition: object, obj: MetaData) -> str | None: CHECK negates it; - a literal compared with a field is of that field's kind (a boolean for field.boolean, a number for a numeric field, a string otherwise) and, for field.enum, one of its - @values; + @values, for field.duration, an ISO-8601 duration; - no comparison has a null literal: ``x = NULL`` is never true, so use isNull / isNotNull. Mirrors TS conditionProblem.""" fields = {f.name: f for f in obj.fields()} @@ -3893,6 +3898,11 @@ def visit(node: object) -> str | None: f'compares "{field.name}" with {_json_literal(value)}, which is not one ' f"of its @values ({', '.join(str(m) for m in members)})." ) + if sub == FIELD_SUBTYPE_DURATION and DURATION_RE.fullmatch(value) is None: + return ( + f'compares "{field.name}" with {_json_literal(value)}, which is not ' + f"{_DURATION_LITERAL_FORM}." + ) for key in ("left", "right", "arg"): found = visit(node.get(key)) if found is not None: @@ -3932,6 +3942,22 @@ def _validate_cross_field_validators(root: MetaData, errors: list[MetaError]) -> envelope=v.source, )) continue + if v.sub_type in (vc.VALIDATOR_SUBTYPE_REQUIRED_WHEN, vc.VALIDATOR_SUBTYPE_PRESENT_IFF): + when_name = v.get_meta_attr(vc.VALIDATOR_ATTR_WHEN) + when = next((f for f in obj.fields() if f.name == when_name), None) + equals = v.get_meta_attr(vc.VALIDATOR_ATTR_EQUALS) + if when is not None and when.sub_type == FIELD_SUBTYPE_DURATION and not ( + isinstance(equals, str) and DURATION_RE.fullmatch(equals) + ): + errors.append(MetaError( + code=ErrorCode.ERR_BAD_ATTR_VALUE, + message=( + f'validator.{v.sub_type} "{obj.name}.{v.name}" @equals {_json_literal(equals)} ' + f'gates field.duration "{when.name}"; it needs {_DURATION_LITERAL_FORM}.' + ), + envelope=v.source, + )) + continue condition = v.get_meta_attr(vc.VALIDATOR_ATTR_CONDITION) if condition is None: continue diff --git a/server/typescript/packages/metadata/src/loader/validation-passes.ts b/server/typescript/packages/metadata/src/loader/validation-passes.ts index e3b328a128..b11b5dbf30 100644 --- a/server/typescript/packages/metadata/src/loader/validation-passes.ts +++ b/server/typescript/packages/metadata/src/loader/validation-passes.ts @@ -14,9 +14,14 @@ import { TYPE_VALIDATOR } from "../shared/base-types.js"; import { CROSS_FIELD_VALIDATOR_SUBTYPES, VALIDATOR_ATTR_CONDITION, + VALIDATOR_ATTR_EQUALS, VALIDATOR_ATTR_FIELDS, + VALIDATOR_ATTR_WHEN, VALIDATOR_SUBTYPE_EXACTLY_ONE, + VALIDATOR_SUBTYPE_PRESENT_IFF, + VALIDATOR_SUBTYPE_REQUIRED_WHEN, } from "../core/validator/validator-constants.js"; +import { parseDurationLiteral } from "../core/field/duration-wire.js"; import type { MetaData } from "../shared/meta-data.js"; import type { MetaObject } from "../core/object/meta-object.js"; import type { MetaReferenceIdentity } from "../core/identity/meta-identity.js"; @@ -96,6 +101,7 @@ import { FIELD_SUBTYPE_TIME, FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_UUID, + FIELD_SUBTYPE_DURATION, } from "../core/field/field-constants.js"; import { FIELD_ATTR_COLUMN, FIELD_ATTR_FLATTENED_COLUMN_PREFIX, FIELD_ATTR_DB_INDEXED, IDENTITY_ATTR_EXPR, IDENTITY_ATTR_SEQUENCE_NAME, IDENTITY_ATTR_SEQUENCE_START } from "../persistence/db/db-constants.js"; import { SOURCE_KIND_VIEW } from "../persistence/source/source-constants.js"; @@ -1692,7 +1698,9 @@ export function validateDerivedFieldProvidability(root: MetaData): ParseError[] // which the database enforces as CHECKs: // - exactlyOne @fields names two or more fields (ERR_BAD_ATTR_VALUE): one is a required field; // - every field a @condition expression names is a field of the owning entity -// (ERR_BAD_ATTR_VALUE), so the CHECK it renders to references a real column. +// (ERR_BAD_ATTR_VALUE), so the CHECK it renders to references a real column; +// - a requiredWhen / presentIff @equals gating a field.duration is an ISO-8601 duration +// (ERR_BAD_ATTR_VALUE), the form its CHECK literal is built from. // Read RESOLVING (ADR-0039): an entity's validators and fields include inherited ones. // --------------------------------------------------------------------------- @@ -1706,6 +1714,9 @@ function expressionFieldRefs(node: unknown, out: string[] = []): string[] { return out; } +/** What a literal compared with a field.duration must be. */ +const DURATION_LITERAL_FORM = "an ISO-8601 duration (such as PT1H30M; no years or months)"; + /** Field subtypes whose values a condition compares with a number literal. */ const _CONDITION_NUMBER_SUBTYPES = new Set([ FIELD_SUBTYPE_INT, FIELD_SUBTYPE_LONG, FIELD_SUBTYPE_DOUBLE, FIELD_SUBTYPE_FLOAT, @@ -1718,7 +1729,8 @@ const _CONDITION_NUMBER_SUBTYPES = new Set([ * - it types cleanly (the origin.computed inference) and its root is boolean, since a CHECK * negates it; * - a literal compared with a field is of that field's kind (a boolean for field.boolean, a - * number for a numeric field, a string otherwise) and, for field.enum, one of its @values; + * number for a numeric field, a string otherwise) and, for field.enum, one of its @values, + * for field.duration, an ISO-8601 duration; * - no comparison has a null literal: `x = NULL` is never true, so use isNull / isNotNull. */ function conditionProblem(condition: unknown, obj: MetaObject): string | undefined { @@ -1755,6 +1767,10 @@ function conditionProblem(condition: unknown, obj: MetaObject): string | undefin problem = `compares "${field.name}" with ${JSON.stringify(value)}, which is not one of its @values (${members.join(", ")}).`; return; } + if (field.subType === FIELD_SUBTYPE_DURATION && parseDurationLiteral(value as string) === undefined) { + problem = `compares "${field.name}" with ${JSON.stringify(value)}, which is not ${DURATION_LITERAL_FORM}.`; + return; + } } } for (const key of ["left", "right", "arg"]) visit(n[key]); @@ -1782,6 +1798,18 @@ export function validateCrossFieldValidators(root: MetaData): ParseError[] { continue; } } + if (v.subType === VALIDATOR_SUBTYPE_REQUIRED_WHEN || v.subType === VALIDATOR_SUBTYPE_PRESENT_IFF) { + const when = (obj as MetaObject).fields().find((f) => f.name === v.attr(VALIDATOR_ATTR_WHEN)); + const equals = v.attr(VALIDATOR_ATTR_EQUALS); + if (when?.subType === FIELD_SUBTYPE_DURATION && (typeof equals !== "string" || parseDurationLiteral(equals) === undefined)) { + errors.push(new ParseError( + `validator.${v.subType} "${obj.name}.${v.name}" @equals ${JSON.stringify(equals)} gates field.duration ` + + `"${when.name}"; it needs ${DURATION_LITERAL_FORM}.`, + { code: "ERR_BAD_ATTR_VALUE", source: v.source }, + )); + continue; + } + } const condition = v.attr(VALIDATOR_ATTR_CONDITION); if (condition !== undefined) { const label = `validator.${v.subType} "${obj.name}.${v.name}" @condition`; From 08a3481bfcaf0d603c2b0945730494958800d3bd Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 12:57:46 -0500 Subject: [PATCH 09/12] no-mistakes(review): Anchor C# duration literal regex with \z --- .../expected-errors.json | 10 ++++++++++ .../input/meta.timers.json | 8 ++++++++ server/csharp/MetaObjects/Loader/ValidationPasses.cs | 2 +- 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/fixtures/conformance/error-validator-condition-duration-literal/expected-errors.json b/fixtures/conformance/error-validator-condition-duration-literal/expected-errors.json index 4b21bda427..33942fa7d5 100644 --- a/fixtures/conformance/error-validator-condition-duration-literal/expected-errors.json +++ b/fixtures/conformance/error-validator-condition-duration-literal/expected-errors.json @@ -39,6 +39,16 @@ ], "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[10]['validator.presentIff']" } + }, + { + "code": "ERR_BAD_ATTR_VALUE", + "source": { + "format": "json", + "files": [ + "meta.timers.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[11]['validator.requiredWhen']" + } } ], "warnings": [] diff --git a/fixtures/conformance/error-validator-condition-duration-literal/input/meta.timers.json b/fixtures/conformance/error-validator-condition-duration-literal/input/meta.timers.json index 001f62c8c7..e257fc7548 100644 --- a/fixtures/conformance/error-validator-condition-duration-literal/input/meta.timers.json +++ b/fixtures/conformance/error-validator-condition-duration-literal/input/meta.timers.json @@ -49,6 +49,14 @@ "@equals": "P1M" } }, + { + "validator.requiredWhen": { + "name": "requiredWhenTrailingNewline", + "@field": "storeId", + "@when": "timeout", + "@equals": "PT1H\n" + } + }, { "validator.requiredWhen": { "name": "requiredWhenIso", diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs index ba9f66003a..f1dc5d0483 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs @@ -2172,7 +2172,7 @@ public static IReadOnlyList ValidateCrossFieldValidators(MetaData roo /// The accepted ISO-8601 duration form, the same pattern as TS DURATION_PATTERN. private static readonly Regex DurationLiteral = - new(@"^[-+]?P(?!$)([0-9]+W)?([0-9]+D)?(T(?=[0-9])([0-9]+H)?([0-9]+M)?([0-9]+(\.[0-9]+)?S)?)?$", RegexOptions.CultureInvariant); + new(@"^[-+]?P(?!\z)([0-9]+W)?([0-9]+D)?(T(?=[0-9])([0-9]+H)?([0-9]+M)?([0-9]+(\.[0-9]+)?S)?)?\z", RegexOptions.CultureInvariant); /// What a literal compared with a field.duration must be. private const string DurationLiteralForm = "an ISO-8601 duration (such as PT1H30M; no years or months)"; From 3f43cdeb63e4e00db72af4325202c559e8539dd0 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 13:03:15 -0500 Subject: [PATCH 10/12] no-mistakes(review): Correct metamodel fixture count to 449 --- AGENTS.md | 2 +- docs/CONFORMANCE.md | 4 ++-- examples/showcase/site-payload.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e7ce2badf7..5f149be4de 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -71,7 +71,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken. - **Kotlin** — `codegen-kotlin` (KotlinPoet on JVM): entity + Exposed table + Spring controller + payload + relations + filter allowlist + validator + stored-proc + output-parser generators. `integration-tests-kotlin` runs the persistence-conformance corpus through Exposed against Testcontainers Postgres. **Cross-port conformance corpora** (every port runs the shared corpus): -- Metamodel: `fixtures/conformance/` (448 fixtures; 38 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. +- Metamodel: `fixtures/conformance/` (449 fixtures; 38 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. - Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical. - Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015; the FR-044 `report-*` scenarios also run on SQLite, MySQL and D1 where a port has the engine, see [docs/CONFORMANCE.md](docs/CONFORMANCE.md)). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".) - API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed. Three sub-corpora run the **generated lane only, on all five ports** — `write-through/`, `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`) and `report/` (FR-044: a view-backed `object.report` serves GET list, answers `POST` with that same `405`, and mounts no `/{id}` route). That is deliberate, not a gap: what is under test is whether a port's GENERATOR emits those routes, and a hand-rolled reference server would answer every scenario by construction. The `m2m/` sub-corpus also gates **TPH x M:N together** (base-declared, subtype-declared, abstract-mid-declared, a non-subtype source onto a subtype TARGET, and the cross-subtype source id answering `200 []`) — the two corpora were originally built disjoint (`tph/` had no relationships, `m2m/` no discriminators), which is precisely how that defect class survived. diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 56eeee277a..9ca93357ae 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -288,7 +288,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker. ## Fixture-to-doc mapping -### `fixtures/conformance/` — metamodel loader + canonical serializer (448) +### `fixtures/conformance/` — metamodel loader + canonical serializer (449) | Fixture prefix | Feature doc | |---|---| @@ -633,7 +633,7 @@ JSON and verify payload feeds it unchanged. ## Orphaned fixtures (tested but not yet documented) -The fixtures in the nine corpora mapped above (metamodel 448 + yaml 16 + verify 45 +The fixtures in the nine corpora mapped above (metamodel 449 + yaml 16 + verify 45 + render 18 + persistence 59 + api-contract 121 + source-resolution 25 + scope 10 + dependency 37) each map to a feature doc, and so do the two requirement corpora, `shared-model-conformance`, the `cube-model` corpus and `explorer-conformance`, whose case lists live in their own READMEs. None are orphaned today. The remaining diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index 231e25c150..e7d1564682 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -7,7 +7,7 @@ "metamodel": "1.3" }, "counts": { - "fixtures": 448, + "fixtures": 449, "corpora": 38, "baseTypes": 17 }, From 131f19bcf816f395d9c74b10ae7bbb15d0afc917 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 14:52:09 -0500 Subject: [PATCH 11/12] no-mistakes(test): Assert loader refuses non-ISO duration literal in migrate test --- .../test/unit/cross-field-check-extensions.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/server/typescript/packages/migrate-ts/test/unit/cross-field-check-extensions.test.ts b/server/typescript/packages/migrate-ts/test/unit/cross-field-check-extensions.test.ts index da0149d593..18cf951438 100644 --- a/server/typescript/packages/migrate-ts/test/unit/cross-field-check-extensions.test.ts +++ b/server/typescript/packages/migrate-ts/test/unit/cross-field-check-extensions.test.ts @@ -240,10 +240,16 @@ describe("a duration literal in a CHECK", () => { .toEqual([`(COALESCE("wait", "hold") <= '-00:01:00') OR ("a" < "b")`]); }); - test("a literal that is not a duration is refused when the CHECK is built", async () => { - const root = await load([...AB, { "field.duration": { name: "wait" } }, + test("a literal that is not a duration is refused at load, before any CHECK is built", async () => { + const doc = { "metadata.root": { package: "acme", children: [{ "object.entity": { name: "T", children: [ + { "source.rdb": { "@table": "t" } }, { "field.long": { name: "id" } }, + { "identity.primary": { name: "pk", "@fields": "id" } }, + ...AB, { "field.duration": { name: "wait" } }, { "validator.comparison": { name: "cmp", "@left": "a", "@op": "lt", "@right": "b", - "@condition": { op: "gt", left: { field: "wait" }, right: { value: "P1M" } } } }]); - expect(() => buildExpectedSchema(root, { dialect: "postgres" })).toThrow(/compares field.duration 'wait' with "P1M", which is not an ISO-8601 duration/); + "@condition": { op: "gt", left: { field: "wait" }, right: { value: "P1M" } } } }, + ] } }] } }; + const { errors } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(doc))]); + expect(errors.map((e) => [(e as { code?: string }).code, e.message])).toEqual([["ERR_BAD_ATTR_VALUE", + 'validator.comparison "T.cmp" @condition compares "wait" with "P1M", which is not an ISO-8601 duration (such as PT1H30M; no years or months).']]); }); }); From 02d4f916abd2d230ec41cb3ac2fda5e1e77a583d Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 4 Nov 2026 16:09:59 -0500 Subject: [PATCH 12/12] no-mistakes(document): Fixed stale duration wire docs and agent-context mirrors. The 449 fixture count was already correct. Unaudited: smaller CR docs. --- agent-context/skills/metaobjects-authoring/SKILL.md | 2 +- .../skills/metaobjects-codegen/references/wire-format.md | 5 ++++- .../expected/.claude/skills/metaobjects-authoring/SKILL.md | 2 +- .../skills/metaobjects-codegen/references/wire-format.md | 5 ++++- .../expected/.claude/skills/metaobjects-authoring/SKILL.md | 2 +- .../skills/metaobjects-codegen/references/wire-format.md | 5 ++++- .../expected/.claude/skills/metaobjects-authoring/SKILL.md | 2 +- .../skills/metaobjects-codegen/references/wire-format.md | 5 ++++- .../expected/.claude/skills/metaobjects-authoring/SKILL.md | 2 +- .../skills/metaobjects-codegen/references/wire-format.md | 5 ++++- .../expected/.claude/skills/metaobjects-authoring/SKILL.md | 2 +- .../skills/metaobjects-codegen/references/wire-format.md | 5 ++++- fixtures/duration-wire/README.md | 2 +- 13 files changed, 31 insertions(+), 13 deletions(-) diff --git a/agent-context/skills/metaobjects-authoring/SKILL.md b/agent-context/skills/metaobjects-authoring/SKILL.md index 555033850b..1979d161f2 100644 --- a/agent-context/skills/metaobjects-authoring/SKILL.md +++ b/agent-context/skills/metaobjects-authoring/SKILL.md @@ -245,7 +245,7 @@ Reach for these before inventing anything. When no row fits, run the decision pr | Open JSON, no reader narrows it | `field.string` + `@dbColumnType: jsonb` | the LAST rung — see "A JSON column" | | URL / URI | `field.uri` | native `URI`/`Uri`; `text` column; strict absolute-URI validation (add `@lenient: true` to store any string) — a real native type + behavior, so a subtype (not a validated string) | | IP address | `field.inet` | native IP type; Postgres `inet` column; strict IPv4/IPv6-literal validation (add `@lenient: true` for a plain-string `text` column) | -| Elapsed time / timeout / delay | `field.duration` | native `TimeSpan`/`Duration`/`timedelta`; Postgres `interval` column; ISO-8601 on the wire (`PT1H30M`). Not for a calendar span ("every 3 months"): that is an int and a unit. Never a string or a count of seconds | +| Elapsed time / timeout / delay | `field.duration` | native `TimeSpan`/`Duration`/`timedelta`; Postgres `interval` column; wire form is the port's native one by default, canonical ISO-8601 (`PT1H30M`) under `wire.durations: iso`. Not for a calendar span ("every 3 months"): that is an int and a unit. Never a string or a count of seconds | | Validated plain string (email / hostname) | `field.string` + `@stringFormat` | `@stringFormat: email` or `@stringFormat: hostname` — idiomatic per-port validation; don't hand-write the `validator.regex` | Four rules behind those rows, in short (full text: `references/choosing-a-shape.md`): diff --git a/agent-context/skills/metaobjects-codegen/references/wire-format.md b/agent-context/skills/metaobjects-codegen/references/wire-format.md index a27288b3d1..b0b2f91c8a 100644 --- a/agent-context/skills/metaobjects-codegen/references/wire-format.md +++ b/agent-context/skills/metaobjects-codegen/references/wire-format.md @@ -21,13 +21,16 @@ not "fix" a port to match another one, or to match the corpus. loaded root; there is no CLI flag and no generator argument. ```json -{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native" } } +{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native", "durations": "native" } } ``` - `decimals`: `native` (default) or `string`, the canonical string (`"12.5"`, zero `"0"`, no exponent, no trailing zeros). - `nullMembers`: `native` (default), `emit` or `omit`. It covers the members of an `object.value` only. A top-level entity field that is null is always written as `null`. +- `durations`: `native` (default) or `iso`, for `field.duration`. `native` writes and reads each + port's standard duration form (C# `TimeSpan` is `"01:30:00"`). `iso` writes and reads the + canonical ISO-8601 form (`PT1H30M`) in every port. - An absent block or key means `native`. An unknown key or value is refused when the config is read. There is no `"number"` value: in C#, Java and Kotlin `native` already is a number. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index 555033850b..1979d161f2 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -245,7 +245,7 @@ Reach for these before inventing anything. When no row fits, run the decision pr | Open JSON, no reader narrows it | `field.string` + `@dbColumnType: jsonb` | the LAST rung — see "A JSON column" | | URL / URI | `field.uri` | native `URI`/`Uri`; `text` column; strict absolute-URI validation (add `@lenient: true` to store any string) — a real native type + behavior, so a subtype (not a validated string) | | IP address | `field.inet` | native IP type; Postgres `inet` column; strict IPv4/IPv6-literal validation (add `@lenient: true` for a plain-string `text` column) | -| Elapsed time / timeout / delay | `field.duration` | native `TimeSpan`/`Duration`/`timedelta`; Postgres `interval` column; ISO-8601 on the wire (`PT1H30M`). Not for a calendar span ("every 3 months"): that is an int and a unit. Never a string or a count of seconds | +| Elapsed time / timeout / delay | `field.duration` | native `TimeSpan`/`Duration`/`timedelta`; Postgres `interval` column; wire form is the port's native one by default, canonical ISO-8601 (`PT1H30M`) under `wire.durations: iso`. Not for a calendar span ("every 3 months"): that is an int and a unit. Never a string or a count of seconds | | Validated plain string (email / hostname) | `field.string` + `@stringFormat` | `@stringFormat: email` or `@stringFormat: hostname` — idiomatic per-port validation; don't hand-write the `validator.regex` | Four rules behind those rows, in short (full text: `references/choosing-a-shape.md`): diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md index a27288b3d1..b0b2f91c8a 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -21,13 +21,16 @@ not "fix" a port to match another one, or to match the corpus. loaded root; there is no CLI flag and no generator argument. ```json -{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native" } } +{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native", "durations": "native" } } ``` - `decimals`: `native` (default) or `string`, the canonical string (`"12.5"`, zero `"0"`, no exponent, no trailing zeros). - `nullMembers`: `native` (default), `emit` or `omit`. It covers the members of an `object.value` only. A top-level entity field that is null is always written as `null`. +- `durations`: `native` (default) or `iso`, for `field.duration`. `native` writes and reads each + port's standard duration form (C# `TimeSpan` is `"01:30:00"`). `iso` writes and reads the + canonical ISO-8601 form (`PT1H30M`) in every port. - An absent block or key means `native`. An unknown key or value is refused when the config is read. There is no `"number"` value: in C#, Java and Kotlin `native` already is a number. diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md index 555033850b..1979d161f2 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -245,7 +245,7 @@ Reach for these before inventing anything. When no row fits, run the decision pr | Open JSON, no reader narrows it | `field.string` + `@dbColumnType: jsonb` | the LAST rung — see "A JSON column" | | URL / URI | `field.uri` | native `URI`/`Uri`; `text` column; strict absolute-URI validation (add `@lenient: true` to store any string) — a real native type + behavior, so a subtype (not a validated string) | | IP address | `field.inet` | native IP type; Postgres `inet` column; strict IPv4/IPv6-literal validation (add `@lenient: true` for a plain-string `text` column) | -| Elapsed time / timeout / delay | `field.duration` | native `TimeSpan`/`Duration`/`timedelta`; Postgres `interval` column; ISO-8601 on the wire (`PT1H30M`). Not for a calendar span ("every 3 months"): that is an int and a unit. Never a string or a count of seconds | +| Elapsed time / timeout / delay | `field.duration` | native `TimeSpan`/`Duration`/`timedelta`; Postgres `interval` column; wire form is the port's native one by default, canonical ISO-8601 (`PT1H30M`) under `wire.durations: iso`. Not for a calendar span ("every 3 months"): that is an int and a unit. Never a string or a count of seconds | | Validated plain string (email / hostname) | `field.string` + `@stringFormat` | `@stringFormat: email` or `@stringFormat: hostname` — idiomatic per-port validation; don't hand-write the `validator.regex` | Four rules behind those rows, in short (full text: `references/choosing-a-shape.md`): diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/wire-format.md index a27288b3d1..b0b2f91c8a 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/wire-format.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -21,13 +21,16 @@ not "fix" a port to match another one, or to match the corpus. loaded root; there is no CLI flag and no generator argument. ```json -{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native" } } +{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native", "durations": "native" } } ``` - `decimals`: `native` (default) or `string`, the canonical string (`"12.5"`, zero `"0"`, no exponent, no trailing zeros). - `nullMembers`: `native` (default), `emit` or `omit`. It covers the members of an `object.value` only. A top-level entity field that is null is always written as `null`. +- `durations`: `native` (default) or `iso`, for `field.duration`. `native` writes and reads each + port's standard duration form (C# `TimeSpan` is `"01:30:00"`). `iso` writes and reads the + canonical ISO-8601 form (`PT1H30M`) in every port. - An absent block or key means `native`. An unknown key or value is refused when the config is read. There is no `"number"` value: in C#, Java and Kotlin `native` already is a number. diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md index 555033850b..1979d161f2 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -245,7 +245,7 @@ Reach for these before inventing anything. When no row fits, run the decision pr | Open JSON, no reader narrows it | `field.string` + `@dbColumnType: jsonb` | the LAST rung — see "A JSON column" | | URL / URI | `field.uri` | native `URI`/`Uri`; `text` column; strict absolute-URI validation (add `@lenient: true` to store any string) — a real native type + behavior, so a subtype (not a validated string) | | IP address | `field.inet` | native IP type; Postgres `inet` column; strict IPv4/IPv6-literal validation (add `@lenient: true` for a plain-string `text` column) | -| Elapsed time / timeout / delay | `field.duration` | native `TimeSpan`/`Duration`/`timedelta`; Postgres `interval` column; ISO-8601 on the wire (`PT1H30M`). Not for a calendar span ("every 3 months"): that is an int and a unit. Never a string or a count of seconds | +| Elapsed time / timeout / delay | `field.duration` | native `TimeSpan`/`Duration`/`timedelta`; Postgres `interval` column; wire form is the port's native one by default, canonical ISO-8601 (`PT1H30M`) under `wire.durations: iso`. Not for a calendar span ("every 3 months"): that is an int and a unit. Never a string or a count of seconds | | Validated plain string (email / hostname) | `field.string` + `@stringFormat` | `@stringFormat: email` or `@stringFormat: hostname` — idiomatic per-port validation; don't hand-write the `validator.regex` | Four rules behind those rows, in short (full text: `references/choosing-a-shape.md`): diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/wire-format.md index a27288b3d1..b0b2f91c8a 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/wire-format.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -21,13 +21,16 @@ not "fix" a port to match another one, or to match the corpus. loaded root; there is no CLI flag and no generator argument. ```json -{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native" } } +{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native", "durations": "native" } } ``` - `decimals`: `native` (default) or `string`, the canonical string (`"12.5"`, zero `"0"`, no exponent, no trailing zeros). - `nullMembers`: `native` (default), `emit` or `omit`. It covers the members of an `object.value` only. A top-level entity field that is null is always written as `null`. +- `durations`: `native` (default) or `iso`, for `field.duration`. `native` writes and reads each + port's standard duration form (C# `TimeSpan` is `"01:30:00"`). `iso` writes and reads the + canonical ISO-8601 form (`PT1H30M`) in every port. - An absent block or key means `native`. An unknown key or value is refused when the config is read. There is no `"number"` value: in C#, Java and Kotlin `native` already is a number. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md index 555033850b..1979d161f2 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -245,7 +245,7 @@ Reach for these before inventing anything. When no row fits, run the decision pr | Open JSON, no reader narrows it | `field.string` + `@dbColumnType: jsonb` | the LAST rung — see "A JSON column" | | URL / URI | `field.uri` | native `URI`/`Uri`; `text` column; strict absolute-URI validation (add `@lenient: true` to store any string) — a real native type + behavior, so a subtype (not a validated string) | | IP address | `field.inet` | native IP type; Postgres `inet` column; strict IPv4/IPv6-literal validation (add `@lenient: true` for a plain-string `text` column) | -| Elapsed time / timeout / delay | `field.duration` | native `TimeSpan`/`Duration`/`timedelta`; Postgres `interval` column; ISO-8601 on the wire (`PT1H30M`). Not for a calendar span ("every 3 months"): that is an int and a unit. Never a string or a count of seconds | +| Elapsed time / timeout / delay | `field.duration` | native `TimeSpan`/`Duration`/`timedelta`; Postgres `interval` column; wire form is the port's native one by default, canonical ISO-8601 (`PT1H30M`) under `wire.durations: iso`. Not for a calendar span ("every 3 months"): that is an int and a unit. Never a string or a count of seconds | | Validated plain string (email / hostname) | `field.string` + `@stringFormat` | `@stringFormat: email` or `@stringFormat: hostname` — idiomatic per-port validation; don't hand-write the `validator.regex` | Four rules behind those rows, in short (full text: `references/choosing-a-shape.md`): diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md index a27288b3d1..b0b2f91c8a 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -21,13 +21,16 @@ not "fix" a port to match another one, or to match the corpus. loaded root; there is no CLI flag and no generator argument. ```json -{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native" } } +{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native", "durations": "native" } } ``` - `decimals`: `native` (default) or `string`, the canonical string (`"12.5"`, zero `"0"`, no exponent, no trailing zeros). - `nullMembers`: `native` (default), `emit` or `omit`. It covers the members of an `object.value` only. A top-level entity field that is null is always written as `null`. +- `durations`: `native` (default) or `iso`, for `field.duration`. `native` writes and reads each + port's standard duration form (C# `TimeSpan` is `"01:30:00"`). `iso` writes and reads the + canonical ISO-8601 form (`PT1H30M`) in every port. - An absent block or key means `native`. An unknown key or value is refused when the config is read. There is no `"number"` value: in C#, Java and Kotlin `native` already is a number. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md index 555033850b..1979d161f2 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-authoring/SKILL.md @@ -245,7 +245,7 @@ Reach for these before inventing anything. When no row fits, run the decision pr | Open JSON, no reader narrows it | `field.string` + `@dbColumnType: jsonb` | the LAST rung — see "A JSON column" | | URL / URI | `field.uri` | native `URI`/`Uri`; `text` column; strict absolute-URI validation (add `@lenient: true` to store any string) — a real native type + behavior, so a subtype (not a validated string) | | IP address | `field.inet` | native IP type; Postgres `inet` column; strict IPv4/IPv6-literal validation (add `@lenient: true` for a plain-string `text` column) | -| Elapsed time / timeout / delay | `field.duration` | native `TimeSpan`/`Duration`/`timedelta`; Postgres `interval` column; ISO-8601 on the wire (`PT1H30M`). Not for a calendar span ("every 3 months"): that is an int and a unit. Never a string or a count of seconds | +| Elapsed time / timeout / delay | `field.duration` | native `TimeSpan`/`Duration`/`timedelta`; Postgres `interval` column; wire form is the port's native one by default, canonical ISO-8601 (`PT1H30M`) under `wire.durations: iso`. Not for a calendar span ("every 3 months"): that is an int and a unit. Never a string or a count of seconds | | Validated plain string (email / hostname) | `field.string` + `@stringFormat` | `@stringFormat: email` or `@stringFormat: hostname` — idiomatic per-port validation; don't hand-write the `validator.regex` | Four rules behind those rows, in short (full text: `references/choosing-a-shape.md`): diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/wire-format.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/wire-format.md index a27288b3d1..b0b2f91c8a 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/wire-format.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/wire-format.md @@ -21,13 +21,16 @@ not "fix" a port to match another one, or to match the corpus. loaded root; there is no CLI flag and no generator argument. ```json -{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native" } } +{ "schema_version": 1, "wire": { "decimals": "native", "nullMembers": "native", "durations": "native" } } ``` - `decimals`: `native` (default) or `string`, the canonical string (`"12.5"`, zero `"0"`, no exponent, no trailing zeros). - `nullMembers`: `native` (default), `emit` or `omit`. It covers the members of an `object.value` only. A top-level entity field that is null is always written as `null`. +- `durations`: `native` (default) or `iso`, for `field.duration`. `native` writes and reads each + port's standard duration form (C# `TimeSpan` is `"01:30:00"`). `iso` writes and reads the + canonical ISO-8601 form (`PT1H30M`) in every port. - An absent block or key means `native`. An unknown key or value is refused when the config is read. There is no `"number"` value: in C#, Java and Kotlin `native` already is a number. diff --git a/fixtures/duration-wire/README.md b/fixtures/duration-wire/README.md index 5033b54ad4..ded2676da3 100644 --- a/fixtures/duration-wire/README.md +++ b/fixtures/duration-wire/README.md @@ -5,7 +5,7 @@ to store one. [`vectors.json`](./vectors.json) has two parts. ## `wire`: every port passes every vector -The ISO-8601 text a caller sends and a port answers. Clients depend on it, so every port agrees. +The ISO-8601 text a caller sends and a port answers under `"wire": { "durations": "iso" }`. Clients depend on it, so every port agrees in that mode. Under the default `native` mode each port writes its own standard form (see [the wire setting](../../docs/features/api-contract.md#the-wire-setting)). - **`parse`**: a duration a caller may send, and the canonical form a port answers for it. The canonical form is `[-]PT[H][M][[.]S]`: hours are never folded into days, zero