From 43177a92ee9727304622b5753919797b8ab1f404 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Wed, 11 Nov 2026 22:48:46 -0500 Subject: [PATCH 1/4] feat(metadata): rename @dbSetName to @collectionName and add the naming.collections pluralizer Renames the unreleased @dbSetName to @collectionName (no alias) and registers it in every port on object.entity, object.projection and object.report, marked implementedIn csharp: the generated C# context takes it as the DbSet property name. A project chooses the pluralizer an object without one takes its name from with naming.collections in .metaobjects/config.json (standard by default, none, or one the application registers); every port reads the key and refuses an unknown name. The shared fixture object-port-scoped-attr-dbsetname becomes object-collection-name, and object-port-scoped-attr-object-adapter keeps the port-scoped marker covered. --- AGENTS.md | 2 +- CHANGELOG.md | 41 ++++---- docs/CONFORMANCE.md | 9 +- docs/features/migrations/physical-naming.md | 5 +- examples/showcase/site-payload.json | 2 +- .../input/meta.diagnosis.json | 2 +- .../expected.json | 6 +- .../input/meta.diagnosis.json | 6 +- .../expected.json | 94 ++++++++++++++++++ .../input/meta.diagnosis.json | 23 +++++ fixtures/metamodel-docs/expected/providers.md | 6 +- .../metamodel-docs/expected/types/object.md | 6 +- fixtures/registry-conformance/README.md | 2 +- .../expected-registry.json | 18 ++-- .../CSharpNamingTests.cs | 10 +- .../DbContextForeignKeyConfigTests.cs | 4 +- .../MetaObjects.Codegen/CSharpNaming.cs | 8 +- .../Generators/DbContextGenerator.cs | 4 +- .../CollectionNameVocabularyTests.cs | 49 ++++++++++ .../DbSetNameVocabularyTests.cs | 41 -------- .../NamingStrategiesTests.cs | 25 +++++ .../MetaObjects/Config/NeutralConfig.cs | 18 +++- .../Core/Object/ObjectConstants.cs | 10 +- .../MetaObjects/Core/Object/ObjectSchema.cs | 13 ++- server/csharp/MetaObjects/CoreTypes.cs | 6 +- .../MetaObjects/Meta/CollectionNames.cs | 46 +++++++++ .../csharp/MetaObjects/Meta/PhysicalNaming.cs | 57 ++++++++++- server/csharp/MetaObjects/RegistryManifest.cs | 2 +- .../MetaObjects/SpecMetamodel/object.json | 6 +- .../generator/kotlin/PortScopedAttrKtTest.kt | 14 +-- .../com/metaobjects/config/NeutralConfig.java | 17 +++- .../database/NamingStrategies.java | 71 +++++++++++++- .../metaobjects/database/PhysicalNaming.java | 26 ++++- .../com/metaobjects/object/MetaObject.java | 10 +- .../registry/RegistryManifest.java | 6 +- .../loader/PortScopedAttrTest.java | 20 ++-- .../src/metaobjects/config/neutral_config.py | 11 ++- server/python/src/metaobjects/core_types.py | 6 +- .../meta/core/object/object_constants.py | 7 +- server/python/src/metaobjects/naming.py | 85 ++++++++++++++-- .../metaobjects/spec_metamodel/object.json | 6 +- .../tests/loader/test_port_scoped_attr.py | 21 ++-- .../tests/unit/test_collection_naming.py | 70 ++++++++++++++ .../packages/cli/test/types-command.test.ts | 4 +- .../src/core/object/object-constants.ts | 2 + .../core/object/object-definition.embedded.ts | 18 ++-- .../typescript/packages/metadata/src/index.ts | 4 +- .../packages/metadata/src/naming.ts | 96 +++++++++++++++++-- .../src/registry-manifest-exclusions.ts | 2 +- .../object-definition-completeness.test.ts | 16 ++-- .../metadata/test/port-scoped-attr.test.ts | 20 ++-- site-reference/providers.html | 6 +- site-reference/types/object.html | 12 +-- spec/metamodel/object.json | 6 +- 54 files changed, 842 insertions(+), 235 deletions(-) rename fixtures/conformance/{object-port-scoped-attr-dbsetname => object-collection-name}/expected.json (93%) rename fixtures/conformance/{object-port-scoped-attr-dbsetname => object-collection-name}/input/meta.diagnosis.json (71%) create mode 100644 fixtures/conformance/object-port-scoped-attr-object-adapter/expected.json create mode 100644 fixtures/conformance/object-port-scoped-attr-object-adapter/input/meta.diagnosis.json create mode 100644 server/csharp/MetaObjects.Conformance.Tests/CollectionNameVocabularyTests.cs delete mode 100644 server/csharp/MetaObjects.Conformance.Tests/DbSetNameVocabularyTests.cs create mode 100644 server/csharp/MetaObjects/Meta/CollectionNames.cs create mode 100644 server/python/tests/unit/test_collection_naming.py diff --git a/AGENTS.md b/AGENTS.md index bde4ffec93..d9afccf518 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/` (454 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/` (455 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/CHANGELOG.md b/CHANGELOG.md index 918e4c2873..bf07d8ea50 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,12 +16,12 @@ _npm `1.3.0` (full lockstep across all 14 `@metaobjectsdev/*` publish candidates **`metamodelVersion` moves to `1.3`.** This release adds vocabulary (`field.duration`, `validator.exactlyOne`, `@constraintName`, `@condition`, `@maxLength` on `field.enum`, -`@sequenceStart`, `@sequenceName`, `@flattenedColumnPrefix`, the `pascal` column rule, and -`@dbSetName`, a C#-only attr) and nothing that loaded before stops loading. The registry gains two +`@sequenceStart`, `@sequenceName`, `@flattenedColumnPrefix`, the `pascal` column rule and +`@collectionName`) and nothing that loaded before stops loading. The registry gains two fields on a type or attr entry, carried into the registry manifest: `implementedIn` (cross-port vocabulary only the named ports implement so far, e.g. the `view.*` controls, `["typescript"]`) and -`ports` (vocabulary only the named ports ever act on, e.g. `@dbSetName`, `["csharp"]`, and -`@objectAdapter`, `["java", "kotlin"]`: the JVM runtime both share acts on it). The manifest's row +`ports` (vocabulary only the named ports ever act on, e.g. `@objectAdapter`, `["java", "kotlin"]`: +the JVM runtime both share acts on it). The manifest's row set grows by the `implementedIn` rows that used to be carved out. A port that reports `1.2` does not know that vocabulary; upgrade every port together. @@ -50,6 +50,8 @@ Each item below is described in full under Added, Changed or Fixed; this list is - **`@sequenceStart` records a start; in 1.3.0 it does not move an existing counter.** A later release is planned to move a counter that sits below the declared start forward to it, never backward. +- **`@dbSetName` is `@collectionName`.** The attr was never published under its first name, so + there is no alias: a model that set `@dbSetName` in a pre-release build renames it. - **`metamodelVersion` is `1.3`.** Upgrade every port together. ### Added @@ -83,22 +85,29 @@ Each item below is described in full under Added, Changed or Fixed; this list is canonical grows by those rows. `meta types` marks them `[ts; pending elsewhere]` / `[java; pending elsewhere]` (it used to say `[ts-only]`, "the cross-port contract does not carry it"; its JSON change is under Changed), and the metamodel docs now cover the view controls. - `@objectAdapter` joins `@dbSetName` as port-scoped (`"ports": ["java", "kotlin"]`). C#, Java, + `@objectAdapter` is port-scoped (`"ports": ["java", "kotlin"]`, below). C#, Java, Kotlin and Python now register the `metaobjects-ui-web` attrs (`view.textarea @rows` and the `view.image` attrs), and C# and Python register `@object` / `@objectAdapter`, so a model that uses any of them loads strictly in every port. Gated by the conformance fixtures `view-kind1-controls`, `object-kind1-jvm-binding-attrs` and `error-kind1-view-unregistered-attr`, and by the registry canonical. -- **Port-scoped vocabulary, and `@dbSetName`.** A registry attr may name the ports - that act on it (`"ports": ["csharp"]` on its `spec/metamodel` entry, carried into the registry +- **Port-scoped vocabulary.** A registry attr may name the ports + that act on it (`"ports": ["java", "kotlin"]` on its `spec/metamodel` entry, carried into the registry manifest). Every port registers and type-checks it, so a model that carries it loads under every port's strict loader; a port not named does nothing with it. An attr the registry does not carry still fails, and a wrong value type fails in every port (Java now refuses a list given to a scalar - string attr, as the other ports did). `meta types` marks such an attr `[csharp-only]` and its JSON - carries `ports`. The first one is `@dbSetName`: the EF Core DbSet property name the generated C# - context gives an `object.entity`, `object.projection` or `object.report` (`"@dbSetName": - "Diagnosis"`), in place of the derived plural; the routes, the reverse finders and the api - reference read the property name through it. + string attr, as the other ports did). `meta types` marks such an attr `[java+kotlin]` (one port: + `[csharp-only]`) and its JSON carries `ports`. `@objectAdapter` is the one there is. +- **`@collectionName`, and the `naming.collections` pluralizer.** An `object.entity`, + `object.projection` or `object.report` may name its collection in its own casing + (`"@collectionName": "CaseHistory"`); it need not be a grammatical plural. The generated C# + context uses it as the DbSet property name in place of the derived plural, and the routes, the + reverse finders and the api reference read the property through it. Absent, the project's + pluralizer derives the name: `"naming": { "collections": "standard" }` (the default) or `"none"` + in `.metaobjects/config.json`, or one the application registers (`registerPluralizer` in + TypeScript, `NamingStrategies.RegisterPluralizer` in C#, `NamingStrategies.registerPluralizer` in + Java, `register_pluralizer` in Python). Every port registers the attr and reads the key, and + refuses an unknown pluralizer when it reads the config. - **An int-backed enum can be an array.** `@intValueMap` together with `isArray: true` (the map on the field, or inherited from a shared enum) now loads in every port instead of @@ -635,8 +644,8 @@ Each item below is described in full under Added, Changed or Fixed; this list is - **C#: one generated context maps each relation to one object.** Two concrete objects of one context that resolve to the same table, view, procedure or function in the same `@schema` are refused with `ERR_DUPLICATE_SQL_NAME`, naming both; before, they silently shared it - once `@dbSetName` split their DbSet names. The same name in two schemas is two relations, and an - object that extends another and inherits its source is one EF hierarchy. A `@dbSetName` that is not + once `@collectionName` split their DbSet names. The same name in two schemas is two relations, and an + object that extends another and inherits its source is one EF hierarchy. A `@collectionName` that is not a C# identifier, or names `Database`, `Model`, `ChangeTracker` or `ContextId`, is refused by name. - **TypeScript: the collision refusal checks only the names the run emits.** The pluralized module names are checked when entity modules, queries or a UI tier are wired, and the REST segments when @@ -682,7 +691,7 @@ Each item below is described in full under Added, Changed or Fixed; this list is Changed. - **A generated name collides only in the scope that carries it**. The run-wide refusal of two objects whose short names pluralize alike is gone. C#: DbSet names are unique - within one generated context (the error says "rename one, or set @dbSetName"), route segments + within one generated context (the error says "rename one, or set @collectionName"), route segments within one routes entry, reverse finders within their class. TypeScript: the pluralized names within the entity modules' scope (one directory under the flat layout, one per package under the package layout), REST segments across the app. Python and JVM keep one run-wide scope, @@ -858,7 +867,7 @@ Each item below is described in full under Added, Changed or Fixed; this list is cross-port wire contract and keeps the shared rule (`/api/diagnosises`) until every port changes together. An adopter that aliased the old DbSet name can drop the alias. **Upgrade note:** code that names `Diagnosises` or `FindDiagnosisesBy…` stops compiling; use the new name, - or set `"@dbSetName": "Diagnosises"` on the entity to keep the old one. + or set `"@collectionName": "Diagnosises"` on the entity to keep the old one. - **TypeScript: a derived flattened `field.object` no longer produces flat member keys (#524 follow-up).** A flattened field that carries an `origin.*` owns no column (the Drizzle table and diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 1a929ad48a..fa4087bfa8 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) | 454 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | +| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 455 | ✓ | ✓ | 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 | ✓ | — | — | — | ✓ | @@ -155,7 +155,7 @@ canonical (`fixtures/registry-conformance/expected-registry.json`). `registry-conformance.test.ts` in TypeScript; `RegistryManifestConformanceTest` in Java and Kotlin; `test_registry_conformance.py` in Python). - *Port-scoped, for comparison* (`ports`, language-specific by nature, never "pending"): - `@dbSetName` (`["csharp"]`) and `@objectAdapter` (`["java", "kotlin"]`). + `@objectAdapter` (`["java", "kotlin"]`). **Native index vocabulary and `@description` in the ORM models.** `meta migrate` creates every index (`identity.secondary`, `index.lookup`, a field's `@unique`, with `@where`, `@using`, @@ -351,7 +351,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker. ## Fixture-to-doc mapping -### `fixtures/conformance/` — metamodel loader + canonical serializer (454) +### `fixtures/conformance/` — metamodel loader + canonical serializer (455) | Fixture prefix | Feature doc | |---|---| @@ -376,6 +376,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker. | `requirement-*`, `error-unknown-attr-requirement` | [features/requirements.md](features/requirements.md) (vocabulary only — the gate has its own corpus, `requirement-check-conformance/`; see "Split coverage" above) | | `reporting-*`, `error-dimension-*`, `error-measure-*`, `error-ratio-*`, `error-segment-*`, `error-report-*`, `error-relative-date-*` | [features/reporting.md](features/reporting.md) (the `dimension` / `measure` / `segment` / `object.report` vocabulary and its load-time rules; design in the [FR-044 spec](superpowers/specs/2026-10-02-fr-044-core-reporting-design.md)) | | `provider-extension-*`, `provider-custom-vocabulary-*` | [features/extending-with-providers.md](features/extending-with-providers.md) (a project's own provider: a new subtype plus attributes on an existing type load with it and are refused without it, every port writing the provider in its own language) | +| `object-collection-name` | [features/migrations/physical-naming.md](features/migrations/physical-naming.md) (`@collectionName` and the `collections` pluralizer) | | `smoke-empty-metadata` | [features/entities.md](features/entities.md) | ### `fixtures/yaml-conformance/` (16) @@ -712,7 +713,7 @@ JSON and verify payload feeds it unchanged. ## Orphaned fixtures (tested but not yet documented) -The fixtures in the nine corpora mapped above (metamodel 454 + yaml 16 + verify 45 +The fixtures in the nine corpora mapped above (metamodel 455 + yaml 16 + verify 45 + render 18 + persistence 62 + api-contract 123 + source-resolution 25 + scope 10 + dependency 47) 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/docs/features/migrations/physical-naming.md b/docs/features/migrations/physical-naming.md index bb4ca9ed4d..c00fb32f74 100644 --- a/docs/features/migrations/physical-naming.md +++ b/docs/features/migrations/physical-naming.md @@ -27,6 +27,7 @@ every runtime `ObjectManager` and `verify` read it, so they always produce the s |---|---|---|---| | `columns` | `snake_case`, `literal`, `kebab-case`, `pascal`, or a name your application registered | a field with no `@column` | `snake_case` | | `tables` | `snake_case`, `literal`, or a name your application registered | a source with no `@table`/`@view`/`@proc`/`@function`: its `name`, else the object's name made plural | `snake_case` | +| `collections` | `standard`, `none`, or a name your application registered | an object with no `@collectionName`: the collection name the generated C# DbSet takes | `standard` | An unknown key fails when the file is read, and so does a name no registered strategy answers. Index and constraint names are unchanged. @@ -61,7 +62,9 @@ and is `pascal` otherwise: Table rules register the same way (`namingStrategies.tables`, `RegisterTables`, `registerTables`, `register_table_naming_rule`) and name a source with no physical name; the entity-name fallback -is still made plural after the rule. A built-in name cannot be replaced. Register the same +is still made plural after the rule. Pluralizers register the same way (`registerPluralizer`, `RegisterPluralizer`, +`registerPluralizer`, `register_pluralizer`): an object name in, in its declared casing, its +collection name out. `standard` is the cross-port plural and `none` keeps the name as it is. A built-in name cannot be replaced. Register the same strategy in every port that reads the database, because each port resolves the names itself. Every port looks a strategy up by name each time it applies one, so registering again under the same name replaces it for everything that runs afterwards. A test that registers its own calls diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index 445b33ac1a..162a816c8b 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -7,7 +7,7 @@ "metamodel": "1.3" }, "counts": { - "fixtures": 454, + "fixtures": 455, "corpora": 38, "baseTypes": 17 }, diff --git a/fixtures/conformance/error-port-scoped-attr-wrong-type/input/meta.diagnosis.json b/fixtures/conformance/error-port-scoped-attr-wrong-type/input/meta.diagnosis.json index 5a7b506c8a..22063012de 100644 --- a/fixtures/conformance/error-port-scoped-attr-wrong-type/input/meta.diagnosis.json +++ b/fixtures/conformance/error-port-scoped-attr-wrong-type/input/meta.diagnosis.json @@ -2,7 +2,7 @@ "metadata.root": { "package": "acme::clinical", "children": [ - { "object.entity": { "name": "Diagnosis", "@dbSetName": ["Diagnosis"], "children": [ + { "object.entity": { "name": "Diagnosis", "@objectAdapter": ["com.acme.clinical.DiagnosisAdapter"], "children": [ { "field.long": { "name": "id" } }, { "identity.primary": { "name": "pk", "@fields": ["id"] } } ] } } diff --git a/fixtures/conformance/object-port-scoped-attr-dbsetname/expected.json b/fixtures/conformance/object-collection-name/expected.json similarity index 93% rename from fixtures/conformance/object-port-scoped-attr-dbsetname/expected.json rename to fixtures/conformance/object-collection-name/expected.json index 0454a28d5e..e4136d2a7b 100644 --- a/fixtures/conformance/object-port-scoped-attr-dbsetname/expected.json +++ b/fixtures/conformance/object-collection-name/expected.json @@ -5,7 +5,7 @@ { "object.entity": { "name": "Diagnosis", - "@dbSetName": "Diagnosis", + "@collectionName": "Diagnosis", "children": [ { "source.rdb": { @@ -51,7 +51,7 @@ { "object.projection": { "name": "DiagnosisSummary", - "@dbSetName": "DiagnosisSummary", + "@collectionName": "DiagnosisSummary", "children": [ { "source.rdb": { @@ -70,7 +70,7 @@ { "object.report": { "name": "DiagnosisCounts", - "@dbSetName": "DiagnosisCounts", + "@collectionName": "DiagnosisCounts", "@dimensions": [ "code" ], diff --git a/fixtures/conformance/object-port-scoped-attr-dbsetname/input/meta.diagnosis.json b/fixtures/conformance/object-collection-name/input/meta.diagnosis.json similarity index 71% rename from fixtures/conformance/object-port-scoped-attr-dbsetname/input/meta.diagnosis.json rename to fixtures/conformance/object-collection-name/input/meta.diagnosis.json index f0e736fca8..3fb6616ee3 100644 --- a/fixtures/conformance/object-port-scoped-attr-dbsetname/input/meta.diagnosis.json +++ b/fixtures/conformance/object-collection-name/input/meta.diagnosis.json @@ -2,7 +2,7 @@ "metadata.root": { "package": "acme::clinical", "children": [ - { "object.entity": { "name": "Diagnosis", "@dbSetName": "Diagnosis", "children": [ + { "object.entity": { "name": "Diagnosis", "@collectionName": "Diagnosis", "children": [ { "source.rdb": { "@table": "diagnoses" } }, { "field.long": { "name": "id" } }, { "field.string": { "name": "code" } }, @@ -10,11 +10,11 @@ { "dimension.attribute": { "name": "code", "@of": "Diagnosis.code" } }, { "measure.aggregate": { "name": "diagnoses", "@agg": "count", "@of": "Diagnosis.id" } } ] } }, - { "object.projection": { "name": "DiagnosisSummary", "@dbSetName": "DiagnosisSummary", "children": [ + { "object.projection": { "name": "DiagnosisSummary", "@collectionName": "DiagnosisSummary", "children": [ { "source.rdb": { "@kind": "view", "@view": "v_diagnosis_summary" } }, { "field.string": { "name": "code" } } ] } }, - { "object.report": { "name": "DiagnosisCounts", "@dbSetName": "DiagnosisCounts", "@from": "Diagnosis", + { "object.report": { "name": "DiagnosisCounts", "@collectionName": "DiagnosisCounts", "@from": "Diagnosis", "@dimensions": ["code"], "@measures": ["diagnoses"], "children": [ { "source.rdb": { "@kind": "view", "@view": "v_diagnosis_counts" } } ] } } diff --git a/fixtures/conformance/object-port-scoped-attr-object-adapter/expected.json b/fixtures/conformance/object-port-scoped-attr-object-adapter/expected.json new file mode 100644 index 0000000000..9affb23940 --- /dev/null +++ b/fixtures/conformance/object-port-scoped-attr-object-adapter/expected.json @@ -0,0 +1,94 @@ +{ + "metadata.root": { + "package": "acme::clinical", + "children": [ + { + "object.entity": { + "name": "Diagnosis", + "@objectAdapter": "com.acme.clinical.DiagnosisAdapter", + "children": [ + { + "source.rdb": { + "@table": "diagnoses" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.string": { + "name": "code" + } + }, + { + "identity.primary": { + "name": "pk", + "@fields": [ + "id" + ] + } + }, + { + "dimension.attribute": { + "name": "code", + "@of": "acme::clinical::Diagnosis.code" + } + }, + { + "measure.aggregate": { + "name": "diagnoses", + "@agg": "count", + "@of": [ + "acme::clinical::Diagnosis.id" + ] + } + } + ] + } + }, + { + "object.projection": { + "name": "DiagnosisSummary", + "@objectAdapter": "com.acme.clinical.DiagnosisSummaryAdapter", + "children": [ + { + "source.rdb": { + "@kind": "view", + "@view": "v_diagnosis_summary" + } + }, + { + "field.string": { + "name": "code" + } + } + ] + } + }, + { + "object.report": { + "name": "DiagnosisCounts", + "@dimensions": [ + "code" + ], + "@from": "acme::clinical::Diagnosis", + "@measures": [ + "diagnoses" + ], + "@objectAdapter": "com.acme.clinical.DiagnosisCountsAdapter", + "children": [ + { + "source.rdb": { + "@kind": "view", + "@view": "v_diagnosis_counts" + } + } + ] + } + } + ] + } +} + diff --git a/fixtures/conformance/object-port-scoped-attr-object-adapter/input/meta.diagnosis.json b/fixtures/conformance/object-port-scoped-attr-object-adapter/input/meta.diagnosis.json new file mode 100644 index 0000000000..dcb977939e --- /dev/null +++ b/fixtures/conformance/object-port-scoped-attr-object-adapter/input/meta.diagnosis.json @@ -0,0 +1,23 @@ +{ + "metadata.root": { + "package": "acme::clinical", + "children": [ + { "object.entity": { "name": "Diagnosis", "@objectAdapter": "com.acme.clinical.DiagnosisAdapter", "children": [ + { "source.rdb": { "@table": "diagnoses" } }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "code" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"] } }, + { "dimension.attribute": { "name": "code", "@of": "Diagnosis.code" } }, + { "measure.aggregate": { "name": "diagnoses", "@agg": "count", "@of": "Diagnosis.id" } } + ] } }, + { "object.projection": { "name": "DiagnosisSummary", "@objectAdapter": "com.acme.clinical.DiagnosisSummaryAdapter", "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_diagnosis_summary" } }, + { "field.string": { "name": "code" } } + ] } }, + { "object.report": { "name": "DiagnosisCounts", "@objectAdapter": "com.acme.clinical.DiagnosisCountsAdapter", "@from": "Diagnosis", + "@dimensions": ["code"], "@measures": ["diagnoses"], "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_diagnosis_counts" } } + ] } } + ] + } +} diff --git a/fixtures/metamodel-docs/expected/providers.md b/fixtures/metamodel-docs/expected/providers.md index e2a4b48491..54ff5312e6 100644 --- a/fixtures/metamodel-docs/expected/providers.md +++ b/fixtures/metamodel-docs/expected/providers.md @@ -30,9 +30,9 @@ Core metaobjects metamodel types and subtypes. - `identity.reference`: `@enforce`, `@fields`, `@references` - `identity.secondary`: `@fields` - `object.base`: `@object`, `@objectAdapter` -- `object.entity`: `@dbSetName`, `@discriminator`, `@discriminatorValue` -- `object.projection`: `@dbSetName`, `@filter` -- `object.report`: `@dbSetName`, `@dimensions`, `@filter`, `@from`, `@measures`, `@segment`, `@spine` +- `object.entity`: `@collectionName`, `@discriminator`, `@discriminatorValue` +- `object.projection`: `@collectionName`, `@filter` +- `object.report`: `@collectionName`, `@dimensions`, `@filter`, `@from`, `@measures`, `@segment`, `@spine` - `origin.aggregate`: `@agg`, `@distinct`, `@filter`, `@of`, `@orderBy`, `@via` - `origin.computed`: `@expr` - `origin.first`: `@filter`, `@of`, `@orderBy`, `@via` diff --git a/fixtures/metamodel-docs/expected/types/object.md b/fixtures/metamodel-docs/expected/types/object.md index cb69c9ca09..1ceb902a36 100644 --- a/fixtures/metamodel-docs/expected/types/object.md +++ b/fixtures/metamodel-docs/expected/types/object.md @@ -46,7 +46,7 @@ An object that owns its data: own identity, writable sources, and lifecycle. The | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | -| `@dbSetName` | string | no | | | metaobjects-core-types | C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it. | +| `@collectionName` | string | no | | | metaobjects-core-types | The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own). | | `@discriminator` | string | no | | | metaobjects-core-types | FR-014: names the field on this entity (resolvable via extends:) that holds the subtype-discriminator value. Subtypes of this entity declare @discriminatorValue to bind their rows to a discriminator value. The discriminator field itself is an ordinary field declaration (typically field.enum or field.int / field.string). | | `@discriminatorValue` | string | no | | | metaobjects-core-types | FR-014: on a subtype of an entity with @discriminator — the value that identifies rows of this subtype in the shared discriminator field. Wire form is always a string; the underlying field's subtype (enum / int / string) controls codegen + storage coercion. Required on every concrete subtype of a discriminated entity. | | `@object` | string | no | | | metaobjects-core-types | The native class an object binds to (ADR-0001), in place of its default runtime class: on the JVM, a fully-qualified class name. Cross-port vocabulary only Java implements so far; every port loads and type-checks it, and the others do not act on it yet. | @@ -80,7 +80,7 @@ A derived read-only representation of entities. Its fields are extends-bound / o | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | -| `@dbSetName` | string | no | | | metaobjects-core-types | C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it. | +| `@collectionName` | string | no | | | metaobjects-core-types | The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own). | | `@filter` | filter | no | | | metaobjects-core-types | Optional row-scope predicate (a portable attr.filter object: eq/ne/gt/gte/lt/lte/like/in/isNull with and/or, desugared to { field: { op: value } } at parse time) selecting which rows the view returns — lowered to an outer SQL WHERE. Resolves against the projection's own declared fields; an aggregate-derived field is not addressable (fail-closed). | | `@object` | string | no | | | metaobjects-core-types | The native class an object binds to (ADR-0001), in place of its default runtime class: on the JVM, a fully-qualified class name. Cross-port vocabulary only Java implements so far; every port loads and type-checks it, and the others do not act on it yet. | | `@objectAdapter` | string | no | | | metaobjects-core-types | JVM-only (ADR-0005): the fully-qualified name of an ObjectAdapter that takes over value access for this object's instances. Every port loads and type-checks it; only Java and Kotlin act on it. | @@ -106,7 +106,7 @@ A declared report (FR-044): a fixed combination of dimensions and measures of ON | Attribute | Type | Required | Default | Allowed values | Provider | Description | | --- | --- | --- | --- | --- | --- | --- | -| `@dbSetName` | string | no | | | metaobjects-core-types | C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it. | +| `@collectionName` | string | no | | | metaobjects-core-types | The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own). | | `@dimensions` | string[] | no | | | metaobjects-core-types | Dimension names of @from; a time dimension is written 'name:grain'. Absent means one global row. | | `@filter` | filter | no | | | metaobjects-core-types | Optional row scope over @from's fields; may use relative-date values. | | `@from` | string | yes | | | metaobjects-core-types | The object.entity whose rows the report aggregates. | diff --git a/fixtures/registry-conformance/README.md b/fixtures/registry-conformance/README.md index e0afc8a162..5d84ae7efb 100644 --- a/fixtures/registry-conformance/README.md +++ b/fixtures/registry-conformance/README.md @@ -261,7 +261,7 @@ still fails everywhere. as `PRESENTATION_ONLY` / `NATIVE_BINDING`; both reasons are retired. The parity gaps are listed in `docs/CONFORMANCE.md`, "Split coverage". - **Kind 2 — language-specific by nature.** The attr carries `"ports": [...]`: - `@dbSetName` (`["csharp"]`) and `@objectAdapter` (`["java", "kotlin"]`, ADR-0005). + `@objectAdapter` (`["java", "kotlin"]`, ADR-0005). ### EXCLUDED from v1 — deferred follow-ons (documented, not silent) diff --git a/fixtures/registry-conformance/expected-registry.json b/fixtures/registry-conformance/expected-registry.json index bd8be1378f..0acf77e54d 100644 --- a/fixtures/registry-conformance/expected-registry.json +++ b/fixtures/registry-conformance/expected-registry.json @@ -3505,14 +3505,14 @@ "whenToUse": "The thing owns its own data and lifecycle — a table you create/read/update/delete. The default for any persisted resource.", "attrs": [ { - "name": "dbSetName", + "name": "collectionName", "valueType": "string", "isArray": false, "required": false, - "ports": [ + "implementedIn": [ "csharp" ], - "description": "C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it." + "description": "The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own)." }, { "name": "discriminator", @@ -3638,14 +3638,14 @@ "whenToUse": "You need a derived, read-only view over entities — a report, summary, or joined/aggregated read model. Declare it (with origin.* children) instead of hand-writing the SELECT/joins; it stays read-only and regenerates.", "attrs": [ { - "name": "dbSetName", + "name": "collectionName", "valueType": "string", "isArray": false, "required": false, - "ports": [ + "implementedIn": [ "csharp" ], - "description": "C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it." + "description": "The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own)." }, { "name": "filter", @@ -3728,14 +3728,14 @@ "whenToUse": "Dashboard totals, per-day series and per-group summaries that would otherwise be hand-written GROUP BY queries.", "attrs": [ { - "name": "dbSetName", + "name": "collectionName", "valueType": "string", "isArray": false, "required": false, - "ports": [ + "implementedIn": [ "csharp" ], - "description": "C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it." + "description": "The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own)." }, { "name": "dimensions", diff --git a/server/csharp/MetaObjects.Codegen.Tests/CSharpNamingTests.cs b/server/csharp/MetaObjects.Codegen.Tests/CSharpNamingTests.cs index 96278f7f30..27be88dadf 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/CSharpNamingTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/CSharpNamingTests.cs @@ -268,7 +268,7 @@ private static MetaRoot PackagedObjects(params NamedObject[] objects) { var sources = objects.Select((o, i) => { - var objAttrs = (o.Abstract ? "\"abstract\": true, " : "") + (o.DbSetName is { } d ? $"\"@dbSetName\": \"{d}\", " : ""); + var objAttrs = (o.Abstract ? "\"abstract\": true, " : "") + (o.DbSetName is { } d ? $"\"@collectionName\": \"{d}\", " : ""); var schema = o.Schema is { } sc ? $@", ""@schema"": ""{sc}""" : ""; var source = o.Source ? $@"{{ ""source.rdb"": {{ ""@table"": ""{o.Name.ToLowerInvariant()}s""{schema} }} }}," @@ -372,7 +372,7 @@ public void NamingConformance_two_emitting_entities_in_one_context_are_refused_b var root = PackagedObjects(ReadObject(c.GetProperty("entityA")), ReadObject(c.GetProperty("entityB"))); var ex = Assert.Throws(() => Run(new DbContextGenerator(), root)); Assert.Contains($"DbSet property \"{c.GetProperty("collidesOn").GetString()}\"", ex.Message); - Assert.Contains("@dbSetName", ex.Message); + Assert.Contains("@collectionName", ex.Message); } } @@ -410,7 +410,7 @@ public void DbSetName_names_the_property_and_resolves_a_same_context_collision() Assert.Contains("Payers { get; set; }", context); } - // A @dbSetName that is no C# identifier, or names a DbContext member, would fail at compile + // A @collectionName that is no C# identifier, or names a DbContext member, would fail at compile // time (or hide Database / Model); codegen refuses it by name. [Theory] [InlineData("Payer Rows")] @@ -421,12 +421,12 @@ public void A_dbSetName_that_cannot_be_the_property_is_refused_at_codegen(string { var root = PackagedObjects(new NamedObject("core", "Payer", false, true, DbSetName: bad)); var ex = Assert.Throws(() => Run(new DbContextGenerator(), root)); - Assert.Contains("@dbSetName", ex.Message); + Assert.Contains("@collectionName", ex.Message); Assert.Contains($"\"{bad}\"", ex.Message); } // One context, one relation per (schema, name). Two objects that map to one - // table would silently share it; the DbSet rule no longer catches them once @dbSetName splits + // table would silently share it; the DbSet rule no longer catches them once @collectionName splits // the property names. [Theory] [InlineData("billing")] diff --git a/server/csharp/MetaObjects.Codegen.Tests/DbContextForeignKeyConfigTests.cs b/server/csharp/MetaObjects.Codegen.Tests/DbContextForeignKeyConfigTests.cs index b8bbc191fd..f249ac48d3 100644 --- a/server/csharp/MetaObjects.Codegen.Tests/DbContextForeignKeyConfigTests.cs +++ b/server/csharp/MetaObjects.Codegen.Tests/DbContextForeignKeyConfigTests.cs @@ -138,11 +138,11 @@ private static MetaRoot Load(string model) [Fact] public void Fk_property_is_named_by_typed_lambda_not_nameof() { - // The pair shares one context only once @dbSetName gives Address another DbSet; the + // The pair shares one context only once @collectionName gives Address another DbSet; the // entity type Addresses still meets a DbSet property of its own name, which is the // hazard the typed lambda pins. var renamed = DbSetNameCollisionModel.Replace( - "\"name\": \"Address\", \"children\"", "\"name\": \"Address\", \"@dbSetName\": \"AddressRows\", \"children\""); + "\"name\": \"Address\", \"children\"", "\"name\": \"Address\", \"@collectionName\": \"AddressRows\", \"children\""); var src = Assert.Single(new DbContextGenerator().Generate(Ctx(Load(renamed)))).Content; Assert.Contains( diff --git a/server/csharp/MetaObjects.Codegen/CSharpNaming.cs b/server/csharp/MetaObjects.Codegen/CSharpNaming.cs index 839a90be56..ae102e5420 100644 --- a/server/csharp/MetaObjects.Codegen/CSharpNaming.cs +++ b/server/csharp/MetaObjects.Codegen/CSharpNaming.cs @@ -365,21 +365,21 @@ public static string PropertyName(MetaField field, GenConfig? config) => public static string RoutePath(MetaObject entity) => PluralizeCrossPort(ToSnakeCase(entity.Name)); /// - /// The DbSet property name for an object: its @dbSetName when it declares one, else + /// The DbSet property name for an object: its @collectionName when it declares one, else /// Pluralize(Pascal(name)). Every site that names the property (the context, the /// routes, the reverse finders, the api reference) reads it here, so they always agree. /// public static string DbSetName(MetaObject entity) { - // ADR-0039: own — @dbSetName names THIS object's property; an abstract base's value + // ADR-0039: own — @collectionName names THIS object's property; an abstract base's value // inherited by every subtype would hand them all one name. - if (entity.OwnAttr(OBJECT_ATTR_DB_SET_NAME) is not string { Length: > 0 } declared) + if (entity.OwnAttr(OBJECT_ATTR_COLLECTION_NAME) is not string { Length: > 0 } declared) return Pluralize(Pascal(entity.Name)); // Refused here, the one place every generator reads it from, rather than as a compile // error in the generated context (or a property that hides DbContext.Database). if (!System.Text.RegularExpressions.Regex.IsMatch(declared, "^[A-Za-z_][A-Za-z0-9_]*$") || ReservedDbSetNames.Contains(declared)) throw new InvalidOperationException( - $"{entity.ResolutionKey()}: @dbSetName \"{declared}\" cannot be the DbSet property name — it must be a C# identifier " + + $"{entity.ResolutionKey()}: @collectionName \"{declared}\" cannot be the DbSet property name — it must be a C# identifier " + "(letters, digits and underscores, not starting with a digit) and not a DbContext member (Database, Model, ChangeTracker, ContextId)."); return declared; } diff --git a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs index ccd96d12a1..7be5950fc7 100644 --- a/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs +++ b/server/csharp/MetaObjects.Codegen/Generators/DbContextGenerator.cs @@ -104,7 +104,7 @@ public virtual IEnumerable Generate(GenContext ctx) ? new[] { (DbSetPropertyName(o, ctx), o.ResolutionKey()), (CSharpNaming.ViewDbSetName(o), o.ResolutionKey()) } : new[] { (DbSetPropertyName(o, ctx), o.ResolutionKey()) }), "DbSet property", "one generated context", - "rename one, or set @dbSetName on one of them"); + "rename one, or set @collectionName on one of them"); // In one context each relation (or routine) is one object's. Two objects // that resolve to one (schema, name) would silently read and write one table. AssertOnePhysicalAddress(objects, ctx); @@ -614,7 +614,7 @@ protected virtual string DbSetLine(MetaObject entity, GenContext ctx) } /// - /// Hook — the DbSet property name for an object: its @dbSetName, else the derived + /// Hook — the DbSet property name for an object: its @collectionName, else the derived /// plural (). The context's collision check compares /// this name, so an override that renames the property is checked as it emits it. /// diff --git a/server/csharp/MetaObjects.Conformance.Tests/CollectionNameVocabularyTests.cs b/server/csharp/MetaObjects.Conformance.Tests/CollectionNameVocabularyTests.cs new file mode 100644 index 0000000000..dbc84fc4a1 --- /dev/null +++ b/server/csharp/MetaObjects.Conformance.Tests/CollectionNameVocabularyTests.cs @@ -0,0 +1,49 @@ +// @collectionName is shared vocabulary: an object's collection name (the EF Core DbSet property). +// It is in the shared registry marked implementedIn ["csharp"], so every port loads and +// type-checks it. It loads under the strict +// loader on object.entity / projection / report. + +using MetaObjects; +using MetaObjects.Loader; +using Xunit; + +namespace MetaObjects.Conformance.Tests; + +public sealed class CollectionNameVocabularyTests +{ + private const string Model = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "Diagnosis", "@collectionName": "Diagnosis", "children": [ + { "source.rdb": { "@table": "diagnoses" } }, + { "field.long": { "name": "id" } }, + { "identity.primary": { "@fields": "id" } } + ] } } + ] } } + """; + + [Fact] + public void A_strict_load_accepts_collectionName_on_an_entity() + { + var result = new MetaDataLoader(Provider.ComposeRegistry(CoreTypes.LibraryProviders), strict: true) + .Load([new InMemoryStringSource(Model, id: "meta.json")]); + Assert.Empty(result.Errors.Select(e => e.Code + ": " + e.Message)); + Assert.Equal("Diagnosis", result.Root.Objects().Single().OwnAttr("collectionName")); + } + + [Fact] + public void A_strict_load_refuses_the_never_released_dbSetName() + { + var result = new MetaDataLoader(Provider.ComposeRegistry(CoreTypes.LibraryProviders), strict: true) + .Load([new InMemoryStringSource(Model.Replace("@collectionName", "@dbSetName"), id: "meta.json")]); + Assert.Contains(result.Errors, e => e.Code == ErrorCode.ERR_UNKNOWN_ATTR); + } + + [Fact] + public void It_is_shared_vocabulary_marked_by_implementing_port() + { + Assert.Equal(RegistryManifest.ExclusionReason.Included, RegistryManifest.ClassifyPerTypeAttr("collectionName")); + var registry = Provider.ComposeRegistry(CoreTypes.LibraryProviders); + var attr = registry.Find("object", "entity")!.Attributes.Single(a => a.Name == "collectionName"); + Assert.Equal(new[] { "csharp" }, attr.ImplementedIn); + } +} diff --git a/server/csharp/MetaObjects.Conformance.Tests/DbSetNameVocabularyTests.cs b/server/csharp/MetaObjects.Conformance.Tests/DbSetNameVocabularyTests.cs deleted file mode 100644 index 935de01ede..0000000000 --- a/server/csharp/MetaObjects.Conformance.Tests/DbSetNameVocabularyTests.cs +++ /dev/null @@ -1,41 +0,0 @@ -// @dbSetName is C#-only vocabulary: the EF Core DbSet property name the generated -// context gives an object. It is in the shared registry with `ports: ["csharp"]`, so every port -// loads and type-checks it and only C# acts on it. It loads under the strict loader on -// object.entity / projection / report. - -using MetaObjects; -using MetaObjects.Loader; -using Xunit; - -namespace MetaObjects.Conformance.Tests; - -public sealed class DbSetNameVocabularyTests -{ - private const string Model = """ - { "metadata.root": { "package": "acme", "children": [ - { "object.entity": { "name": "Diagnosis", "@dbSetName": "Diagnosis", "children": [ - { "source.rdb": { "@table": "diagnoses" } }, - { "field.long": { "name": "id" } }, - { "identity.primary": { "@fields": "id" } } - ] } } - ] } } - """; - - [Fact] - public void A_strict_load_accepts_dbSetName_on_an_entity() - { - var result = new MetaDataLoader(Provider.ComposeRegistry(CoreTypes.LibraryProviders), strict: true) - .Load([new InMemoryStringSource(Model, id: "meta.json")]); - Assert.Empty(result.Errors.Select(e => e.Code + ": " + e.Message)); - Assert.Equal("Diagnosis", result.Root.Objects().Single().OwnAttr("dbSetName")); - } - - [Fact] - public void It_is_shared_vocabulary_scoped_to_csharp() - { - Assert.Equal(RegistryManifest.ExclusionReason.Included, RegistryManifest.ClassifyPerTypeAttr("dbSetName")); - var registry = Provider.ComposeRegistry(CoreTypes.LibraryProviders); - var attr = registry.Find("object", "entity")!.Attributes.Single(a => a.Name == "dbSetName"); - Assert.Equal(new[] { "csharp" }, attr.Ports); - } -} diff --git a/server/csharp/MetaObjects.Conformance.Tests/NamingStrategiesTests.cs b/server/csharp/MetaObjects.Conformance.Tests/NamingStrategiesTests.cs index 39acc929c2..5213663ae8 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/NamingStrategiesTests.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/NamingStrategiesTests.cs @@ -140,4 +140,29 @@ public void The_config_reader_can_leave_an_unknown_name_to_resolution() Directory.Delete(dir, recursive: true); } } + + // naming.collections selects a pluralizer by name; an unknown one is refused at read. + [Fact] + public void The_config_reader_selects_a_pluralizer_and_refuses_an_unknown_one() + { + var dir = Path.Combine(Path.GetTempPath(), "mo-naming-collections-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path.Combine(dir, ".metaobjects")); + try + { + var config = Path.Combine(dir, ".metaobjects", "config.json"); + File.WriteAllText(config, """{ "schema_version": 1, "naming": { "collections": "none" } }"""); + Assert.Equal("none", NeutralConfig.Read(dir)!.Naming.Collections); + + File.WriteAllText(config, """{ "schema_version": 1 }"""); + Assert.Null(NeutralConfig.Read(dir)!.Naming.Collections); + + File.WriteAllText(config, """{ "schema_version": 1, "naming": { "collections": "klingon" } }"""); + var ex = Assert.Throws(() => NeutralConfig.Read(dir)); + Assert.Contains("\"naming.collections\" names \"klingon\"", ex.Message); + } + finally + { + Directory.Delete(dir, recursive: true); + } + } } diff --git a/server/csharp/MetaObjects/Config/NeutralConfig.cs b/server/csharp/MetaObjects/Config/NeutralConfig.cs index 7d13b9a414..d80ba04fec 100644 --- a/server/csharp/MetaObjects/Config/NeutralConfig.cs +++ b/server/csharp/MetaObjects/Config/NeutralConfig.cs @@ -273,6 +273,7 @@ private NeutralConfig( var columns = ColumnNamingStrategy.SnakeCase; var tables = TableNamingRule.SnakeCase; + string? collections = null; if (root.TryGetProperty("naming", out var naming)) { // Present but not an object — the same "wrong-shaped raises" rule as @@ -317,9 +318,22 @@ private NeutralConfig( } tables = table; break; + // The pluralizer an object without @collectionName takes its collection + // name from; refused here when nothing answers it. + case "collections": + if (p.Value.ValueKind != JsonValueKind.String) + throw new MetaModelException( + $"{path}: \"naming.collections\" must be a string naming a pluralizer", + ErrorCode.ERR_BAD_ATTR_VALUE); + collections = p.Value.GetString()!; + if (validateNaming && !NamingStrategies.TryGetPluralizer(collections, out _)) + throw new MetaModelException( + $"{path}: {NamingStrategies.UnknownMessage("collections", collections)}", + ErrorCode.ERR_BAD_ATTR_VALUE); + break; default: throw new MetaModelException( - $"{path}: \"naming\" has an unknown key \"{p.Name}\" (expected \"columns\" and/or \"tables\")", + $"{path}: \"naming\" has an unknown key \"{p.Name}\" (expected \"columns\", \"tables\" and/or \"collections\")", ErrorCode.ERR_BAD_ATTR_VALUE); } } @@ -327,7 +341,7 @@ private NeutralConfig( // Unknown top-level keys, and every key of `scope` / `migrate` but the two pattern // lists read above, are IGNORED by design — see the file header. - return new NeutralConfig(specs, libraries, new PhysicalNaming(columns, tables), ReadWire(root, path), + return new NeutralConfig(specs, libraries, new PhysicalNaming(columns, tables) { Collections = collections }, ReadWire(root, path), ReadCSharp(root, path, configDir), dependencies, scopeInclude, migrateScope, Path.GetFullPath(configDir)) { diff --git a/server/csharp/MetaObjects/Core/Object/ObjectConstants.cs b/server/csharp/MetaObjects/Core/Object/ObjectConstants.cs index 8f74e8c38e..cdee773888 100644 --- a/server/csharp/MetaObjects/Core/Object/ObjectConstants.cs +++ b/server/csharp/MetaObjects/Core/Object/ObjectConstants.cs @@ -47,12 +47,10 @@ public static class ObjectConstants /// public const string OBJECT_ATTR_DISCRIMINATOR_VALUE = "discriminatorValue"; - /// - /// The collection property a per-context ORM mapping gives an object (the EF Core DbSet - /// property name), in place of the plural derived from its name. On object.entity and on the - /// view-backed object.projection / object.report that get a DbSet. - /// - public const string OBJECT_ATTR_DB_SET_NAME = "dbSetName"; + /// The object's collection name (the EF Core DbSet property), in place of the plural + /// derived from its name. On + /// object.entity, object.projection and object.report. + public const string OBJECT_ATTR_COLLECTION_NAME = "collectionName"; /// /// The native class an object binds to (ADR-0001). Cross-port vocabulary only diff --git a/server/csharp/MetaObjects/Core/Object/ObjectSchema.cs b/server/csharp/MetaObjects/Core/Object/ObjectSchema.cs index 8be451bb0c..0aa43f1b57 100644 --- a/server/csharp/MetaObjects/Core/Object/ObjectSchema.cs +++ b/server/csharp/MetaObjects/Core/Object/ObjectSchema.cs @@ -55,18 +55,17 @@ public static class ObjectSchema ]; /// - /// @dbSetName — the DbSet property name the generated context gives the object, in place of - /// the derived plural. Registered on object.entity, object.projection and object.report - /// (see CoreTypes); the description is single-sourced from spec/metamodel/object.json. + /// @collectionName, the name of the object's collection in its own casing (the C# DbSet + /// property). Registered on object.entity, object.projection and + /// object.report (see CoreTypes); the description is single-sourced from spec/metamodel/object.json. /// - public static readonly AttrSchema DbSetNameAttr = + public static readonly AttrSchema CollectionNameAttr = new AttrSchema( - Name: ObjectConstants.OBJECT_ATTR_DB_SET_NAME, + Name: ObjectConstants.OBJECT_ATTR_COLLECTION_NAME, ValueType: AttrConstants.ATTR_SUBTYPE_STRING, Required: false, Description: - "The name of the collection property a per-context ORM mapping gives this object " + - "(the C# EF Core DbSet property), in place of the plural derived from its name."); + "The name of this object's collection, in the object's own casing: the C# DbSet property."); /// /// #207 — the optional row-scope @filter on an object.projection (a portable diff --git a/server/csharp/MetaObjects/CoreTypes.cs b/server/csharp/MetaObjects/CoreTypes.cs index 4fc5fe9c0a..44bb64208b 100644 --- a/server/csharp/MetaObjects/CoreTypes.cs +++ b/server/csharp/MetaObjects/CoreTypes.cs @@ -319,9 +319,9 @@ private static void RegisterCoreTypeDefs(TypeRegistry registry) // declare; its structural child graph also comes from the spec. List objectAttrs = subType switch { - OBJECT_SUBTYPE_PROJECTION => [.. ObjectSchema.ObjectAttrs, ObjectSchema.ProjectionFilterAttr, ObjectSchema.DbSetNameAttr], - OBJECT_SUBTYPE_REPORT => [.. ObjectSchema.ObjectAttrs, .. ReportingSchema.ReportAttrs, ObjectSchema.DbSetNameAttr], - OBJECT_SUBTYPE_ENTITY => [.. ObjectSchema.ObjectAttrs, ObjectSchema.DbSetNameAttr], + OBJECT_SUBTYPE_PROJECTION => [.. ObjectSchema.ObjectAttrs, ObjectSchema.ProjectionFilterAttr, ObjectSchema.CollectionNameAttr], + OBJECT_SUBTYPE_REPORT => [.. ObjectSchema.ObjectAttrs, .. ReportingSchema.ReportAttrs, ObjectSchema.CollectionNameAttr], + OBJECT_SUBTYPE_ENTITY => [.. ObjectSchema.ObjectAttrs, ObjectSchema.CollectionNameAttr], _ => ObjectSchema.ObjectAttrs.ToList(), }; diff --git a/server/csharp/MetaObjects/Meta/CollectionNames.cs b/server/csharp/MetaObjects/Meta/CollectionNames.cs new file mode 100644 index 0000000000..843914d43b --- /dev/null +++ b/server/csharp/MetaObjects/Meta/CollectionNames.cs @@ -0,0 +1,46 @@ +// An object's collection name: its own @collectionName, else the project's pluralizer +// (naming.collections) over its name. Ported from +// typescript/packages/metadata/src/naming.ts (collectionNameOf, declaredCollectionName). + +using static MetaObjects.Core.Object.ObjectConstants; + +namespace MetaObjects.Meta; + +public static class CollectionNames +{ + private static readonly char[] AlreadyPluralExcludedPrecedingChars = ['s', 'u', 'i', 'a']; + + /// + /// The collection name of , in its own casing: declared, else the project's + /// pluralizer over its name. Own, not resolving: a name inherited from an abstract base would hand + /// every subtype one collection. + /// + public static string Of(MetaObject obj) => + Declared(obj) ?? NamingStrategies.Pluralizer(ConfiguredPluralizer(obj) ?? NamingStrategies.DefaultPluralizer)(obj.Name); + + /// The pluralizer the project configures (naming.collections), or null when it names none. + public static string? ConfiguredPluralizer(MetaData node) => PhysicalNaming.Of(node).Collections; + + /// The collection name declares (@collectionName), or null. + public static string? Declared(MetaObject obj) => + // ADR-0039: own — a collection name is the declaring object's alone (see Of). + obj.OwnAttr(OBJECT_ATTR_COLLECTION_NAME) is string { Length: > 0 } own ? own : null; + + /// + /// The standard pluralizer, the rule every port shares (fixtures/naming-conformance): + /// an already-plural name stays, else the suffix rule. + /// + public static string StandardPluralize(string s) + { + if (s.Length >= 2 && s.EndsWith("s", StringComparison.OrdinalIgnoreCase) + && Array.IndexOf(AlreadyPluralExcludedPrecedingChars, char.ToLowerInvariant(s[^2])) < 0) + return s; + if (s.EndsWith("s", StringComparison.OrdinalIgnoreCase) || s.EndsWith("x", StringComparison.OrdinalIgnoreCase) || + s.EndsWith("z", StringComparison.OrdinalIgnoreCase) || s.EndsWith("ch", StringComparison.OrdinalIgnoreCase) || + s.EndsWith("sh", StringComparison.OrdinalIgnoreCase)) + return s + "es"; + if (s.Length > 1 && (s[^1] == 'y' || s[^1] == 'Y') && !"aeiouAEIOU".Contains(s[^2])) + return s[..^1] + "ies"; + return s + "s"; + } +} diff --git a/server/csharp/MetaObjects/Meta/PhysicalNaming.cs b/server/csharp/MetaObjects/Meta/PhysicalNaming.cs index 0ef85c3efd..e941e0cfd7 100644 --- a/server/csharp/MetaObjects/Meta/PhysicalNaming.cs +++ b/server/csharp/MetaObjects/Meta/PhysicalNaming.cs @@ -158,6 +158,14 @@ public static class NamingStrategies private static readonly object Gate = new(); private static readonly List ColumnList = []; private static readonly List TableList = []; + private static readonly List<(string Name, Func Apply)> PluralizerList = []; + + /// The pluralizers every port builds in: standard (the cross-port + /// rule: already-plural detection, then the suffix rule) and none (the name as it is). + public static IReadOnlyList BuiltInPluralizers { get; } = ["standard", "none"]; + + /// The pluralizer an object without @collectionName gets when naming.collections is absent. + public const string DefaultPluralizer = "standard"; /// The column rules every port builds in, in the order the conformance corpus lists them. public static IReadOnlyList BuiltInColumns { get; } = @@ -170,8 +178,41 @@ static NamingStrategies() { ColumnList.AddRange(BuiltInColumns); TableList.AddRange(BuiltInTables); + PluralizerList.AddRange(BuiltInPluralizerFunctions()); + } + + private static IEnumerable<(string Name, Func Apply)> BuiltInPluralizerFunctions() => + [("standard", CollectionNames.StandardPluralize), ("none", n => n)]; + + /// + /// Registers a pluralizer under : an object name in, its + /// collection name out. "naming": { "collections": "<name>" } then selects it; an + /// object's own @collectionName always wins. A built-in cannot be replaced; registering + /// one of your own again replaces it. + /// + public static void RegisterPluralizer(string name, Func apply) + { + Validate(name, apply, BuiltInPluralizers.Contains(name), "collection-naming (pluralizer)"); + lock (Gate) { Upsert(PluralizerList, (Name: name, Apply: apply), p => p.Name); } } + /// Every registered pluralizer's name: the built-ins, then the application's. + public static IReadOnlyList Pluralizers + { + get { lock (Gate) { return [.. PluralizerList.Select(p => p.Name)]; } } + } + + /// The pluralizer registered as , if any. + public static bool TryGetPluralizer(string name, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out Func? apply) + { + lock (Gate) { apply = PluralizerList.FirstOrDefault(p => p.Name == name).Apply; } + return apply is not null; + } + + /// The pluralizer registered as . Throws, naming it, when nothing answers it. + public static Func Pluralizer(string name) => + TryGetPluralizer(name, out var p) ? p : throw new MetaModelException(UnknownMessage("collections", name), ErrorCode.ERR_BAD_ATTR_VALUE); + /// Every registered column strategy: the built-ins in corpus order, then the application's in registration order. public static IReadOnlyList Columns { @@ -243,6 +284,8 @@ public static void ResetForTesting() ColumnList.AddRange(BuiltInColumns); TableList.Clear(); TableList.AddRange(BuiltInTables); + PluralizerList.Clear(); + PluralizerList.AddRange(BuiltInPluralizerFunctions()); } } @@ -254,10 +297,13 @@ public static void ResetForTesting() public static string UnknownMessage(string key, string name) { var columns = key == "columns"; - var builtIns = columns ? BuiltInColumns.Select(s => s.Name).ToList() : BuiltInTables.Select(s => s.Name).ToList(); - var registered = (columns ? Columns.Select(s => s.Name) : Tables.Select(s => s.Name)) + var collections = key == "collections"; + var builtIns = columns ? BuiltInColumns.Select(s => s.Name).ToList() + : collections ? [.. BuiltInPluralizers] : BuiltInTables.Select(s => s.Name).ToList(); + var registered = (columns ? Columns.Select(s => s.Name) : collections ? Pluralizers : Tables.Select(s => s.Name)) .Where(n => !builtIns.Contains(n)).ToList(); - return $"\"naming.{key}\" names \"{name}\", which no {(columns ? "column" : "table")}-naming strategy registered in the " + var kind = columns ? "column-naming" : collections ? "collection-naming (pluralizer)" : "table-naming"; + return $"\"naming.{key}\" names \"{name}\", which no {kind} strategy registered in the " + $"{Port} port answers (built in: {string.Join(", ", builtIns)}; registered by the application: " + $"{(registered.Count == 0 ? "none" : string.Join(", ", registered))}). Register it before " + ".metaobjects/config.json is read, or correct the name."; @@ -285,6 +331,11 @@ private static void Upsert(List list, T item, Func nameOf) /// public sealed record PhysicalNaming(ColumnNamingStrategy Columns, TableNamingRule Tables) { + /// The pluralizer an object without @collectionName takes its + /// collection name from (naming.collections); null when the project names none, which + /// means . + public string? Collections { get; init; } + /// The default in every port: snake_case columns, snake_case tables. public static readonly PhysicalNaming Default = new(ColumnNamingStrategy.SnakeCase, TableNamingRule.SnakeCase); diff --git a/server/csharp/MetaObjects/RegistryManifest.cs b/server/csharp/MetaObjects/RegistryManifest.cs index 0e80bdfcd1..9fe0b7d4c1 100644 --- a/server/csharp/MetaObjects/RegistryManifest.cs +++ b/server/csharp/MetaObjects/RegistryManifest.cs @@ -41,7 +41,7 @@ public static class RegistryManifest // - NOT carved out: vocabulary only some ports act on. Kind 1 // (implemented in some ports so far — the generic `view.*` controls, // `@object`) carries `implementedIn`; kind 2 (language-specific — - // `@dbSetName`, `@objectAdapter`) carries `ports`. Both are IN the manifest. + // `@objectAdapter`) carries `ports`. Both are IN the manifest. // ------------------------------------------------------------------ // Wave 3b — the in/out boundary is an EXPLICIT CLASSIFICATION (a reason diff --git a/server/csharp/MetaObjects/SpecMetamodel/object.json b/server/csharp/MetaObjects/SpecMetamodel/object.json index f9d9280409..8c9f73ae9e 100644 --- a/server/csharp/MetaObjects/SpecMetamodel/object.json +++ b/server/csharp/MetaObjects/SpecMetamodel/object.json @@ -31,7 +31,7 @@ { "type": "segment", "subType": "*", "name": "*", "min": 0, "max": null }, { "type": "attr", "subType": "string", "name": "discriminator", "min": 0, "max": 1, "description": "FR-014: names the field on this entity (resolvable via extends:) that holds the subtype-discriminator value. Subtypes of this entity declare @discriminatorValue to bind their rows to a discriminator value. The discriminator field itself is an ordinary field declaration (typically field.enum or field.int / field.string)." }, { "type": "attr", "subType": "string", "name": "discriminatorValue", "min": 0, "max": 1, "description": "FR-014: on a subtype of an entity with @discriminator — the value that identifies rows of this subtype in the shared discriminator field. Wire form is always a string; the underlying field's subtype (enum / int / string) controls codegen + storage coercion. Required on every concrete subtype of a discriminated entity." }, - { "type": "attr", "subType": "string", "name": "dbSetName", "min": 0, "max": 1, "ports": ["csharp"], "description": "C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it." } + { "type": "attr", "subType": "string", "name": "collectionName", "min": 0, "max": 1, "implementedIn": ["csharp"], "description": "The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own)." } ] }, { @@ -54,7 +54,7 @@ "rules": "object.projection is a derived read-only representation: every field is extends-bound, origin-derived, or self-declared-under-external-assembly, and all are read-only at the subtype level. Identity is optional and, when present, MUST extend an entity identity. Sources are restricted to read-only @kinds. The declared field set IS the exposure — an inclusive list, fail-closed. A projection NEVER declares relationships (derivation is expressed via @via, not a relationship child) and NEVER co-locates templates — hence its child set omits both relationship and template. The assembly origins (origin.aggregate / origin.computed / origin.first) live HERE, not on object.value; a SOURCELESS projection is the legal shape for a wire-assembled read model, and a template-level @payloadRef/@responseRef may target one (#210). See ADR-0028 (object taxonomy, projection).", "children": [ { "type": "attr", "subType": "filter", "name": "filter", "min": 0, "max": 1, "description": "Optional row-scope predicate (a portable attr.filter object: eq/ne/gt/gte/lt/lte/like/in/isNull with and/or, desugared to { field: { op: value } } at parse time) selecting which rows the view returns — lowered to an outer SQL WHERE. Resolves against the projection's own declared fields; an aggregate-derived field is not addressable (fail-closed)." }, - { "type": "attr", "subType": "string", "name": "dbSetName", "min": 0, "max": 1, "ports": ["csharp"], "description": "C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it." } + { "type": "attr", "subType": "string", "name": "collectionName", "min": 0, "max": 1, "implementedIn": ["csharp"], "description": "The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own)." } ] }, { @@ -70,7 +70,7 @@ { "type": "attr", "subType": "string", "name": "segment", "min": 0, "max": 1, "description": "Optional segment of @from scoping the rows." }, { "type": "attr", "subType": "filter", "name": "filter", "min": 0, "max": 1, "description": "Optional row scope over @from's fields; may use relative-date values." }, { "type": "attr", "subType": "string", "name": "spine", "min": 0, "max": 1, "description": "Optional to-one path from @from to the entity whose rows supply the report's rows (e.g. 'Purchase.program'), written like a dimension's @via. With it the report has one row per distinct dimension tuple among THAT entity's rows, including the ones no row of @from refers to: a count there is 0 and any other measure is null unless it declares @default. Every listed dimension must be reached through this path. @segment and @filter still scope the rows of @from and never remove a row. A row of @from whose reference is null or matches nothing is in no row of the report." }, - { "type": "attr", "subType": "string", "name": "dbSetName", "min": 0, "max": 1, "ports": ["csharp"], "description": "C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it." } + { "type": "attr", "subType": "string", "name": "collectionName", "min": 0, "max": 1, "implementedIn": ["csharp"], "description": "The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own)." } ] } ] diff --git a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/PortScopedAttrKtTest.kt b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/PortScopedAttrKtTest.kt index 778eace974..9c5254ccfd 100644 --- a/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/PortScopedAttrKtTest.kt +++ b/server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/PortScopedAttrKtTest.kt @@ -9,9 +9,9 @@ import kotlin.test.assertEquals import kotlin.test.assertTrue /** - * The Kotlin half of port-scoped vocabulary: `@dbSetName` (C#-only, `ports: ["csharp"]`) loads under the strict - * loader the Kotlin generators read, and Kotlin does nothing with it; a wrong value type and an attribute the - * registry does not carry still fail. + * The Kotlin half of vocabulary another port implements: `@collectionName` (`implementedIn: + * ["csharp"]`) loads under the strict loader the Kotlin generators read; a wrong value type, the + * never-released `@dbSetName` and an attribute the registry does not carry still fail. */ class PortScopedAttrKtTest { private fun model(attr: String) = """ @@ -35,9 +35,11 @@ class PortScopedAttrKtTest { ObjectClassRegistry.resetGlobal() } - @Test fun `a csharp-only attribute loads`() = assertEquals(emptyList(), errors("\"@dbSetName\": \"Diagnosis\",")) + @Test fun `a collection name loads`() = assertEquals(emptyList(), errors("\"@collectionName\": \"Diagnoses\",")) - @Test fun `it is type-checked`() = assertTrue(errors("\"@dbSetName\": [\"Diagnosis\"],").isNotEmpty()) + @Test fun `it is type-checked`() = assertTrue(errors("\"@collectionName\": [\"Diagnoses\"],").isNotEmpty()) - @Test fun `an unregistered attribute still fails`() = assertTrue(errors("\"@dbSetNameX\": \"Diagnosis\",").isNotEmpty()) + @Test fun `the never-released dbSetName is not vocabulary`() = assertTrue(errors("\"@dbSetName\": \"Diagnoses\",").isNotEmpty()) + + @Test fun `an unregistered attribute still fails`() = assertTrue(errors("\"@collectionNameX\": \"Diagnoses\",").isNotEmpty()) } 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 b01a309433..3ee082e13d 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 @@ -303,6 +303,7 @@ public static NeutralConfig parse(String content, String origin) { // otherwise bind a whole schema to the wrong names and report success). String columns = PhysicalNaming.DEFAULT_COLUMNS; String tables = PhysicalNaming.DEFAULT_TABLES; + String collections = null; JsonElement naming = root.get("naming"); if (naming != null && !(naming.isJsonObject())) { throw new MetaDataException( @@ -312,10 +313,10 @@ public static NeutralConfig parse(String content, String origin) { if (naming != null) { JsonObject namingObj = naming.getAsJsonObject(); for (String key : namingObj.keySet()) { - if (!"columns".equals(key) && !"tables".equals(key)) { + if (!"columns".equals(key) && !"tables".equals(key) && !"collections".equals(key)) { throw new MetaDataException( origin + ": \"naming\" has unknown key \"" + key - + "\"; expected \"columns\" and/or \"tables\"", + + "\"; expected \"columns\", \"tables\" and/or \"collections\"", ErrorCode.ERR_BAD_ATTR_VALUE); } } @@ -337,10 +338,20 @@ public static NeutralConfig parse(String content, String origin) { } tables = tablesEl.getAsString(); } + // The pluralizer an object without @collectionName takes its collection name from. + JsonElement collectionsEl = namingObj.get("collections"); + if (collectionsEl != null) { + if (!collectionsEl.isJsonPrimitive() || !collectionsEl.getAsJsonPrimitive().isString()) { + throw new MetaDataException( + origin + ": \"naming.collections\" must be a string naming a pluralizer", + ErrorCode.ERR_BAD_ATTR_VALUE); + } + collections = collectionsEl.getAsString(); + } } // PhysicalNaming.of validates the values against the NamingStrategies registry (a // built-in or an application-registered name; ADR-0058 Amendment 1). - PhysicalNaming physicalNaming = PhysicalNaming.of(columns, tables); + PhysicalNaming physicalNaming = PhysicalNaming.of(columns, tables, collections); WireFormat wireFormat = parseWire(root.get("wire"), origin); diff --git a/server/java/metadata/src/main/java/com/metaobjects/database/NamingStrategies.java b/server/java/metadata/src/main/java/com/metaobjects/database/NamingStrategies.java index 17f3b95a7c..6fb3373c9d 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/database/NamingStrategies.java +++ b/server/java/metadata/src/main/java/com/metaobjects/database/NamingStrategies.java @@ -50,6 +50,17 @@ public interface NamingFunction { private static final Map COLUMNS = new LinkedHashMap<>(); private static final Map TABLES = new LinkedHashMap<>(); + /** The cross-port pluralizer (already-plural detection, then the suffix rule). */ + public static final String PLURALIZER_STANDARD = "standard"; + /** The name as it is. */ + public static final String PLURALIZER_NONE = "none"; + /** The pluralizers every port builds in. */ + public static final List BUILT_IN_PLURALIZERS = List.of(PLURALIZER_STANDARD, PLURALIZER_NONE); + /** The pluralizer an object without {@code @collectionName} gets when {@code naming.collections} is absent. */ + public static final String DEFAULT_PLURALIZER = PLURALIZER_STANDARD; + + private static final Map PLURALIZERS = new LinkedHashMap<>(); + static { COLUMNS.put(ColumnNaming.SNAKE_CASE, ColumnNaming::toSnakeCase); COLUMNS.put(ColumnNaming.LITERAL, n -> n); @@ -57,6 +68,55 @@ public interface NamingFunction { COLUMNS.put(ColumnNaming.PASCAL, ColumnNaming::toPascalColumn); TABLES.put(ColumnNaming.SNAKE_CASE, ColumnNaming::toSnakeCase); TABLES.put(ColumnNaming.LITERAL, n -> n); + PLURALIZERS.put(PLURALIZER_STANDARD, NamingStrategies::standardPluralize); + PLURALIZERS.put(PLURALIZER_NONE, n -> n); + } + + /** + * Registers a pluralizer under {@code name}: an object name in, its collection name out. + * {@code "naming": { "collections": "" }} then selects it; an object's own + * {@code @collectionName} always wins over it. A built-in cannot be replaced. + */ + public static void registerPluralizer(String name, NamingFunction apply) { + register(PLURALIZERS, BUILT_IN_PLURALIZERS, name, apply, "collection-naming (pluralizer)"); + } + + public static synchronized boolean isPluralizer(String name) { + return PLURALIZERS.containsKey(name); + } + + /** Every registered pluralizer's name: the built-ins, then the application's. */ + public static synchronized List registeredPluralizers() { + return new ArrayList<>(PLURALIZERS.keySet()); + } + + /** The pluralizer registered as {@code name}. Throws, naming it, when nothing answers it. */ + public static synchronized NamingFunction pluralizer(String name) { + NamingFunction fn = PLURALIZERS.get(name); + if (fn == null) throw new MetaDataException(unknownMessage("collections", name), ErrorCode.ERR_BAD_ATTR_VALUE); + return fn; + } + + /** + * The {@code standard} pluralizer, the rule every port shares + * ({@code fixtures/naming-conformance/already-plural-pluralize.json}): a name ending in + * {@code s} not preceded by s/u/i/a is already plural and stays; else the suffix rule. + * Case-insensitive, and the name keeps its own casing. + */ + public static String standardPluralize(String s) { + if (s == null || s.isEmpty()) return s; + String lower = s.toLowerCase(java.util.Locale.ROOT); + if (lower.length() >= 2 && lower.endsWith("s") && "suia".indexOf(lower.charAt(lower.length() - 2)) < 0) { + return s; + } + if (lower.endsWith("s") || lower.endsWith("x") || lower.endsWith("z") + || lower.endsWith("ch") || lower.endsWith("sh")) { + return s + "es"; + } + if (lower.length() > 1 && lower.endsWith("y") && "aeiou".indexOf(lower.charAt(lower.length() - 2)) < 0) { + return s.substring(0, s.length() - 1) + "ies"; + } + return s + "s"; } /** @@ -95,6 +155,7 @@ private static void register(Map into, List buil public static synchronized void resetForTesting() { COLUMNS.keySet().retainAll(BUILT_IN_COLUMNS); TABLES.keySet().retainAll(BUILT_IN_TABLES); + PLURALIZERS.keySet().retainAll(BUILT_IN_PLURALIZERS); } /** True when {@code name} is a registered column strategy, built in or the application's. */ @@ -143,13 +204,15 @@ public static synchronized NamingFunction table(String name) { */ public static synchronized String unknownMessage(String key, String name) { boolean columns = "columns".equals(key); - List builtIns = columns ? BUILT_IN_COLUMNS : BUILT_IN_TABLES; + boolean collections = "collections".equals(key); + List builtIns = columns ? BUILT_IN_COLUMNS : collections ? BUILT_IN_PLURALIZERS : BUILT_IN_TABLES; List registered = new ArrayList<>(); - for (String n : (columns ? COLUMNS : TABLES).keySet()) { + for (String n : (columns ? COLUMNS : collections ? PLURALIZERS : TABLES).keySet()) { if (!builtIns.contains(n)) registered.add(n); } - return "\"naming." + key + "\" names \"" + name + "\", which no " + (columns ? "column" : "table") - + "-naming strategy registered in the " + PORT + " port answers (built in: " + String kind = columns ? "column-naming" : collections ? "collection-naming (pluralizer)" : "table-naming"; + return "\"naming." + key + "\" names \"" + name + "\", which no " + kind + + " strategy registered in the " + PORT + " port answers (built in: " + String.join(", ", builtIns) + "; registered by the application: " + (registered.isEmpty() ? "none" : String.join(", ", registered)) + "). Register it before " + ".metaobjects/config.json is read, or correct the name."; 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 c2ce756f07..92a63e014c 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 @@ -29,14 +29,16 @@ public final class PhysicalNaming { public static final String DEFAULT_TABLES = TABLE_SNAKE_CASE; /** Both rules at their default. */ - public static final PhysicalNaming DEFAULT = new PhysicalNaming(DEFAULT_COLUMNS, DEFAULT_TABLES); + public static final PhysicalNaming DEFAULT = new PhysicalNaming(DEFAULT_COLUMNS, DEFAULT_TABLES, null); private final String columns; private final String tables; + private final String collections; - private PhysicalNaming(String columns, String tables) { + private PhysicalNaming(String columns, String tables, String collections) { this.columns = columns; this.tables = tables; + this.collections = collections; } /** @@ -58,7 +60,20 @@ public static PhysicalNaming of(String columns, String tables) { if (!NamingStrategies.isTable(tables)) { throw new MetaDataException(NamingStrategies.unknownMessage("tables", tables), ErrorCode.ERR_BAD_ATTR_VALUE); } - return new PhysicalNaming(columns, tables); + return new PhysicalNaming(columns, tables, null); + } + + /** + * As {@link #of(String, String)}, with the pluralizer an object without + * {@code @collectionName} takes its collection name from ({@code naming.collections}); + * {@code null} means {@link NamingStrategies#DEFAULT_PLURALIZER}. + */ + public static PhysicalNaming of(String columns, String tables, String collections) { + PhysicalNaming base = of(columns, tables); + if (collections != null && !NamingStrategies.isPluralizer(collections)) { + throw new MetaDataException(NamingStrategies.unknownMessage("collections", collections), ErrorCode.ERR_BAD_ATTR_VALUE); + } + return new PhysicalNaming(base.columns, base.tables, collections); } /** The {@code naming.columns} strategy's name — a built-in or an application-registered one. */ @@ -71,6 +86,11 @@ public String tables() { return tables; } + /** The configured pluralizer ({@code naming.collections}), or {@code null} when none is named. */ + public String collections() { + return collections; + } + /** {@code name} cased by the table rule registered as {@code rule} (never pluralized — that * is a separate, frozen step). Throws on a name nothing registered. */ public static String applyTableRule(String name, String rule) { diff --git a/server/java/metadata/src/main/java/com/metaobjects/object/MetaObject.java b/server/java/metadata/src/main/java/com/metaobjects/object/MetaObject.java index b550df994a..66860cbdff 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/object/MetaObject.java +++ b/server/java/metadata/src/main/java/com/metaobjects/object/MetaObject.java @@ -85,10 +85,10 @@ public abstract class MetaObject extends MetaData { /** FR-014: this subtype's discriminator value (matched against the root's {@code @discriminator} field). */ public static final String ATTR_DISCRIMINATOR_VALUE = "discriminatorValue"; - /** C#-only vocabulary (ports ["csharp"]): the EF Core DbSet property name. Loaded and - * type-checked on object.entity / projection / report so a shared model loads here; no JVM - * generator acts on it. */ - public static final String ATTR_DB_SET_NAME = "dbSetName"; + /** An object's collection name: the plural an author declares when the default pluralizer + * gets it wrong. The C# DbSet property reads it; Java loads and type-checks it on + * object.entity / projection / report. */ + public static final String ATTR_COLLECTION_NAME = "collectionName"; /** * #207: an optional row-scope predicate on an {@code object.projection} — a @@ -153,7 +153,7 @@ public static void registerTypes(MetaDataRegistry registry) { def.optionalAttributeWithConstraints(ATTR_DISCRIMINATOR_VALUE).ofType(StringAttribute.SUBTYPE_STRING).asSingle(); // object.json declares it on entity / projection / report; the strict spec scoping // prunes it from the subtypes that do not. - def.optionalAttributeWithConstraints(ATTR_DB_SET_NAME).ofType(StringAttribute.SUBTYPE_STRING).asSingle(); + def.optionalAttributeWithConstraints(ATTR_COLLECTION_NAME).ofType(StringAttribute.SUBTYPE_STRING).asSingle(); // Java-only runtime hint (never in the conformance corpus): the FQN of an // ObjectAdapter that takes over value access. Inherited by entity + value. 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 a866c7d6dd..d1bcb81814 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 @@ -316,9 +316,9 @@ public static List metamodelProviders() { // - metadata.base is Java's internal inheritance anchor — other ports // register only metadata.root (the deferred inheritsFrom anchor). // - NOT carved out: vocabulary only some ports act on. Cross-port vocabulary - // only some ports implement so far (the generic view.* controls, @object) - // carries `implementedIn`; language-specific vocabulary (@dbSetName, - // @objectAdapter) carries `ports`. Both are IN the manifest. + // only some ports implement so far (the generic view.* controls, @object, + // @collectionName) carries `implementedIn`; language-specific vocabulary + // (@objectAdapter) carries `ports`. Both are IN the manifest. // ------------------------------------------------------------------ /** diff --git a/server/java/metadata/src/test/java/com/metaobjects/loader/PortScopedAttrTest.java b/server/java/metadata/src/test/java/com/metaobjects/loader/PortScopedAttrTest.java index bd81cd9c55..804e62e91f 100644 --- a/server/java/metadata/src/test/java/com/metaobjects/loader/PortScopedAttrTest.java +++ b/server/java/metadata/src/test/java/com/metaobjects/loader/PortScopedAttrTest.java @@ -10,9 +10,10 @@ import static org.junit.Assert.assertTrue; /** - * {@code @dbSetName} (the C# EF DbSet property name) is in the shared registry with - * {@code ports: ["csharp"]}. Java registers and type-checks it, so a model that carries it loads - * strictly here, and Java does nothing with it. An attribute the registry does not carry still + * {@code @collectionName} is shared vocabulary marked {@code implementedIn: ["csharp"]} + * (the DbSet property it names is generated there). Java registers and + * type-checks it, so a model that carries it loads strictly here. The never-released + * {@code @dbSetName} it replaced is not vocabulary; an attribute the registry does not carry still * fails, and a wrong value type fails. */ public class PortScopedAttrTest { @@ -44,17 +45,22 @@ private static boolean loads(String attr) { } @Test - public void aCsharpOnlyAttributeLoadsStrictly() { - assertTrue(loads("\"@dbSetName\": \"Diagnosis\",")); + public void aCollectionNameLoadsStrictly() { + assertTrue(loads("\"@collectionName\": \"Diagnoses\",")); } @Test public void itIsTypeChecked() { - assertFalse(loads("\"@dbSetName\": [\"Diagnosis\"],")); + assertFalse(loads("\"@collectionName\": [\"Diagnoses\"],")); + } + + @Test + public void theNeverReleasedDbSetNameIsNotVocabulary() { + assertFalse(loads("\"@dbSetName\": \"Diagnoses\",")); } @Test public void anAttributeTheRegistryDoesNotCarryStillFails() { - assertFalse(loads("\"@dbSetNameX\": \"Diagnosis\",")); + assertFalse(loads("\"@collectionNameX\": \"Diagnoses\",")); } } diff --git a/server/python/src/metaobjects/config/neutral_config.py b/server/python/src/metaobjects/config/neutral_config.py index 7670e5f81e..870333a2f7 100644 --- a/server/python/src/metaobjects/config/neutral_config.py +++ b/server/python/src/metaobjects/config/neutral_config.py @@ -9,6 +9,7 @@ from metaobjects.naming import ( NamingRules, registered_column_naming_strategies, + registered_pluralizers, registered_table_naming_rules, unknown_naming_strategy_message, ) @@ -302,7 +303,7 @@ def _read_libraries(raw: dict[str, object], path: Path) -> list[str]: #: Every key the `naming` block accepts, and the ONLY ones it accepts (ADR-0058 #: Decision 1 — an unknown key is refused, not ignored, unlike unknown TOP-level keys). -_NAMING_KEYS = ("columns", "tables") +_NAMING_KEYS = ("columns", "tables", "collections") def _read_naming(raw: dict[str, object], path: Path) -> NamingRules: @@ -341,7 +342,13 @@ def _read_naming(raw: dict[str, object], path: Path) -> NamingRules: f"{path}: {unknown_naming_strategy_message('tables', str(tables))}", code=ErrorCode.ERR_COLLECTION_NOT_FOUND, ) - return NamingRules(columns=columns, tables=tables) + collections = naming.get("collections", NamingRules.collections) + if not isinstance(collections, str) or collections not in registered_pluralizers(): + raise ParseError( + f"{path}: {unknown_naming_strategy_message('collections', str(collections))}", + code=ErrorCode.ERR_COLLECTION_NOT_FOUND, + ) + return NamingRules(columns=columns, tables=tables, collections=collections) #: Every key the `enums` block accepts, and the ONLY ones it accepts (refused, not ignored, like `naming`). diff --git a/server/python/src/metaobjects/core_types.py b/server/python/src/metaobjects/core_types.py index 893133929f..5030e34220 100644 --- a/server/python/src/metaobjects/core_types.py +++ b/server/python/src/metaobjects/core_types.py @@ -59,7 +59,7 @@ from .meta.core.object.object_constants import ( OBJECT_ATTR_DISCRIMINATOR, OBJECT_ATTR_DISCRIMINATOR_VALUE, - OBJECT_ATTR_DB_SET_NAME, + OBJECT_ATTR_COLLECTION_NAME, OBJECT_ATTR_OBJECT, OBJECT_ATTR_OBJECT_ADAPTER, OBJECT_PROJECTION_ATTR_FILTER, @@ -297,10 +297,10 @@ def _register_subtypes( value_type=ATTR_SUBTYPE_STRING, required=False, ), - # @dbSetName, C#-only vocabulary every port loads and type-checks. object.json + # @collectionName, vocabulary every port loads and type-checks. object.json # declares it on entity / projection / report; the strict per-subtype scoping # (apply_spec_descriptions) prunes it from the others. - AttrSchema(name=OBJECT_ATTR_DB_SET_NAME, value_type=ATTR_SUBTYPE_STRING, required=False), + AttrSchema(name=OBJECT_ATTR_COLLECTION_NAME, value_type=ATTR_SUBTYPE_STRING, required=False), # @object / @objectAdapter: the JVM's native binding attrs, declared on object.base # in object.json and so kept on every subtype. Loaded and type-checked; unused here. AttrSchema(name=OBJECT_ATTR_OBJECT, value_type=ATTR_SUBTYPE_STRING, required=False), diff --git a/server/python/src/metaobjects/meta/core/object/object_constants.py b/server/python/src/metaobjects/meta/core/object/object_constants.py index 8d3e31d6d0..b4ceb0bd78 100644 --- a/server/python/src/metaobjects/meta/core/object/object_constants.py +++ b/server/python/src/metaobjects/meta/core/object/object_constants.py @@ -21,9 +21,10 @@ # @discriminatorValue — on a subtype: the value identifying its rows. OBJECT_ATTR_DISCRIMINATOR = "discriminator" OBJECT_ATTR_DISCRIMINATOR_VALUE = "discriminatorValue" -# C#-only (ports ["csharp"]): the EF Core DbSet property name. Loaded and type-checked here -# on object.entity / projection / report; no Python generator acts on it. -OBJECT_ATTR_DB_SET_NAME = "dbSetName" +# The object's collection name, which the generated C# DbSet property takes (implementedIn +# ["csharp"]). Loaded and type-checked here on +# object.entity / projection / report; no Python generator acts on it yet. +OBJECT_ATTR_COLLECTION_NAME = "collectionName" # On object.base, so every object subtype carries them. ``@object`` (ADR-0001) is # cross-port vocabulary only Java implements so far; ``@objectAdapter`` (ADR-0005) is # JVM-only (ports ["java", "kotlin"]). Python loads and type-checks both; it acts on neither. diff --git a/server/python/src/metaobjects/naming.py b/server/python/src/metaobjects/naming.py index 383311c544..d1cb126869 100644 --- a/server/python/src/metaobjects/naming.py +++ b/server/python/src/metaobjects/naming.py @@ -105,6 +105,10 @@ class NamingRules: columns: str = DEFAULT_COLUMN_NAMING tables: str = DEFAULT_TABLE_NAMING + #: The pluralizer an object without ``@collectionName`` takes its collection name from + #: (``naming.collections``). Not a physical name; it rides here because it is the same + #: project setting, read the same way. + collections: str = "standard" def naming_rules_of(node: MetaData) -> NamingRules: @@ -201,17 +205,59 @@ def _to_table_snake_case(s: str) -> str: TABLE_NAMING_LITERAL: lambda n: n, } +#: The pluralizers every port builds in. ``standard`` is the cross-port rule (already-plural +#: detection, then the suffix rule); ``none`` keeps the object name (a project whose collections +#: are named like its objects). +PLURALIZER_STANDARD = "standard" +PLURALIZER_NONE = "none" +BUILTIN_PLURALIZERS = (PLURALIZER_STANDARD, PLURALIZER_NONE) +#: The pluralizer a project gets when ``naming.collections`` is absent. +DEFAULT_PLURALIZER = PLURALIZER_STANDARD + + +def _standard_pluralize(name: str) -> str: + # Local import: the API-docs naming module imports this one. + from metaobjects.apidocs.naming import pluralize + + return pluralize(name) + + +_PLURALIZERS: dict[str, NamingFunction] = { + PLURALIZER_STANDARD: _standard_pluralize, + PLURALIZER_NONE: lambda n: n, +} + + +def _registry(kind: str) -> dict[str, NamingFunction]: + return _COLUMN_STRATEGIES if kind == "columns" else _TABLE_RULES if kind == "tables" else _PLURALIZERS + + +def _builtins(kind: str) -> tuple[str, ...]: + return ( + BUILTIN_COLUMN_NAMING_STRATEGIES + if kind == "columns" + else BUILTIN_TABLE_NAMING_RULES + if kind == "tables" + else BUILTIN_PLURALIZERS + ) + class UnknownNamingStrategyError(ValueError): """A name no registered naming strategy answers (ADR-0058 Amendment 1).""" def _kind_label(kind: str) -> str: - return "column-naming" if kind == "columns" else "table-naming" + return ( + "column-naming" + if kind == "columns" + else "table-naming" + if kind == "tables" + else "collection-naming (pluralizer)" + ) def _register(kind: str, name: str, apply: NamingFunction) -> None: - builtins = BUILTIN_COLUMN_NAMING_STRATEGIES if kind == "columns" else BUILTIN_TABLE_NAMING_RULES + builtins = _builtins(kind) if not isinstance(name, str) or not name: raise ValueError(f"a {_kind_label(kind)} strategy needs a non-empty name") if name in builtins: @@ -222,7 +268,7 @@ def _register(kind: str, name: str, apply: NamingFunction) -> None: if not callable(apply): raise ValueError(f'{_kind_label(kind)} strategy "{name}" must be a function from a name to a name') # Registering a name again replaces it: an application registers once at startup. - (_COLUMN_STRATEGIES if kind == "columns" else _TABLE_RULES)[name] = apply + _registry(kind)[name] = apply def register_column_naming_strategy(name: str, apply: NamingFunction) -> None: @@ -244,6 +290,26 @@ def register_table_naming_rule(name: str, apply: NamingFunction) -> None: _register("tables", name, apply) +def register_pluralizer(name: str, apply: NamingFunction) -> None: + """Register a named pluralizer: an object name (Pascal, as declared) in, its collection name + out. ``"naming": {"collections": ""}`` selects it; see + :func:`register_column_naming_strategy`.""" + _register("collections", name, apply) + + +def registered_pluralizers() -> tuple[str, ...]: + """Every registered pluralizer: the built-ins, then the application's.""" + return tuple(_PLURALIZERS) + + +def pluralizer(name: str) -> NamingFunction: + """The pluralizer registered as *name*; raises like :func:`column_naming_strategy`.""" + fn = _PLURALIZERS.get(name) + if fn is None: + raise UnknownNamingStrategyError(unknown_naming_strategy_message("collections", name)) + return fn + + def reset_naming_strategies_for_testing() -> None: """TEST-ONLY: forget every strategy the application registered, leaving the built-ins. The registry is process-wide, so a test that registers one resets it when it is done rather than @@ -252,6 +318,8 @@ def reset_naming_strategies_for_testing() -> None: del _COLUMN_STRATEGIES[name] for name in [n for n in _TABLE_RULES if n not in BUILTIN_TABLE_NAMING_RULES]: del _TABLE_RULES[name] + for name in [n for n in _PLURALIZERS if n not in BUILTIN_PLURALIZERS]: + del _PLURALIZERS[name] def registered_column_naming_strategies() -> tuple[str, ...]: @@ -266,14 +334,11 @@ def registered_table_naming_rules() -> tuple[str, ...]: def unknown_naming_strategy_message(kind: str, name: str) -> str: """What every port says when a config names a strategy no registered strategy answers: - the same words in every port, with that port's name. *kind* is ``columns`` or ``tables``. + the same words in every port, with that port's name. *kind* is ``columns``, ``tables`` or + ``collections``. """ - builtins = BUILTIN_COLUMN_NAMING_STRATEGIES if kind == "columns" else BUILTIN_TABLE_NAMING_RULES - registered = [ - n - for n in (registered_column_naming_strategies() if kind == "columns" else registered_table_naming_rules()) - if n not in builtins - ] + builtins = _builtins(kind) + registered = [n for n in _registry(kind) if n not in builtins] return ( f'"naming.{kind}" names "{name}", which no {_kind_label(kind)} strategy registered in the ' f"{_NAMING_PORT} port answers (built in: {', '.join(builtins)}; registered by the " diff --git a/server/python/src/metaobjects/spec_metamodel/object.json b/server/python/src/metaobjects/spec_metamodel/object.json index f9d9280409..8c9f73ae9e 100644 --- a/server/python/src/metaobjects/spec_metamodel/object.json +++ b/server/python/src/metaobjects/spec_metamodel/object.json @@ -31,7 +31,7 @@ { "type": "segment", "subType": "*", "name": "*", "min": 0, "max": null }, { "type": "attr", "subType": "string", "name": "discriminator", "min": 0, "max": 1, "description": "FR-014: names the field on this entity (resolvable via extends:) that holds the subtype-discriminator value. Subtypes of this entity declare @discriminatorValue to bind their rows to a discriminator value. The discriminator field itself is an ordinary field declaration (typically field.enum or field.int / field.string)." }, { "type": "attr", "subType": "string", "name": "discriminatorValue", "min": 0, "max": 1, "description": "FR-014: on a subtype of an entity with @discriminator — the value that identifies rows of this subtype in the shared discriminator field. Wire form is always a string; the underlying field's subtype (enum / int / string) controls codegen + storage coercion. Required on every concrete subtype of a discriminated entity." }, - { "type": "attr", "subType": "string", "name": "dbSetName", "min": 0, "max": 1, "ports": ["csharp"], "description": "C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it." } + { "type": "attr", "subType": "string", "name": "collectionName", "min": 0, "max": 1, "implementedIn": ["csharp"], "description": "The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own)." } ] }, { @@ -54,7 +54,7 @@ "rules": "object.projection is a derived read-only representation: every field is extends-bound, origin-derived, or self-declared-under-external-assembly, and all are read-only at the subtype level. Identity is optional and, when present, MUST extend an entity identity. Sources are restricted to read-only @kinds. The declared field set IS the exposure — an inclusive list, fail-closed. A projection NEVER declares relationships (derivation is expressed via @via, not a relationship child) and NEVER co-locates templates — hence its child set omits both relationship and template. The assembly origins (origin.aggregate / origin.computed / origin.first) live HERE, not on object.value; a SOURCELESS projection is the legal shape for a wire-assembled read model, and a template-level @payloadRef/@responseRef may target one (#210). See ADR-0028 (object taxonomy, projection).", "children": [ { "type": "attr", "subType": "filter", "name": "filter", "min": 0, "max": 1, "description": "Optional row-scope predicate (a portable attr.filter object: eq/ne/gt/gte/lt/lte/like/in/isNull with and/or, desugared to { field: { op: value } } at parse time) selecting which rows the view returns — lowered to an outer SQL WHERE. Resolves against the projection's own declared fields; an aggregate-derived field is not addressable (fail-closed)." }, - { "type": "attr", "subType": "string", "name": "dbSetName", "min": 0, "max": 1, "ports": ["csharp"], "description": "C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it." } + { "type": "attr", "subType": "string", "name": "collectionName", "min": 0, "max": 1, "implementedIn": ["csharp"], "description": "The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own)." } ] }, { @@ -70,7 +70,7 @@ { "type": "attr", "subType": "string", "name": "segment", "min": 0, "max": 1, "description": "Optional segment of @from scoping the rows." }, { "type": "attr", "subType": "filter", "name": "filter", "min": 0, "max": 1, "description": "Optional row scope over @from's fields; may use relative-date values." }, { "type": "attr", "subType": "string", "name": "spine", "min": 0, "max": 1, "description": "Optional to-one path from @from to the entity whose rows supply the report's rows (e.g. 'Purchase.program'), written like a dimension's @via. With it the report has one row per distinct dimension tuple among THAT entity's rows, including the ones no row of @from refers to: a count there is 0 and any other measure is null unless it declares @default. Every listed dimension must be reached through this path. @segment and @filter still scope the rows of @from and never remove a row. A row of @from whose reference is null or matches nothing is in no row of the report." }, - { "type": "attr", "subType": "string", "name": "dbSetName", "min": 0, "max": 1, "ports": ["csharp"], "description": "C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it." } + { "type": "attr", "subType": "string", "name": "collectionName", "min": 0, "max": 1, "implementedIn": ["csharp"], "description": "The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own)." } ] } ] diff --git a/server/python/tests/loader/test_port_scoped_attr.py b/server/python/tests/loader/test_port_scoped_attr.py index 1263414c23..00526279d1 100644 --- a/server/python/tests/loader/test_port_scoped_attr.py +++ b/server/python/tests/loader/test_port_scoped_attr.py @@ -1,7 +1,8 @@ -"""A language-specific attribute is in the shared registry with the ports that act on -it (``@dbSetName``, the C# EF DbSet property name: ports ``["csharp"]``). Python registers and -type-checks it, so a model that carries it loads strictly here, and Python does nothing with it. An -attribute the registry does not carry still fails, and a wrong value type fails. +"""``@collectionName`` is shared vocabulary marked ``implementedIn: ["csharp"]`` (the +DbSet property it names is generated there). Python registers and type-checks it, +so a model that carries it loads strictly here, and Python does nothing with it yet. The +never-released ``@dbSetName`` it replaced is not vocabulary; an attribute the registry does not +carry still fails, and a wrong value type fails. """ from __future__ import annotations @@ -24,13 +25,17 @@ def _codes(attr: dict) -> list[str]: return [str(getattr(e, "code", e)) for e in load_string(_model(attr), strict=True).errors] -def test_a_csharp_only_attribute_loads_strictly() -> None: - assert _codes({"@dbSetName": "Diagnosis"}) == [] +def test_a_collection_name_loads_strictly() -> None: + assert _codes({"@collectionName": "Diagnoses"}) == [] def test_it_is_type_checked() -> None: - assert _codes({"@dbSetName": ["Diagnosis"]}) != [] + assert _codes({"@collectionName": ["Diagnoses"]}) != [] + + +def test_the_never_released_db_set_name_is_not_vocabulary() -> None: + assert any("UNKNOWN_ATTR" in c for c in _codes({"@dbSetName": "Diagnoses"})) def test_an_attribute_the_registry_does_not_carry_still_fails() -> None: - assert any("UNKNOWN_ATTR" in c for c in _codes({"@dbSetNameX": "Diagnosis"})) + assert any("UNKNOWN_ATTR" in c for c in _codes({"@collectionNameX": "Diagnoses"})) diff --git a/server/python/tests/unit/test_collection_naming.py b/server/python/tests/unit/test_collection_naming.py new file mode 100644 index 0000000000..294903f2bd --- /dev/null +++ b/server/python/tests/unit/test_collection_naming.py @@ -0,0 +1,70 @@ +"""``naming.collections`` names the pluralizer an object without ``@collectionName`` takes its +collection name from. Python generates nothing from it yet, but a project shared with a port that +does carries the key, so Python reads it, refuses an unknown name like ``columns`` and ``tables``, +and keeps the selected name on the loaded rules. +""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from metaobjects.config.neutral_config import read_neutral_config +from metaobjects.errors import ParseError +from metaobjects.naming import ( + BUILTIN_PLURALIZERS, + DEFAULT_PLURALIZER, + NamingRules, + pluralizer, + register_pluralizer, + registered_pluralizers, + reset_naming_strategies_for_testing, +) + + +def _config(tmp_path: Path, naming: dict) -> Path: + cfg = tmp_path / ".metaobjects" + cfg.mkdir() + (cfg / "config.json").write_text(json.dumps({"schema_version": 1, "naming": naming})) + return tmp_path + + +def test_the_built_ins_pluralize_or_keep_the_name() -> None: + assert BUILTIN_PLURALIZERS == ("standard", "none") + assert DEFAULT_PLURALIZER == NamingRules().collections == "standard" + assert pluralizer("standard")("Category") == "Categories" + assert pluralizer("standard")("CaseHistory") == "CaseHistories" + assert pluralizer("standard")("Address") == "Addresses" + assert pluralizer("none")("CaseHistory") == "CaseHistory" + + +def test_the_config_reads_a_built_in(tmp_path: Path) -> None: + config = read_neutral_config(_config(tmp_path, {"collections": "none"})) + assert config is not None and config.naming.collections == "none" + + +def test_absent_is_the_default(tmp_path: Path) -> None: + config = read_neutral_config(_config(tmp_path, {"columns": "snake_case"})) + assert config is not None and config.naming.collections == DEFAULT_PLURALIZER + + +def test_an_unknown_pluralizer_is_refused_naming_the_kind(tmp_path: Path) -> None: + with pytest.raises(ParseError, match=r'"naming.collections" names "latin".*collection-naming \(pluralizer\)'): + read_neutral_config(_config(tmp_path, {"collections": "latin"})) + + +def test_an_application_pluralizer_is_selectable(tmp_path: Path) -> None: + register_pluralizer("py-test-shout", lambda n: n.upper() + "S") + try: + assert "py-test-shout" in registered_pluralizers() + config = read_neutral_config(_config(tmp_path, {"collections": "py-test-shout"})) + assert config is not None and config.naming.collections == "py-test-shout" + finally: + reset_naming_strategies_for_testing() + assert "py-test-shout" not in registered_pluralizers() + + +def test_a_built_in_pluralizer_cannot_be_replaced() -> None: + with pytest.raises(ValueError, match="built in"): + register_pluralizer("none", lambda n: n) diff --git a/server/typescript/packages/cli/test/types-command.test.ts b/server/typescript/packages/cli/test/types-command.test.ts index ed8ad15ea3..4cec49d1b4 100644 --- a/server/typescript/packages/cli/test/types-command.test.ts +++ b/server/typescript/packages/cli/test/types-command.test.ts @@ -249,7 +249,8 @@ describe("meta types --format", () => { expect(text).toContain("[java; pending elsewhere]"); expect(text).toContain("[java+kotlin]"); expect(text).not.toContain("[java-only]"); - expect(text).toContain("[csharp-only]"); + expect(text).toContain("[csharp; pending elsewhere]"); + expect(text).not.toContain("[csharp-only]"); expect(text).not.toContain("[ts-only]"); const doc = await json(["object.entity", "--kind", "attr", "--limit", "0"]); const attr = (n: string) => doc.matches.find((m: any) => m.name === `object.entity @${n}`); @@ -257,6 +258,7 @@ describe("meta types --format", () => { // Implemented in Java only, so the deprecated derived `tsOnly` is false. expect(attr("object").tsOnly).toBe(false); expect(attr("objectAdapter").ports).toEqual(["java", "kotlin"]); + expect(attr("collectionName").implementedIn).toEqual(["csharp"]); // A view's attr inherits its type's kind-1 marking. const img = await json(["view.image", "--kind", "attr", "--limit", "0"]); expect(img.matches.find((m: any) => m.name === "view.image @accept").implementedIn).toEqual(["typescript"]); diff --git a/server/typescript/packages/metadata/src/core/object/object-constants.ts b/server/typescript/packages/metadata/src/core/object/object-constants.ts index ac4cbfccf0..baccb04673 100644 --- a/server/typescript/packages/metadata/src/core/object/object-constants.ts +++ b/server/typescript/packages/metadata/src/core/object/object-constants.ts @@ -42,6 +42,8 @@ export const OBJECT_ATTR_DISCRIMINATOR = "discriminator"; /** FR-014: on a subtype of an entity with @discriminator: the value that * identifies rows of this subtype in the shared discriminator field. */ export const OBJECT_ATTR_DISCRIMINATOR_VALUE = "discriminatorValue"; +/** An object's collection name, in its own casing (the C# DbSet property). */ +export const OBJECT_ATTR_COLLECTION_NAME = "collectionName"; // --------------------------------------------------------------------------- // #207 — projection row-scope @filter (view-level WHERE) diff --git a/server/typescript/packages/metadata/src/core/object/object-definition.embedded.ts b/server/typescript/packages/metadata/src/core/object/object-definition.embedded.ts index 5a5ca8d266..e89158df41 100644 --- a/server/typescript/packages/metadata/src/core/object/object-definition.embedded.ts +++ b/server/typescript/packages/metadata/src/core/object/object-definition.embedded.ts @@ -143,13 +143,13 @@ export const OBJECT_DEFINITION: ProviderDefinition = { { "type": "attr", "subType": "string", - "name": "dbSetName", + "name": "collectionName", "min": 0, "max": 1, - "ports": [ + "implementedIn": [ "csharp" ], - "description": "C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it." + "description": "The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own)." } ] }, @@ -189,13 +189,13 @@ export const OBJECT_DEFINITION: ProviderDefinition = { { "type": "attr", "subType": "string", - "name": "dbSetName", + "name": "collectionName", "min": 0, "max": 1, - "ports": [ + "implementedIn": [ "csharp" ], - "description": "C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it." + "description": "The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own)." } ] }, @@ -259,13 +259,13 @@ export const OBJECT_DEFINITION: ProviderDefinition = { { "type": "attr", "subType": "string", - "name": "dbSetName", + "name": "collectionName", "min": 0, "max": 1, - "ports": [ + "implementedIn": [ "csharp" ], - "description": "C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it." + "description": "The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own)." } ] } diff --git a/server/typescript/packages/metadata/src/index.ts b/server/typescript/packages/metadata/src/index.ts index 8bfef1ae9e..60461b178b 100644 --- a/server/typescript/packages/metadata/src/index.ts +++ b/server/typescript/packages/metadata/src/index.ts @@ -377,6 +377,8 @@ export { registeredColumnNamingStrategies, registeredTableNamingRules, isRegisteredColumnNamingStrategy, isRegisteredTableNamingRule, unknownNamingStrategyMessage, resetNamingStrategiesForTesting, + registerPluralizer, registeredPluralizers, isRegisteredPluralizer, pluralizer, + BUILTIN_PLURALIZER_NAMES, DEFAULT_PLURALIZER, collectionNameOf, declaredCollectionName, TABLE_NAMING_RULES, DEFAULT_PHYSICAL_NAMING, physicalNamingOf, applyTableNamingRule, PHYSICAL_NAMING_MIGRATION_GUIDE, PHYSICAL_NAMING_MIGRATION_GUIDE_PATH, physicalNamingRemovedMessage, resolveTableName, resolveColumnName, resolveFlattenedColumnPrefix, resolveFlattenedColumnName, @@ -387,7 +389,7 @@ export { } from "./naming.js"; export type { EntityNameMap, ColumnNamingStrategy, PhysicalNaming, TableNamingRule, NamingFunction, - BuiltinColumnNamingStrategy, BuiltinTableNamingRule, + BuiltinColumnNamingStrategy, BuiltinTableNamingRule, NamingKind, } from "./naming.js"; export { resolveEnumMaxLength, enumDefaultsOf, DEFAULT_ENUM_DEFAULTS, ENUMS_KEY_MAX_LENGTH } from "./enum-length.js"; export type { EnumDefaults } from "./enum-length.js"; diff --git a/server/typescript/packages/metadata/src/naming.ts b/server/typescript/packages/metadata/src/naming.ts index d846509c54..10d9344864 100644 --- a/server/typescript/packages/metadata/src/naming.ts +++ b/server/typescript/packages/metadata/src/naming.ts @@ -17,6 +17,7 @@ import type { MetaSource } from "./persistence/source/meta-source.js"; // also make the divergence refusal see no primaries at all, and say nothing.) import { isMetaSource } from "./shared/node-guards.js"; import { MetaModelError } from "./errors.js"; +import { OBJECT_ATTR_COLLECTION_NAME } from "./core/object/object-constants.js"; /** * Strip the package prefix from a metadata-qualified name @@ -103,8 +104,13 @@ export type TableNamingRule = BuiltinTableNamingRule | (string & {}); interface NamingRegistry { readonly columns: Map; readonly tables: Map; + /** Pluralizers, by name: an object name in, its collection name out. */ + collections?: Map; } +/** The kinds of naming strategy a project selects in `naming` (ADR-0058), `collections` among them. */ +export type NamingKind = "columns" | "tables" | "collections"; + /** * One registry per PROCESS, not per module copy. A globally-installed CLI and a project-local * dependency are two physical copies of this package (see `shared/node-guards.ts`), and an @@ -114,18 +120,21 @@ interface NamingRegistry { */ const REGISTRY_KEY = Symbol.for("@metaobjectsdev/metadata/naming-strategies"); -function registry(): NamingRegistry { +function registry(): Required { const holder = globalThis as { [REGISTRY_KEY]?: NamingRegistry }; let r = holder[REGISTRY_KEY]; if (r === undefined) { r = { columns: new Map(), tables: new Map() }; holder[REGISTRY_KEY] = r; } + // A registry an older copy of this package created has no pluralizers yet. + r.collections ??= new Map(); // Built-ins are re-seeded on every read, so a registry an older copy of this package // created still answers them, and nothing can have replaced one. for (const [name, fn] of BUILTIN_COLUMN_FUNCTIONS) if (!r.columns.has(name)) r.columns.set(name, fn); for (const [name, fn] of BUILTIN_TABLE_FUNCTIONS) if (!r.tables.has(name)) r.tables.set(name, fn); - return r; + for (const [name, fn] of BUILTIN_PLURALIZERS) if (!r.collections.has(name)) r.collections.set(name, fn); + return r as Required; } const BUILTIN_COLUMN_FUNCTIONS: ReadonlyMap = new Map([ @@ -140,11 +149,30 @@ const BUILTIN_TABLE_FUNCTIONS: ReadonlyMap = new Map name], ]); +/** + * The pluralizers every port builds in. `standard` is the cross-port rule + * ({@link pluralize}: already-plural detection, then the suffix rule) every port already applies + * to REST segments and generated function names; `none` keeps the name as it is (a project whose + * collections are named like its objects). + */ +export const BUILTIN_PLURALIZER_NAMES = ["standard", "none"] as const; +/** The pluralizer a project gets when `naming.collections` is absent. */ +export const DEFAULT_PLURALIZER = "standard"; + +const BUILTIN_PLURALIZERS: ReadonlyMap = new Map([ + ["standard", (name) => pluralize(name)], + ["none", (name) => name], +]); + /** The port this registry belongs to, as every port's unknown-strategy message names it. */ const NAMING_PORT = "TypeScript"; -function register(kind: "columns" | "tables", name: string, apply: NamingFunction): void { - const builtIns = kind === "columns" ? BUILTIN_COLUMN_FUNCTIONS : BUILTIN_TABLE_FUNCTIONS; +function builtInsOf(kind: NamingKind): ReadonlyMap { + return kind === "columns" ? BUILTIN_COLUMN_FUNCTIONS : kind === "tables" ? BUILTIN_TABLE_FUNCTIONS : BUILTIN_PLURALIZERS; +} + +function register(kind: NamingKind, name: string, apply: NamingFunction): void { + const builtIns = builtInsOf(kind); if (typeof name !== "string" || name === "") { throw new MetaModelError(`a ${kindLabel(kind)} strategy needs a non-empty name`); } @@ -161,8 +189,8 @@ function register(kind: "columns" | "tables", name: string, apply: NamingFunctio registry()[kind].set(name, apply); } -function kindLabel(kind: "columns" | "tables"): string { - return kind === "columns" ? "column-naming" : "table-naming"; +function kindLabel(kind: NamingKind): string { + return kind === "columns" ? "column-naming" : kind === "tables" ? "table-naming" : "collection-naming (pluralizer)"; } /** @@ -182,6 +210,33 @@ export function registerTableNamingRule(name: string, apply: NamingFunction): vo register("tables", name, apply); } +/** + * Register a named pluralizer: an object name (Pascal, as declared) in, its + * collection name out. `.metaobjects/config.json` selects it as `"naming": { "collections": + * "" }`; an object's own `@collectionName` always wins over it. A built-in (`standard`, + * `none`) cannot be replaced; registering a name of your own again replaces it. + */ +export function registerPluralizer(name: string, apply: NamingFunction): void { + register("collections", name, apply); +} + +/** Every registered pluralizer's name: the built-ins, then the application's. */ +export function registeredPluralizers(): readonly string[] { + return [...registry().collections.keys()]; +} + +/** True when `name` is a registered pluralizer, built in or the application's. */ +export function isRegisteredPluralizer(name: string): boolean { + return registry().collections.has(name); +} + +/** The pluralizer registered as `name`. Throws, naming it, when nothing answers it. */ +export function pluralizer(name: string): NamingFunction { + const fn = registry().collections.get(name); + if (fn === undefined) throw new MetaModelError(unknownNamingStrategyMessage("collections", name)); + return fn; +} + /** Every registered column strategy's name: the built-ins in corpus order, then the application's. */ export function registeredColumnNamingStrategies(): readonly string[] { return [...registry().columns.keys()]; @@ -197,10 +252,9 @@ export function registeredTableNamingRules(): readonly string[] { * answers. Same words in every port, with that port's name, so an estate running several * ports sees one message and knows which port's registry lacked it. */ -export function unknownNamingStrategyMessage(kind: "columns" | "tables", name: string): string { - const builtIns: readonly string[] = kind === "columns" ? BUILTIN_COLUMN_NAMING_STRATEGIES : BUILTIN_TABLE_NAMING_RULES; - const registered = (kind === "columns" ? registeredColumnNamingStrategies() : registeredTableNamingRules()) - .filter((n) => !builtIns.includes(n)); +export function unknownNamingStrategyMessage(kind: NamingKind, name: string): string { + const builtIns: readonly string[] = [...builtInsOf(kind).keys()]; + const registered = [...registry()[kind].keys()].filter((n) => !builtIns.includes(n)); return ( `"naming.${kind}" names "${name}", which no ${kindLabel(kind)} strategy registered in the ` + `${NAMING_PORT} port answers (built in: ${builtIns.join(", ")}; registered by the application: ` + @@ -233,6 +287,7 @@ export function resetNamingStrategiesForTesting(): void { const r = registry(); for (const name of [...r.columns.keys()]) if (!BUILTIN_COLUMN_FUNCTIONS.has(name)) r.columns.delete(name); for (const name of [...r.tables.keys()]) if (!BUILTIN_TABLE_FUNCTIONS.has(name)) r.tables.delete(name); + for (const name of [...r.collections.keys()]) if (!BUILTIN_PLURALIZERS.has(name)) r.collections.delete(name); } /** True when `name` is a registered column strategy, built in or the application's. */ @@ -261,6 +316,10 @@ export const TABLE_NAMING_RULES: readonly TableNamingRule[] = BUILTIN_TABLE_NAMI export interface PhysicalNaming { readonly columns: ColumnNamingStrategy; readonly tables: TableNamingRule; + /** The pluralizer an object without `@collectionName` takes its collection name + * from (`naming.collections`). Not a physical name; it rides here because it is the same + * project setting, read the same way. Absent is {@link DEFAULT_PLURALIZER}. */ + readonly collections?: string; } /** The default in every port, equal to what `meta migrate` has always created. */ @@ -379,6 +438,23 @@ export function pluralize(s: string): string { return pluralizeTableNameLegacy(s); } +/** + * An object's collection name, in the object's own casing: its own `@collectionName`, else the + * project's pluralizer (`naming.collections`, {@link physicalNamingOf}) over its name. Each + * artifact that reads it applies its own casing. Own, not resolving: a name inherited from an abstract base would give every + * subtype one collection. + */ +export function collectionNameOf(obj: MetaData): string { + return declaredCollectionName(obj) ?? pluralizer(physicalNamingOf(obj).collections ?? DEFAULT_PLURALIZER)(obj.name); +} + +/** The collection name an object declares itself (`@collectionName`); undefined when it declares none. */ +export function declaredCollectionName(obj: MetaData): string | undefined { + // ADR-0039: own — a collection name is the declaring object's alone (see collectionNameOf). + const own = obj.ownAttr(OBJECT_ATTR_COLLECTION_NAME); + return typeof own === "string" && own !== "" ? own : undefined; +} + /** * THE `@role: primary` source lookup for this package — and the one place the * primary-source DIVERGENCE refusal lives, so that every caller inherits it. diff --git a/server/typescript/packages/metadata/src/registry-manifest-exclusions.ts b/server/typescript/packages/metadata/src/registry-manifest-exclusions.ts index 1a5fea331c..43f74b7608 100644 --- a/server/typescript/packages/metadata/src/registry-manifest-exclusions.ts +++ b/server/typescript/packages/metadata/src/registry-manifest-exclusions.ts @@ -35,7 +35,7 @@ // cross-port vocabulary, and it is IN the manifest, marked rather than removed. // • kind 1 — implemented so far only in some ports (the 13 generic `view.*` // entries and their attrs): the type carries `implementedIn: ["typescript"]`. -// • kind 2 — language-specific by nature (`@dbSetName`, the JVM's `@object` / +// • kind 2 — language-specific by nature (the JVM's `@object` / // `@objectAdapter`): the attr carries `ports: [...]`. // Every port registers both kinds and loads them strictly; a port not named loads // them and does nothing with them. An unregistered name still fails. diff --git a/server/typescript/packages/metadata/test/object-definition-completeness.test.ts b/server/typescript/packages/metadata/test/object-definition-completeness.test.ts index 31a42f3945..3806007a14 100644 --- a/server/typescript/packages/metadata/test/object-definition-completeness.test.ts +++ b/server/typescript/packages/metadata/test/object-definition-completeness.test.ts @@ -86,11 +86,11 @@ const REPORT_ATTRS: Record = { spine: { valueType: "string", required: false }, }; -// Language-specific attrs every port loads: @dbSetName (C#) on entity / -// projection / report, and the JVM's @object / @objectAdapter on object.base, which -// every subtype composes. -const DB_SET_NAME: Record = { - dbSetName: { valueType: "string", required: false }, +// Cross-port attrs some ports act on: @collectionName (implementedIn C# + TypeScript) on entity / +// projection / report, and the JVM's @object / @objectAdapter on object.base, which every subtype +// composes. +const COLLECTION_NAME: Record = { + collectionName: { valueType: "string", required: false }, }; const JVM_BINDING: Record = { @@ -99,10 +99,10 @@ const JVM_BINDING: Record = { }; function expectedAttrsFor(subType: string): Record { - if (subType === OBJECT_SUBTYPE_ENTITY) return { ...DISCRIMINATOR, ...DB_SET_NAME, ...JVM_BINDING }; + if (subType === OBJECT_SUBTYPE_ENTITY) return { ...DISCRIMINATOR, ...COLLECTION_NAME, ...JVM_BINDING }; if (subType === OBJECT_SUBTYPE_VALUE) return { ...NORMALIZE, ...JVM_BINDING }; - if (subType === OBJECT_SUBTYPE_PROJECTION) return { ...PROJECTION_FILTER, ...DB_SET_NAME, ...JVM_BINDING }; - if (subType === OBJECT_SUBTYPE_REPORT) return { ...REPORT_ATTRS, ...DB_SET_NAME, ...JVM_BINDING }; + if (subType === OBJECT_SUBTYPE_PROJECTION) return { ...PROJECTION_FILTER, ...COLLECTION_NAME, ...JVM_BINDING }; + if (subType === OBJECT_SUBTYPE_REPORT) return { ...REPORT_ATTRS, ...COLLECTION_NAME, ...JVM_BINDING }; // base carries only the JVM bindings (discriminator moved to entity only). return { ...JVM_BINDING }; } diff --git a/server/typescript/packages/metadata/test/port-scoped-attr.test.ts b/server/typescript/packages/metadata/test/port-scoped-attr.test.ts index 8557d16ba9..db41058139 100644 --- a/server/typescript/packages/metadata/test/port-scoped-attr.test.ts +++ b/server/typescript/packages/metadata/test/port-scoped-attr.test.ts @@ -1,6 +1,6 @@ // A language-specific attribute is in the shared registry with the ports that act on -// it (`@dbSetName`, the C# EF DbSet property name: ports ["csharp"]). Every port registers and -// type-checks it, so a model that carries it loads under the strict loader here, and TypeScript, +// it (`@objectAdapter`, the JVM ObjectAdapter class: ports ["java", "kotlin"]). Every port registers +// and type-checks it, so a model that carries it loads under the strict loader here, and TypeScript, // a port not named, does nothing with it. Strictness is unchanged: an attribute the registry does // not carry still fails, and a wrong value type fails. @@ -23,23 +23,27 @@ async function codes(json: string): Promise { return res.errors.map((e) => (e as { code?: string }).code ?? ""); } -describe("a C#-only attribute", () => { +describe("a JVM-only attribute", () => { test("loads under the strict loader", async () => { - expect(await codes(model(`"@dbSetName": "Diagnosis",`))).toEqual([]); + expect(await codes(model(`"@objectAdapter": "com.acme.DiagnosisAdapter",`))).toEqual([]); }); - test("is type-checked: a list is not a DbSet name", async () => { - expect(await codes(model(`"@dbSetName": ["Diagnosis"],`))).not.toEqual([]); + test("is type-checked: a list is not a class name", async () => { + expect(await codes(model(`"@objectAdapter": ["com.acme.DiagnosisAdapter"],`))).not.toEqual([]); + }); + + test("the never-released @dbSetName is not vocabulary (it is @collectionName)", async () => { + expect(await codes(model(`"@dbSetName": "Diagnosis",`))).toContain("ERR_UNKNOWN_ATTR"); }); test("an attribute the registry does not carry still fails", async () => { - expect(await codes(model(`"@dbSetNameX": "Diagnosis",`))).toContain("ERR_UNKNOWN_ATTR"); + expect(await codes(model(`"@objectAdapterX": "com.acme.DiagnosisAdapter",`))).toContain("ERR_UNKNOWN_ATTR"); }); test("the vocabulary names the ports that act on it", async () => { const entity = buildVocabularyCatalog(composeRegistry(coreProviders)) .types.find((t) => t.type === "object" && t.subType === "entity")!; - expect(entity.attrs.find((a) => a.name === "dbSetName")?.ports).toEqual(["csharp"]); + expect(entity.attrs.find((a) => a.name === "objectAdapter")?.ports).toEqual(["java", "kotlin"]); }); }); diff --git a/site-reference/providers.html b/site-reference/providers.html index ab2c127bb5..690badf69a 100644 --- a/site-reference/providers.html +++ b/site-reference/providers.html @@ -67,9 +67,9 @@

metaobjects-core-types

  • identity.reference: @enforce, @fields, @references
  • identity.secondary: @fields
  • object.base: @object, @objectAdapter
  • -
  • object.entity: @dbSetName, @discriminator, @discriminatorValue
  • -
  • object.projection: @dbSetName, @filter
  • -
  • object.report: @dbSetName, @dimensions, @filter, @from, @measures, @segment, @spine
  • +
  • object.entity: @collectionName, @discriminator, @discriminatorValue
  • +
  • object.projection: @collectionName, @filter
  • +
  • object.report: @collectionName, @dimensions, @filter, @from, @measures, @segment, @spine
  • origin.aggregate: @agg, @distinct, @filter, @of, @orderBy, @via
  • origin.computed: @expr
  • origin.first: @filter, @of, @orderBy, @via
  • diff --git a/site-reference/types/object.html b/site-reference/types/object.html index 366a0c512b..02da1becf9 100644 --- a/site-reference/types/object.html +++ b/site-reference/types/object.html @@ -113,13 +113,13 @@

    object.entity

    -@dbSetName +@collectionName string no metaobjects-core-types -C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it. +The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own). @discriminator @@ -191,13 +191,13 @@

    object.projection

    -@dbSetName +@collectionName string no metaobjects-core-types -C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it. +The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own). @filter @@ -254,13 +254,13 @@

    object.report

    -@dbSetName +@collectionName string no metaobjects-core-types -C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it. +The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own). @dimensions diff --git a/spec/metamodel/object.json b/spec/metamodel/object.json index f9d9280409..8c9f73ae9e 100644 --- a/spec/metamodel/object.json +++ b/spec/metamodel/object.json @@ -31,7 +31,7 @@ { "type": "segment", "subType": "*", "name": "*", "min": 0, "max": null }, { "type": "attr", "subType": "string", "name": "discriminator", "min": 0, "max": 1, "description": "FR-014: names the field on this entity (resolvable via extends:) that holds the subtype-discriminator value. Subtypes of this entity declare @discriminatorValue to bind their rows to a discriminator value. The discriminator field itself is an ordinary field declaration (typically field.enum or field.int / field.string)." }, { "type": "attr", "subType": "string", "name": "discriminatorValue", "min": 0, "max": 1, "description": "FR-014: on a subtype of an entity with @discriminator — the value that identifies rows of this subtype in the shared discriminator field. Wire form is always a string; the underlying field's subtype (enum / int / string) controls codegen + storage coercion. Required on every concrete subtype of a discriminated entity." }, - { "type": "attr", "subType": "string", "name": "dbSetName", "min": 0, "max": 1, "ports": ["csharp"], "description": "C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it." } + { "type": "attr", "subType": "string", "name": "collectionName", "min": 0, "max": 1, "implementedIn": ["csharp"], "description": "The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own)." } ] }, { @@ -54,7 +54,7 @@ "rules": "object.projection is a derived read-only representation: every field is extends-bound, origin-derived, or self-declared-under-external-assembly, and all are read-only at the subtype level. Identity is optional and, when present, MUST extend an entity identity. Sources are restricted to read-only @kinds. The declared field set IS the exposure — an inclusive list, fail-closed. A projection NEVER declares relationships (derivation is expressed via @via, not a relationship child) and NEVER co-locates templates — hence its child set omits both relationship and template. The assembly origins (origin.aggregate / origin.computed / origin.first) live HERE, not on object.value; a SOURCELESS projection is the legal shape for a wire-assembled read model, and a template-level @payloadRef/@responseRef may target one (#210). See ADR-0028 (object taxonomy, projection).", "children": [ { "type": "attr", "subType": "filter", "name": "filter", "min": 0, "max": 1, "description": "Optional row-scope predicate (a portable attr.filter object: eq/ne/gt/gte/lt/lte/like/in/isNull with and/or, desugared to { field: { op: value } } at parse time) selecting which rows the view returns — lowered to an outer SQL WHERE. Resolves against the projection's own declared fields; an aggregate-derived field is not addressable (fail-closed)." }, - { "type": "attr", "subType": "string", "name": "dbSetName", "min": 0, "max": 1, "ports": ["csharp"], "description": "C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it." } + { "type": "attr", "subType": "string", "name": "collectionName", "min": 0, "max": 1, "implementedIn": ["csharp"], "description": "The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own)." } ] }, { @@ -70,7 +70,7 @@ { "type": "attr", "subType": "string", "name": "segment", "min": 0, "max": 1, "description": "Optional segment of @from scoping the rows." }, { "type": "attr", "subType": "filter", "name": "filter", "min": 0, "max": 1, "description": "Optional row scope over @from's fields; may use relative-date values." }, { "type": "attr", "subType": "string", "name": "spine", "min": 0, "max": 1, "description": "Optional to-one path from @from to the entity whose rows supply the report's rows (e.g. 'Purchase.program'), written like a dimension's @via. With it the report has one row per distinct dimension tuple among THAT entity's rows, including the ones no row of @from refers to: a count there is 0 and any other measure is null unless it declares @default. Every listed dimension must be reached through this path. @segment and @filter still scope the rows of @from and never remove a row. A row of @from whose reference is null or matches nothing is in no row of the report." }, - { "type": "attr", "subType": "string", "name": "dbSetName", "min": 0, "max": 1, "ports": ["csharp"], "description": "C#-only (EF Core): the DbSet property name the generated context gives this object, in place of the plural derived from its name. Set it when two objects in one generated context would otherwise claim the same property. Every port loads and type-checks it; only C# acts on it." } + { "type": "attr", "subType": "string", "name": "collectionName", "min": 0, "max": 1, "implementedIn": ["csharp"], "description": "The name of this object's collection, in the object's own casing (e.g. 'CaseHistory', 'Queues'): the C# DbSet property the generated context gives it. It need not be a grammatical plural. Absent, the project's pluralizer derives it from the object's name (naming.collections in .metaobjects/config.json; built in: standard, none; an application may register its own)." } ] } ] From b0220c30add7fda4c73dc5de42b14bfbdc16e8bf Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 12 Nov 2026 01:03:54 -0500 Subject: [PATCH 2/4] no-mistakes(review): apply reviewer inline fixes --- .../metadata/test/object-definition-completeness.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/typescript/packages/metadata/test/object-definition-completeness.test.ts b/server/typescript/packages/metadata/test/object-definition-completeness.test.ts index 3806007a14..af2617950c 100644 --- a/server/typescript/packages/metadata/test/object-definition-completeness.test.ts +++ b/server/typescript/packages/metadata/test/object-definition-completeness.test.ts @@ -86,7 +86,7 @@ const REPORT_ATTRS: Record = { spine: { valueType: "string", required: false }, }; -// Cross-port attrs some ports act on: @collectionName (implementedIn C# + TypeScript) on entity / +// Cross-port attrs some ports act on: @collectionName (implementedIn C#) on entity / // projection / report, and the JVM's @object / @objectAdapter on object.base, which every subtype // composes. const COLLECTION_NAME: Record = { From 26f290810917a4cd3f505ef19e8a10a4b8efbf74 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 12 Nov 2026 01:09:28 -0500 Subject: [PATCH 3/4] no-mistakes(review): Read naming.collections in TS, keep it in probes, add tests --- .../csharp/MetaObjects.Codegen/CodegenCli.cs | 2 +- .../CollectionNameVocabularyTests.cs | 37 +++++++ .../database/CollectionNamingTest.java | 104 ++++++++++++++++++ server/python/src/metaobjects/cli.py | 2 +- server/typescript/packages/sdk/src/config.ts | 9 ++ .../sdk/test/collection-naming-config.test.ts | 90 +++++++++++++++ 6 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 server/java/metadata/src/test/java/com/metaobjects/database/CollectionNamingTest.java create mode 100644 server/typescript/packages/sdk/test/collection-naming-config.test.ts diff --git a/server/csharp/MetaObjects.Codegen/CodegenCli.cs b/server/csharp/MetaObjects.Codegen/CodegenCli.cs index 0ba051f8e9..d07cdb8103 100644 --- a/server/csharp/MetaObjects.Codegen/CodegenCli.cs +++ b/server/csharp/MetaObjects.Codegen/CodegenCli.cs @@ -815,7 +815,7 @@ private static IEnumerable NamingCandidates(PhysicalNaming used) var tables = NamingStrategies.Tables; foreach (var t in tables.OrderBy(t => t == used.Tables ? 0 : 1)) foreach (var c in columns.OrderBy(c => c == used.Columns ? 0 : 1)) - if (new PhysicalNaming(c, t) is var candidate && candidate != used) + if (new PhysicalNaming(c, t) { Collections = used.Collections } is var candidate && candidate != used) yield return candidate; } diff --git a/server/csharp/MetaObjects.Conformance.Tests/CollectionNameVocabularyTests.cs b/server/csharp/MetaObjects.Conformance.Tests/CollectionNameVocabularyTests.cs index dbc84fc4a1..fcaccfd0d0 100644 --- a/server/csharp/MetaObjects.Conformance.Tests/CollectionNameVocabularyTests.cs +++ b/server/csharp/MetaObjects.Conformance.Tests/CollectionNameVocabularyTests.cs @@ -3,8 +3,10 @@ // type-checks it. It loads under the strict // loader on object.entity / projection / report. +using System.Text.Json; using MetaObjects; using MetaObjects.Loader; +using MetaObjects.Meta; using Xunit; namespace MetaObjects.Conformance.Tests; @@ -46,4 +48,39 @@ public void It_is_shared_vocabulary_marked_by_implementing_port() var attr = registry.Find("object", "entity")!.Attributes.Single(a => a.Name == "collectionName"); Assert.Equal(new[] { "csharp" }, attr.ImplementedIn); } + [Fact] + public void The_standard_pluralizer_matches_the_shared_naming_conformance_corpus() + { + string repoRoot = Path.GetDirectoryName(Path.GetDirectoryName(CorpusRoot.Path)!)!; + using var doc = JsonDocument.Parse(File.ReadAllText( + Path.Combine(repoRoot, "fixtures", "naming-conformance", "already-plural-pluralize.json"))); + var cases = doc.RootElement.GetProperty("cases").EnumerateArray().ToList(); + Assert.NotEmpty(cases); + foreach (var c in cases) + { + string name = c.GetProperty("name").GetString()!; + Assert.Equal((name, c.GetProperty("apiPlural").GetString()), (name, CollectionNames.StandardPluralize(name))); + } + } + + [Theory] + [InlineData(null, "CaseHistories")] + [InlineData("standard", "CaseHistories")] + [InlineData("none", "CaseHistory")] + public void An_object_without_collectionName_takes_the_project_pluralizer(string? collections, string expected) + { + const string model = """ + { "metadata.root": { "package": "acme", "children": [ + { "object.entity": { "name": "CaseHistory", "children": [ { "field.long": { "name": "id" } } ] } }, + { "object.entity": { "name": "Diagnosis", "@collectionName": "DiagnosisRows", "children": [ { "field.long": { "name": "id" } } ] } } + ] } } + """; + var result = new MetaDataLoader(Provider.ComposeRegistry(CoreTypes.LibraryProviders), strict: true) + .Load([new InMemoryStringSource(model, id: "meta.json")]); + Assert.Empty(result.Errors.Select(e => e.Code + ": " + e.Message)); + result.Root.PhysicalNaming = PhysicalNaming.Default with { Collections = collections }; + var objects = result.Root.Objects().ToDictionary(o => o.Name); + Assert.Equal(expected, CollectionNames.Of(objects["CaseHistory"])); + Assert.Equal("DiagnosisRows", CollectionNames.Of(objects["Diagnosis"])); + } } diff --git a/server/java/metadata/src/test/java/com/metaobjects/database/CollectionNamingTest.java b/server/java/metadata/src/test/java/com/metaobjects/database/CollectionNamingTest.java new file mode 100644 index 0000000000..3eb56c9a8a --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/database/CollectionNamingTest.java @@ -0,0 +1,104 @@ +package com.metaobjects.database; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.metaobjects.MetaDataException; +import com.metaobjects.config.NeutralConfig; +import org.junit.After; +import org.junit.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +/** + * {@code naming.collections} names the pluralizer an object without {@code @collectionName} + * takes its collection name from. Java generates nothing from it yet, but a project shared with + * a port that does carries the key, so Java reads it, refuses an unknown name like + * {@code columns} and {@code tables}, and keeps the selected name on the loaded naming. + */ +public class CollectionNamingTest { + + @After + public void reset() { + NamingStrategies.resetForTesting(); + } + + private static PhysicalNaming readNaming(String naming) throws IOException { + Path dir = Files.createTempDirectory("mo-collection-naming-"); + try { + Files.createDirectory(dir.resolve(".metaobjects")); + Files.writeString(dir.resolve(".metaobjects").resolve("config.json"), + "{ \"schema_version\": 1, \"naming\": " + naming + " }"); + return NeutralConfig.read(dir).orElseThrow().getPhysicalNaming(); + } finally { + try (Stream paths = Files.walk(dir)) { + paths.sorted(Comparator.reverseOrder()).forEach(p -> p.toFile().delete()); + } + } + } + + @Test + public void theBuiltInsPluralizeOrKeepTheName() { + assertEquals(List.of("standard", "none"), NamingStrategies.BUILT_IN_PLURALIZERS); + assertEquals("standard", NamingStrategies.DEFAULT_PLURALIZER); + assertEquals("CaseHistories", NamingStrategies.pluralizer("standard").apply("CaseHistory")); + assertEquals("CaseHistory", NamingStrategies.pluralizer("none").apply("CaseHistory")); + } + + @Test + public void standardMatchesTheSharedNamingConformanceCorpus() throws IOException { + Path repoRoot = Path.of(System.getProperty("user.dir")).resolve("../../..").normalize(); + Path fixture = repoRoot.resolve("fixtures/naming-conformance/already-plural-pluralize.json"); + JsonArray cases = JsonParser.parseString(Files.readString(fixture)).getAsJsonObject().getAsJsonArray("cases"); + assertFalse(cases.isEmpty()); + for (int i = 0; i < cases.size(); i++) { + JsonObject c = cases.get(i).getAsJsonObject(); + String name = c.get("name").getAsString(); + assertEquals(name, c.get("apiPlural").getAsString(), NamingStrategies.standardPluralize(name)); + } + } + + @Test + public void theConfigReadsABuiltIn() throws IOException { + assertEquals("none", readNaming("{ \"collections\": \"none\" }").collections()); + } + + @Test + public void absentNamesNoPluralizer() throws IOException { + assertNull(readNaming("{ \"columns\": \"snake_case\" }").collections()); + } + + @Test + public void anUnknownPluralizerIsRefusedNamingTheKind() { + MetaDataException ex = assertThrows(MetaDataException.class, + () -> readNaming("{ \"collections\": \"latin\" }")); + assertTrue(ex.getMessage(), ex.getMessage().contains("\"naming.collections\" names \"latin\"")); + assertTrue(ex.getMessage(), ex.getMessage().contains("Java port")); + } + + @Test + public void anApplicationPluralizerIsSelectable() throws IOException { + NamingStrategies.registerPluralizer("java-test-shout", n -> n.toUpperCase() + "S"); + assertTrue(NamingStrategies.registeredPluralizers().contains("java-test-shout")); + assertEquals("java-test-shout", readNaming("{ \"collections\": \"java-test-shout\" }").collections()); + NamingStrategies.resetForTesting(); + assertFalse(NamingStrategies.isPluralizer("java-test-shout")); + } + + @Test + public void aBuiltInPluralizerCannotBeReplaced() { + assertThrows(IllegalArgumentException.class, () -> NamingStrategies.registerPluralizer("none", n -> n)); + assertThrows(IllegalArgumentException.class, () -> NamingStrategies.registerPluralizer("standard", n -> n)); + } +} diff --git a/server/python/src/metaobjects/cli.py b/server/python/src/metaobjects/cli.py index 01fc98e100..e39ba81e88 100644 --- a/server/python/src/metaobjects/cli.py +++ b/server/python/src/metaobjects/cli.py @@ -2320,7 +2320,7 @@ def naming_candidates(used: NamingRules) -> list[NamingRules]: candidate for t in tables for c in columns - if (candidate := NamingRules(columns=c, tables=t)) != used + if (candidate := NamingRules(columns=c, tables=t, collections=used.collections)) != used ] diff --git a/server/typescript/packages/sdk/src/config.ts b/server/typescript/packages/sdk/src/config.ts index 25c67fa074..0139c831b7 100644 --- a/server/typescript/packages/sdk/src/config.ts +++ b/server/typescript/packages/sdk/src/config.ts @@ -7,6 +7,7 @@ import { DEFAULT_PHYSICAL_NAMING, isRegisteredColumnNamingStrategy, isRegisteredTableNamingRule, + isRegisteredPluralizer, unknownNamingStrategyMessage, DEFAULT_WIRE_FORMAT, DEFAULT_ENUM_DEFAULTS, @@ -227,6 +228,8 @@ export const ConfigSchema = z.object({ * the PROJECT, read here by every port, because every port reads and writes the one database * `meta migrate` created: codegen, migrate, the runtime and verify all resolve through it. * Absent means `snake_case` for both, which is what `meta migrate` has always created. + * `collections` names the pluralizer (`standard`, `none` or one registered with + * `registerPluralizer`) an object without `@collectionName` takes its collection name from. * * Each value is the NAME of a registered strategy (ADR-0058 Amendment 1): a built-in * (`columns`: `snake_case`, `literal`, `kebab-case`, `pascal`; `tables`: `snake_case`, @@ -247,6 +250,11 @@ export const ConfigSchema = z.object({ if (!isRegisteredTableNamingRule(n)) ctx.addIssue({ code: "custom", message: unknownNamingStrategyMessage("tables", n) }); }) .optional(), + collections: z.string() + .superRefine((n, ctx) => { + if (!isRegisteredPluralizer(n)) ctx.addIssue({ code: "custom", message: unknownNamingStrategyMessage("collections", n) }); + }) + .optional(), }) .strict() .optional(), @@ -316,6 +324,7 @@ export function physicalNamingFromConfig(cfg: Pick): PhysicalN return { columns: cfg.naming?.columns ?? DEFAULT_PHYSICAL_NAMING.columns, tables: cfg.naming?.tables ?? DEFAULT_PHYSICAL_NAMING.tables, + ...(cfg.naming?.collections !== undefined ? { collections: cfg.naming.collections } : {}), }; } diff --git a/server/typescript/packages/sdk/test/collection-naming-config.test.ts b/server/typescript/packages/sdk/test/collection-naming-config.test.ts new file mode 100644 index 0000000000..d663309322 --- /dev/null +++ b/server/typescript/packages/sdk/test/collection-naming-config.test.ts @@ -0,0 +1,90 @@ +// `naming.collections` names the pluralizer an object without `@collectionName` takes its +// collection name from. The config reader accepts a registered pluralizer, refuses an unknown +// one when the file is read, and the loaded root carries the selection to `collectionNameOf`. + +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + InMemoryStringSource, + MetaDataLoader, + collectionNameOf, + isMetaRoot, + pluralizer, + registerPluralizer, + resetNamingStrategiesForTesting, + type MetaRoot, +} from "@metaobjectsdev/metadata"; +import { loadConfig, physicalNamingFromConfig } from "../src/config.js"; + +const PLURALS = resolve(import.meta.dir, "../../../../../fixtures/naming-conformance/already-plural-pluralize.json"); + +afterEach(resetNamingStrategiesForTesting); + +async function readConfigWith(naming: unknown) { + const dir = mkdtempSync(join(tmpdir(), "mo-collection-naming-")); + try { + writeFileSync(join(dir, "config.json"), JSON.stringify({ schema_version: 1, naming })); + return await loadConfig(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +async function loadModel(): Promise { + const json = JSON.stringify({ + "metadata.root": { + package: "acme", + children: [ + { "object.entity": { name: "CaseHistory", children: [{ "field.long": { name: "id" } }] } }, + { "object.entity": { name: "Diagnosis", "@collectionName": "DiagnosisRows", children: [{ "field.long": { name: "id" } }] } }, + ], + }, + }); + const result = await new MetaDataLoader().load([new InMemoryStringSource(json)]); + expect(result.errors).toEqual([]); + if (!isMetaRoot(result.root)) throw new Error("not a root"); + return result.root; +} + +describe("naming.collections", () => { + test("the standard pluralizer matches the shared naming-conformance corpus", () => { + const corpus = JSON.parse(readFileSync(PLURALS, "utf8")) as { cases: { name: string; apiPlural: string }[] }; + expect(corpus.cases.length).toBeGreaterThan(0); + for (const c of corpus.cases) expect([c.name, pluralizer("standard")(c.name)]).toEqual([c.name, c.apiPlural]); + }); + + test("the config reads a built-in and the root carries it to collectionNameOf", async () => { + const config = await readConfigWith({ collections: "none" }); + expect(config.naming?.collections).toBe("none"); + const root = await loadModel(); + root.physicalNaming = physicalNamingFromConfig(config); + expect(collectionNameOf(root.findObject("CaseHistory")!)).toBe("CaseHistory"); + expect(collectionNameOf(root.findObject("Diagnosis")!)).toBe("DiagnosisRows"); + }); + + test("absent, an object takes the standard pluralizer", async () => { + const config = await readConfigWith({ columns: "snake_case" }); + expect(physicalNamingFromConfig(config).collections).toBeUndefined(); + const root = await loadModel(); + root.physicalNaming = physicalNamingFromConfig(config); + expect(collectionNameOf(root.findObject("CaseHistory")!)).toBe("CaseHistories"); + }); + + test("an unknown pluralizer is refused when the config is read", async () => { + await expect(readConfigWith({ collections: "latin" })).rejects.toThrow(/"naming\.collections" names "latin"/); + }); + + test("an application pluralizer is selectable by name", async () => { + registerPluralizer("ts-test-shout", (n) => n.toUpperCase() + "S"); + const config = await readConfigWith({ collections: "ts-test-shout" }); + const root = await loadModel(); + root.physicalNaming = physicalNamingFromConfig(config); + expect(collectionNameOf(root.findObject("CaseHistory")!)).toBe("CASEHISTORYS"); + }); + + test("a built-in pluralizer cannot be replaced", () => { + expect(() => registerPluralizer("none", (n) => n)).toThrow(); + }); +}); From 4187a4eeee3d2ae5597897901558b8adefc4137d Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 12 Nov 2026 02:18:08 -0500 Subject: [PATCH 4/4] no-mistakes(document): Document @collectionName implementedIn and pluralizer --- docs/CONFORMANCE.md | 3 +++ docs/features/migrations/physical-naming.md | 7 ++++--- fixtures/registry-conformance/README.md | 3 ++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index fa4087bfa8..25ceceb556 100644 --- a/docs/CONFORMANCE.md +++ b/docs/CONFORMANCE.md @@ -148,6 +148,9 @@ canonical (`fixtures/registry-conformance/expected-registry.json`). - `view.image`, and its `@accept`, `@aspectRatio`, `@maxBytes`, `@maxEdge`, `@store` - *Implemented in Java only* (`implementedIn: ["java"]`): `object.* @object` (ADR-0001), the native class an object binds to. Java's runtime acts on it; Java's own behaviour is unchanged. +- *Implemented in C# only* (`implementedIn: ["csharp"]`): `object.* @collectionName`, the + collection name the generated DbSet takes. Every port registers it and reads the `naming.collections` + key that names the default pluralizer; C# codegen does not apply that pluralizer yet. - *Loads strictly in every port.* Each port registers the `metaobjects-ui-web` provider (`view.textarea @rows`, the `view.image` attrs) and `@object` / `@objectAdapter` on `object.*`, loads all three fixtures strictly, and emits `implementedIn` in its manifest, which diff --git a/docs/features/migrations/physical-naming.md b/docs/features/migrations/physical-naming.md index c00fb32f74..d1ce792930 100644 --- a/docs/features/migrations/physical-naming.md +++ b/docs/features/migrations/physical-naming.md @@ -62,9 +62,10 @@ and is `pascal` otherwise: Table rules register the same way (`namingStrategies.tables`, `RegisterTables`, `registerTables`, `register_table_naming_rule`) and name a source with no physical name; the entity-name fallback -is still made plural after the rule. Pluralizers register the same way (`registerPluralizer`, `RegisterPluralizer`, -`registerPluralizer`, `register_pluralizer`): an object name in, in its declared casing, its -collection name out. `standard` is the cross-port plural and `none` keeps the name as it is. A built-in name cannot be replaced. Register the same +is still made plural after the rule. Pluralizers register the same way (`registerPluralizer`, +`RegisterPluralizer`, `registerPluralizer`, `register_pluralizer`): an object name in, in its +declared casing, its collection name out. `standard` is the cross-port plural and `none` keeps the +name as it is. A built-in name cannot be replaced. Register the same strategy in every port that reads the database, because each port resolves the names itself. Every port looks a strategy up by name each time it applies one, so registering again under the same name replaces it for everything that runs afterwards. A test that registers its own calls diff --git a/fixtures/registry-conformance/README.md b/fixtures/registry-conformance/README.md index 5d84ae7efb..81ed98be3f 100644 --- a/fixtures/registry-conformance/README.md +++ b/fixtures/registry-conformance/README.md @@ -257,7 +257,8 @@ still fails everywhere. generic `view.*` controls (`checkbox`, `date`, `dropdown`, `hidden`, `hotlink`, `image`, `month`, `number`, `password`, `radio`, `text`, `textarea`, `web`) and their attrs (`view.textarea @rows`, `view.image @accept/@aspectRatio/@maxBytes/@maxEdge/@store`) are - `["typescript"]`; `object.* @object` (ADR-0001) is `["java"]`. They used to be carved out + `["typescript"]`; `object.* @object` (ADR-0001) is `["java"]`; `object.* @collectionName` is + `["csharp"]`. They used to be carved out as `PRESENTATION_ONLY` / `NATIVE_BINDING`; both reasons are retired. The parity gaps are listed in `docs/CONFORMANCE.md`, "Split coverage". - **Kind 2 — language-specific by nature.** The attr carries `"ports": [...]`: