From 10505fd1c08c1ed0af964236919f85a487a78777 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 12 Nov 2026 01:04:55 -0500 Subject: [PATCH 1/3] feat(metadata): walk an identity.reference backwards in an origin @via hop An origin @via hop may now walk an identity.reference backwards, from the entity it targets to the entity holding it, named by the holder's collection name in lower camel case (@collectionName when declared, else the project's pluralizer). The hop is to-many, and two references answering one name are refused with ERR_AMBIGUOUS_PATH; a declared relationship or reference of the same name still wins. TypeScript, C#, Java (Kotlin through it) and Python implement it, gated by the shared fixtures origin-reverse-reference-hop, origin-reverse-hop-collection-name and error-origin-reverse-hop-ambiguous. --- AGENTS.md | 2 +- CHANGELOG.md | 8 + docs/CONFORMANCE.md | 6 +- docs/features/migrations/physical-naming.md | 2 +- examples/showcase/site-payload.json | 2 +- .../expected-errors.json | 17 ++ .../input/meta.care.json | 121 ++++++++++++++ .../providers.json | 1 + .../expected.json | 158 ++++++++++++++++++ .../input/meta.cases.json | 150 +++++++++++++++++ .../providers.json | 1 + .../expected.json | 132 +++++++++++++++ .../input/meta.care.json | 42 +++++ .../providers.json | 1 + .../MetaObjects/Loader/ValidationPasses.cs | 60 +++++-- .../MetaObjects/Meta/ReverseReferenceHops.cs | 47 ++++++ .../loader/ReverseReferenceHops.java | 61 +++++++ .../metaobjects/loader/ValidationPhase.java | 90 ++++++++-- .../metaobjects/object/CollectionNames.java | 34 ++++ .../metaobjects/loader/validation_passes.py | 108 +++++++++--- .../meta/core/object/collection_names.py | 27 +++ .../src/core/relationship/find-reference.ts | 43 +++++ .../typescript/packages/metadata/src/index.ts | 5 +- .../metadata/src/loader/validation-passes.ts | 55 ++++-- 24 files changed, 1104 insertions(+), 69 deletions(-) create mode 100644 fixtures/conformance/error-origin-reverse-hop-ambiguous/expected-errors.json create mode 100644 fixtures/conformance/error-origin-reverse-hop-ambiguous/input/meta.care.json create mode 100644 fixtures/conformance/error-origin-reverse-hop-ambiguous/providers.json create mode 100644 fixtures/conformance/origin-reverse-hop-collection-name/expected.json create mode 100644 fixtures/conformance/origin-reverse-hop-collection-name/input/meta.cases.json create mode 100644 fixtures/conformance/origin-reverse-hop-collection-name/providers.json create mode 100644 fixtures/conformance/origin-reverse-reference-hop/expected.json create mode 100644 fixtures/conformance/origin-reverse-reference-hop/input/meta.care.json create mode 100644 fixtures/conformance/origin-reverse-reference-hop/providers.json create mode 100644 server/csharp/MetaObjects/Meta/ReverseReferenceHops.cs create mode 100644 server/java/metadata/src/main/java/com/metaobjects/loader/ReverseReferenceHops.java create mode 100644 server/java/metadata/src/main/java/com/metaobjects/object/CollectionNames.java create mode 100644 server/python/src/metaobjects/meta/core/object/collection_names.py diff --git a/AGENTS.md b/AGENTS.md index d9afccf518..aafcf4ce63 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/` (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. +- Metamodel: `fixtures/conformance/` (458 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 bf07d8ea50..c9799aba05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,14 @@ Each item below is described in full under Added, Changed or Fixed; this list is 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. +- **Reverse `@via` hops.** An origin's `@via` hop may walk an `identity.reference` backwards, from + the entity it targets to the entity holding it, named by the holder's collection name in lower + camel case (`Patient.consents` walks `Consent.patient`; a declared `"@collectionName": + "CaseHistory"` gives `Case.caseHistory`). The hop is to-many, so a passthrough across it is + refused and an aggregate over it is allowed, and two references that answer one name are refused + (`ERR_AMBIGUOUS_PATH`). A declared relationship or reference of the same name still wins. Every + port's loader implements it, gated by `origin-reverse-reference-hop`, + `origin-reverse-hop-collection-name` and `error-origin-reverse-hop-ambiguous`. - **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 diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index 25ceceb556..4fac400837 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) | 455 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | +| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 458 | ✓ | ✓ | 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 | ✓ | — | — | — | ✓ | @@ -354,7 +354,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker. ## Fixture-to-doc mapping -### `fixtures/conformance/` — metamodel loader + canonical serializer (455) +### `fixtures/conformance/` — metamodel loader + canonical serializer (458) | Fixture prefix | Feature doc | |---|---| @@ -716,7 +716,7 @@ JSON and verify payload feeds it unchanged. ## Orphaned fixtures (tested but not yet documented) -The fixtures in the nine corpora mapped above (metamodel 455 + yaml 16 + verify 45 +The fixtures in the nine corpora mapped above (metamodel 458 + 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 d1ce792930..82606abf68 100644 --- a/docs/features/migrations/physical-naming.md +++ b/docs/features/migrations/physical-naming.md @@ -27,7 +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` | +| `collections` | `standard`, `none`, or a name your application registered | an object with no `@collectionName`: the collection name the generated C# DbSet takes and a backwards `@via` hop is named by | `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. diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index 162a816c8b..e9edb00704 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -7,7 +7,7 @@ "metamodel": "1.3" }, "counts": { - "fixtures": 455, + "fixtures": 458, "corpora": 38, "baseTypes": 17 }, diff --git a/fixtures/conformance/error-origin-reverse-hop-ambiguous/expected-errors.json b/fixtures/conformance/error-origin-reverse-hop-ambiguous/expected-errors.json new file mode 100644 index 0000000000..213b9028b8 --- /dev/null +++ b/fixtures/conformance/error-origin-reverse-hop-ambiguous/expected-errors.json @@ -0,0 +1,17 @@ +{ + "errors": [ + { + "code": "ERR_AMBIGUOUS_PATH", + "source": { + "format": "resolved", + "files": [ + "meta.care.json" + ], + "jsonPath": "$['metadata.root'].children[2]['object.projection'].children[2]['field.int'].children[0]['origin.aggregate']", + "referrer": "PatientConsentSummary::consentCount", + "target": "acme::care::Patient.consents" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-origin-reverse-hop-ambiguous/input/meta.care.json b/fixtures/conformance/error-origin-reverse-hop-ambiguous/input/meta.care.json new file mode 100644 index 0000000000..aac28e4a2b --- /dev/null +++ b/fixtures/conformance/error-origin-reverse-hop-ambiguous/input/meta.care.json @@ -0,0 +1,121 @@ +{ + "metadata.root": { + "package": "acme::care", + "children": [ + { + "object.entity": { + "name": "Patient", + "children": [ + { + "source.rdb": { + "@table": "patients" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": "id" + } + } + ] + } + }, + { + "object.entity": { + "name": "Consent", + "children": [ + { + "source.rdb": { + "@table": "consents" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "patientId" + } + }, + { + "field.boolean": { + "name": "onFile" + } + }, + { + "field.long": { + "name": "witnessId" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": "id" + } + }, + { + "identity.reference": { + "name": "patient", + "@fields": "patientId", + "@references": "acme::care::Patient" + } + }, + { + "identity.reference": { + "name": "witness", + "@fields": "witnessId", + "@references": "acme::care::Patient" + } + } + ] + } + }, + { + "object.projection": { + "name": "PatientConsentSummary", + "children": [ + { + "source.rdb": { + "@kind": "view", + "@view": "v_patient_consent_summary" + } + }, + { + "field.long": { + "name": "id", + "extends": "acme::care::Patient.id" + } + }, + { + "field.int": { + "name": "consentCount", + "children": [ + { + "origin.aggregate": { + "@agg": "count", + "@of": "acme::care::Consent.id", + "@via": "acme::care::Patient.consents" + } + } + ] + } + }, + { + "identity.primary": { + "name": "id", + "extends": "acme::care::Patient.id" + } + } + ] + } + } + ] + } +} \ No newline at end of file diff --git a/fixtures/conformance/error-origin-reverse-hop-ambiguous/providers.json b/fixtures/conformance/error-origin-reverse-hop-ambiguous/providers.json new file mode 100644 index 0000000000..f9bb435a37 --- /dev/null +++ b/fixtures/conformance/error-origin-reverse-hop-ambiguous/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/origin-reverse-hop-collection-name/expected.json b/fixtures/conformance/origin-reverse-hop-collection-name/expected.json new file mode 100644 index 0000000000..c9946a7d44 --- /dev/null +++ b/fixtures/conformance/origin-reverse-hop-collection-name/expected.json @@ -0,0 +1,158 @@ +{ + "metadata.root": { + "package": "acme::cases", + "children": [ + { + "object.entity": { + "name": "Case", + "children": [ + { + "source.rdb": { + "@table": "cases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "CaseHistory", + "@collectionName": "CaseHistory", + "children": [ + { + "source.rdb": { + "@table": "case_history" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "caseId" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.timestamp": { + "name": "createdDate" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "caseId", + "@fields": [ + "caseId" + ], + "@references": "acme::cases::Case" + } + }, + { + "dimension.attribute": { + "name": "status", + "@of": "acme::cases::CaseHistory.status" + } + }, + { + "measure.aggregate": { + "name": "entries", + "@agg": "count", + "@of": [ + "acme::cases::CaseHistory.id" + ] + } + } + ] + } + }, + { + "object.projection": { + "name": "CaseActivity", + "@collectionName": "CaseActivity", + "children": [ + { + "source.rdb": { + "@kind": "view", + "@view": "v_case_activity" + } + }, + { + "field.long": { + "name": "id", + "extends": "acme::cases::Case.id" + } + }, + { + "field.int": { + "name": "historyCount", + "children": [ + { + "origin.aggregate": { + "@agg": "count", + "@of": "acme::cases::CaseHistory.id", + "@via": "acme::cases::Case.caseHistory" + } + } + ] + } + }, + { + "identity.primary": { + "name": "id", + "extends": "acme::cases::Case.id" + } + } + ] + } + }, + { + "object.report": { + "name": "HistoryCounts", + "@collectionName": "HistoryCounts", + "@dimensions": [ + "status" + ], + "@from": "acme::cases::CaseHistory", + "@measures": [ + "entries" + ], + "children": [ + { + "source.rdb": { + "@kind": "view", + "@view": "v_history_counts" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/origin-reverse-hop-collection-name/input/meta.cases.json b/fixtures/conformance/origin-reverse-hop-collection-name/input/meta.cases.json new file mode 100644 index 0000000000..dd54b0f2ec --- /dev/null +++ b/fixtures/conformance/origin-reverse-hop-collection-name/input/meta.cases.json @@ -0,0 +1,150 @@ +{ + "metadata.root": { + "package": "acme::cases", + "children": [ + { + "object.entity": { + "name": "Case", + "children": [ + { + "source.rdb": { + "@table": "cases" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": "id" + } + } + ] + } + }, + { + "object.entity": { + "name": "CaseHistory", + "@collectionName": "CaseHistory", + "children": [ + { + "source.rdb": { + "@table": "case_history" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "caseId" + } + }, + { + "field.string": { + "name": "status" + } + }, + { + "field.timestamp": { + "name": "createdDate" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": "id" + } + }, + { + "identity.reference": { + "name": "caseId", + "@fields": "caseId", + "@references": "acme::cases::Case" + } + }, + { + "dimension.attribute": { + "name": "status", + "@of": "acme::cases::CaseHistory.status" + } + }, + { + "measure.aggregate": { + "name": "entries", + "@agg": "count", + "@of": "acme::cases::CaseHistory.id" + } + } + ] + } + }, + { + "object.projection": { + "name": "CaseActivity", + "@collectionName": "CaseActivity", + "children": [ + { + "source.rdb": { + "@kind": "view", + "@view": "v_case_activity" + } + }, + { + "field.long": { + "name": "id", + "extends": "acme::cases::Case.id" + } + }, + { + "field.int": { + "name": "historyCount", + "children": [ + { + "origin.aggregate": { + "@agg": "count", + "@of": "acme::cases::CaseHistory.id", + "@via": "acme::cases::Case.caseHistory" + } + } + ] + } + }, + { + "identity.primary": { + "name": "id", + "extends": "acme::cases::Case.id" + } + } + ] + } + }, + { + "object.report": { + "name": "HistoryCounts", + "@collectionName": "HistoryCounts", + "@from": "acme::cases::CaseHistory", + "@dimensions": [ + "status" + ], + "@measures": [ + "entries" + ], + "children": [ + { + "source.rdb": { + "@kind": "view", + "@view": "v_history_counts" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/origin-reverse-hop-collection-name/providers.json b/fixtures/conformance/origin-reverse-hop-collection-name/providers.json new file mode 100644 index 0000000000..f9bb435a37 --- /dev/null +++ b/fixtures/conformance/origin-reverse-hop-collection-name/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/fixtures/conformance/origin-reverse-reference-hop/expected.json b/fixtures/conformance/origin-reverse-reference-hop/expected.json new file mode 100644 index 0000000000..41988191ee --- /dev/null +++ b/fixtures/conformance/origin-reverse-reference-hop/expected.json @@ -0,0 +1,132 @@ +{ + "metadata.root": { + "package": "acme::care", + "children": [ + { + "object.entity": { + "name": "Patient", + "children": [ + { + "source.rdb": { + "@table": "patients" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + } + ] + } + }, + { + "object.entity": { + "name": "Consent", + "children": [ + { + "source.rdb": { + "@table": "consents" + } + }, + { + "field.long": { + "name": "id" + } + }, + { + "field.long": { + "name": "patientId" + } + }, + { + "field.boolean": { + "name": "onFile" + } + }, + { + "identity.primary": { + "name": "id", + "@fields": [ + "id" + ] + } + }, + { + "identity.reference": { + "name": "patient", + "@fields": [ + "patientId" + ], + "@references": "acme::care::Patient" + } + } + ] + } + }, + { + "object.projection": { + "name": "PatientConsentSummary", + "children": [ + { + "source.rdb": { + "@kind": "view", + "@view": "v_patient_consent_summary" + } + }, + { + "field.long": { + "name": "id", + "extends": "acme::care::Patient.id" + } + }, + { + "field.int": { + "name": "consentCount", + "children": [ + { + "origin.aggregate": { + "@agg": "count", + "@of": "acme::care::Consent.id", + "@via": "acme::care::Patient.consents" + } + } + ] + } + }, + { + "field.boolean": { + "name": "latestOnFile", + "children": [ + { + "origin.first": { + "@of": "acme::care::Consent.onFile", + "@orderBy": [ + "id:desc" + ], + "@via": "acme::care::Patient.consents" + } + } + ] + } + }, + { + "identity.primary": { + "name": "id", + "extends": "acme::care::Patient.id" + } + } + ] + } + } + ] + } +} + diff --git a/fixtures/conformance/origin-reverse-reference-hop/input/meta.care.json b/fixtures/conformance/origin-reverse-reference-hop/input/meta.care.json new file mode 100644 index 0000000000..a14ed58011 --- /dev/null +++ b/fixtures/conformance/origin-reverse-reference-hop/input/meta.care.json @@ -0,0 +1,42 @@ +{ + "metadata.root": { + "package": "acme::care", + "children": [ + { + "object.entity": { + "name": "Patient", + "children": [ + { "source.rdb": { "@table": "patients" } }, + { "field.long": { "name": "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } } + ] + } + }, + { + "object.entity": { + "name": "Consent", + "children": [ + { "source.rdb": { "@table": "consents" } }, + { "field.long": { "name": "id" } }, + { "field.long": { "name": "patientId" } }, + { "field.boolean": { "name": "onFile" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "identity.reference": { "name": "patient", "@fields": "patientId", "@references": "acme::care::Patient" } } + ] + } + }, + { + "object.projection": { + "name": "PatientConsentSummary", + "children": [ + { "source.rdb": { "@kind": "view", "@view": "v_patient_consent_summary" } }, + { "field.long": { "name": "id", "extends": "acme::care::Patient.id" } }, + { "field.int": { "name": "consentCount", "children": [ { "origin.aggregate": { "@agg": "count", "@of": "acme::care::Consent.id", "@via": "acme::care::Patient.consents" } } ] } }, + { "field.boolean": { "name": "latestOnFile", "children": [ { "origin.first": { "@of": "acme::care::Consent.onFile", "@via": "acme::care::Patient.consents", "@orderBy": ["id:desc"] } } ] } }, + { "identity.primary": { "name": "id", "extends": "acme::care::Patient.id" } } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/origin-reverse-reference-hop/providers.json b/fixtures/conformance/origin-reverse-reference-hop/providers.json new file mode 100644 index 0000000000..f9bb435a37 --- /dev/null +++ b/fixtures/conformance/origin-reverse-reference-hop/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types","metaobjects-db"] diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs index de9c02b8e2..782b6d850b 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs @@ -528,7 +528,7 @@ public static IReadOnlyList ValidateOriginPaths(MetaData root) { var walked = ValidateViaPath(via, root, obj, field.Name, errors, origin.Source); if (walked is WalkedViaPath wp) - CheckPassthroughCardinality(wp.Hops, obj, field.Name, origin.Source, errors); + CheckPassthroughCardinality(wp.Hops, obj, field.Name, origin.Source, errors, wp.Reverse); } else if (fromTarget is ResolvedFromTarget ft2 && !isValueHost) { @@ -610,7 +610,7 @@ public static IReadOnlyList ValidateOriginPaths(MetaData root) else { var predWalked = ValidateViaPath(predVia, root, obj, field.Name, errors, src); - if (predWalked is WalkedViaPath pw) CheckAggregateCardinality(pw.Hops, obj, field.Name, src, errors); + if (predWalked is WalkedViaPath pw) CheckAggregateCardinality(pw.Hops, obj, field.Name, src, errors, pw.Reverse); } continue; } @@ -689,7 +689,7 @@ public static IReadOnlyList ValidateOriginPaths(MetaData root) var woWalked = ValidateViaPath(woVia, root, obj, field.Name, errors, src); if (woWalked is WalkedViaPath ww) { - CheckAggregateCardinality(ww.Hops, obj, field.Name, src, errors); + CheckAggregateCardinality(ww.Hops, obj, field.Name, src, errors, ww.Reverse); // @orderBy keys resolve against the @via TERMINAL entity, not @of. ValidateOrderByKeys(orderBy, ww.Terminal, obj, field.Name, "origin.aggregate @agg:collect", src, errors, @@ -720,7 +720,7 @@ public static IReadOnlyList ValidateOriginPaths(MetaData root) { var walked = ValidateViaPath(via, root, obj, field.Name, errors, src); if (walked is WalkedViaPath aw) - CheckAggregateCardinality(aw.Hops, obj, field.Name, src, errors); + CheckAggregateCardinality(aw.Hops, obj, field.Name, src, errors, aw.Reverse); continue; } // FR-024 §6 — no @via on an aggregate: inference applies only @@ -815,7 +815,7 @@ public static IReadOnlyList ValidateOriginPaths(MetaData root) if (viaObj is string via && via != "") { var walked = ValidateViaPath(via, root, obj, field.Name, errors, src); - if (walked is WalkedViaPath fw) CheckAggregateCardinality(fw.Hops, obj, field.Name, src, errors); + if (walked is WalkedViaPath fw) CheckAggregateCardinality(fw.Hops, obj, field.Name, src, errors, fw.Reverse); } else if (ofTarget is ResolvedFromTarget inferTarget) { @@ -1091,7 +1091,7 @@ private static bool IsReferenceHop(MetaData hop) /// entity they terminate at. Returned as a pair (rather than re-walking for the terminal) /// because a second walk means a second copy of the ADR-0042 package-resolution rule — /// mirrors the TS WalkedViaPath. - private readonly record struct WalkedViaPath(List Hops, MetaData Terminal); + private readonly record struct WalkedViaPath(List Hops, MetaData Terminal, IReadOnlySet Reverse); private static WalkedViaPath? ValidateViaPath( string viaAttr, @@ -1138,6 +1138,7 @@ private static bool IsReferenceHop(MetaData hop) // entity name; each successful relationship hop appends a segment. var validSegments = new List { entityName }; var hops = new List(); + var reverse = new HashSet(ReferenceEqualityComparer.Instance); foreach (var relName in relSegments) { @@ -1146,6 +1147,31 @@ private static bool IsReferenceHop(MetaData hop) var rel = FindRelationship(currentObj, relName) ?? FindReference(currentObj, relName); if (rel is null) { + // A hop may walk an identity.reference BACKWARDS, from the entity it targets + // to the entity that declares it, named by that entity's collection name + // (`Patient.consents` for Consent.patient). A declared relationship or reference wins. + var reversed = currentObj is MetaObject walkedFrom && root is MetaRoot metaRoot + ? ReverseReferenceHops.Find(walkedFrom, relName, metaRoot.Objects()) + : []; + if (reversed.Count == 1) + { + validSegments.Add(relName); + hops.Add(reversed[0].Reference); + reverse.Add(reversed[0].Reference); + currentObj = reversed[0].Holder; + continue; + } + if (reversed.Count > 1) + { + string candidates = string.Join(", ", reversed.Select(r => $"\"{r.Reference.Name}\"")); + errors.Add(new MetaError( + $"origin.@via \"{viaAttr}\" on {projectionName}.{fieldName}: the reverse hop \"{relName}\" from {currentObj.Name} " + + $"matches {reversed.Count} identity.reference nodes onto it ({candidates}) — ambiguous. Declare a relationship on " + + $"{currentObj.Name} for this hop, or keep one reference.", + ErrorCode.ERR_AMBIGUOUS_PATH, + Envelope: ResolvedSource.From(originSource, referrer, viaAttr))); + return null; + } string prefix = string.Join('.', validSegments); errors.Add(new MetaError( $"origin.@via \"{viaAttr}\" on {projectionName}.{fieldName}: " + @@ -1192,7 +1218,7 @@ private static bool IsReferenceHop(MetaData hop) } // currentObj is the terminal: every earlier exit returned null. - return new WalkedViaPath(hops, currentObj); + return new WalkedViaPath(hops, currentObj, reverse); } // ------------------------------------------------------------------------- @@ -1202,10 +1228,12 @@ private static bool IsReferenceHop(MetaData hop) /// A hop's effective @cardinality, or null when not declared. A reference hop /// (a forward FK) is inherently to-one — a child names the parent it points at. - private static string? HopCardinality(MetaData rel) - => IsReferenceHop(rel) - ? CARDINALITY_ONE - : rel.Attr(RELATIONSHIP_ATTR_CARDINALITY) as string; + private static string? HopCardinality(MetaData rel, IReadOnlySet? reverse = null) + => reverse is not null && reverse.Contains(rel) + ? CARDINALITY_MANY // A reference walked backwards reaches every row that references the walked-from one + : IsReferenceHop(rel) + ? CARDINALITY_ONE + : rel.Attr(RELATIONSHIP_ATTR_CARDINALITY) as string; /// FR-024: the entity NAMED by a node's dotted extends ref — the OWNER part /// of `....` resolved as an object. Differs from @@ -1331,11 +1359,12 @@ private static bool IsBaseRelationTarget(MetaData target, MetaData baseEntity, M /// ADR-0029 decision 6 — a passthrough via-path must be effectively to-one at /// EVERY hop. A hop is to-many only when it DECLARES @cardinality "many". private static void CheckPassthroughCardinality( - IReadOnlyList hops, MetaData obj, string fieldName, ErrorSource originSource, List errors) + IReadOnlyList hops, MetaData obj, string fieldName, ErrorSource originSource, List errors, + IReadOnlySet? reverse = null) { foreach (var rel in hops) { - if (HopCardinality(rel) == CARDINALITY_MANY) + if (HopCardinality(rel, reverse) == CARDINALITY_MANY) { errors.Add(new MetaError( $"origin.passthrough on {obj.Name}.{fieldName}: @via hop \"{rel.Name}\" is to-many " + @@ -1351,10 +1380,11 @@ private static void CheckPassthroughCardinality( /// to-many hop. The error fires only when the path is PROVABLY to-one /// (every hop declares @cardinality "one"). private static void CheckAggregateCardinality( - IReadOnlyList hops, MetaData obj, string fieldName, ErrorSource originSource, List errors) + IReadOnlyList hops, MetaData obj, string fieldName, ErrorSource originSource, List errors, + IReadOnlySet? reverse = null) { if (hops.Count == 0) return; - bool provablyToOne = hops.All(rel => HopCardinality(rel) == CARDINALITY_ONE); + bool provablyToOne = hops.All(rel => HopCardinality(rel, reverse) == CARDINALITY_ONE); if (provablyToOne) { errors.Add(new MetaError( diff --git a/server/csharp/MetaObjects/Meta/ReverseReferenceHops.cs b/server/csharp/MetaObjects/Meta/ReverseReferenceHops.cs new file mode 100644 index 0000000000..f81daccada --- /dev/null +++ b/server/csharp/MetaObjects/Meta/ReverseReferenceHops.cs @@ -0,0 +1,47 @@ +// A `@via` hop may walk an identity.reference BACKWARDS, from the entity +// it targets to the entity that declares it, with no relationship declared for it. Ported from +// typescript/packages/metadata/src/core/relationship/find-reference.ts (reverseHopName, +// findReverseReferenceHops); the loader corpus fixtures/conformance/origin-reverse-reference-hop +// and error-origin-reverse-hop-ambiguous pin both ports to the same rule. + +using static MetaObjects.Core.Identity.IdentityConstants; + +namespace MetaObjects.Meta; + +/// A reverse @via hop: the reference walked backwards and the object that declares it. +public sealed record ReverseReferenceHop(MetaObject Holder, MetaReferenceIdentity Reference); + +public static class ReverseReferenceHops +{ + /// + /// The name a reverse hop takes: the holder's collection name (its @collectionName, else the project's pluralizer), lowerFirst — + /// consents for Consent. The pluralization is the one every port shares + /// (already-plural names stay; else the suffix rule), as fixtures/naming-conformance pins it. + /// + public static string HopName(MetaObject holder) + { + // the holder's collection name — its @collectionName, else the project's pluralizer. + var plural = CollectionNames.Of(holder); + return plural.Length == 0 ? plural : char.ToLowerInvariant(plural[0]) + plural[1..]; + } + + /// + /// Every identity.reference that targets from a concrete object whose + /// is . One is the hop; none means the hop is not a + /// reverse; more than one is ambiguous. A declared relationship or reference of the same name on + /// the target always wins; callers look those up first. + /// + public static IReadOnlyList Find(MetaObject target, string hopName, IEnumerable objects) + { + var found = new List(); + foreach (var holder in objects) + { + if (holder.IsAbstract || HopName(holder) != hopName) continue; + foreach (var r in holder.ReferenceIdentities()) + // ADR-0059 R7: compare the bound target, never a short name. + if (ReferenceEquals(NamingRefs.RefTarget(r, IDENTITY_REFERENCE_ATTR_REFERENCES), target)) + found.Add(new ReverseReferenceHop(holder, r)); + } + return found; + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ReverseReferenceHops.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ReverseReferenceHops.java new file mode 100644 index 0000000000..412a16f312 --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ReverseReferenceHops.java @@ -0,0 +1,61 @@ +package com.metaobjects.loader; + +import com.metaobjects.MetaData; +import com.metaobjects.identity.MetaIdentity; +import com.metaobjects.object.CollectionNames; +import com.metaobjects.object.MetaObject; + +import java.util.ArrayList; +import java.util.List; + +/** + * A {@code @via} hop may walk an {@code identity.reference} BACKWARDS, from the entity it + * targets to the entity that declares it, with no relationship declared for it. The hop is + * named by the declaring entity's collection name, lowerFirst ({@code Patient.consents} for + * {@code Consent.patient}). Mirrors TS {@code findReverseReferenceHops} / C# + * {@code ReverseReferenceHops}; the loader corpus fixtures + * {@code origin-reverse-reference-hop} and {@code error-origin-reverse-hop-ambiguous} pin the rule. + */ +public final class ReverseReferenceHops { + + private ReverseReferenceHops() {} + + /** A reverse hop: the object that declares the reference, and the reference walked backwards. */ + public record Hop(MetaObject holder, MetaIdentity reference) { } + + /** The name a reverse hop onto {@code holder}'s references takes: its collection name, lowerFirst. */ + public static String hopName(MetaObject holder) { + String plural = CollectionNames.of(holder); + return plural == null || plural.isEmpty() ? plural + : Character.toLowerCase(plural.charAt(0)) + plural.substring(1); + } + + /** + * Every {@code identity.reference} that targets {@code target} from a concrete object whose + * {@link #hopName} is {@code hopName}. One is the hop; none means the hop is not a reverse; + * more than one is ambiguous. A declared relationship or reference of the same name on the + * target always wins; callers look those up first. + */ + public static List find(MetaObject target, String hopName, List objects) { + List found = new ArrayList<>(); + for (MetaObject holder : objects) { + if (isAbstract(holder) || !hopName.equals(hopName(holder))) continue; + // ADR-0039: resolving — a reference may be inherited through extends. + for (MetaData c : holder.getChildren(MetaData.class, true)) { + if (!(c instanceof MetaIdentity) || !MetaIdentity.SUBTYPE_REFERENCE.equals(c.getSubType())) continue; + // ADR-0059 R7: compare the bound target, never a short name. + if (References.refTarget(c, MetaIdentity.ATTR_REFERENCES) == target) { + found.add(new Hop(holder, (MetaIdentity) c)); + } + } + } + return found; + } + + private static boolean isAbstract(MetaData node) { + // ADR-0039: own — abstractness is a declaration-layer marker. + if (!node.hasMetaAttr(MetaData.ATTR_IS_ABSTRACT, false)) return false; + Object v = node.getMetaAttr(MetaData.ATTR_IS_ABSTRACT, false).getValue(); + return v instanceof Boolean ? (Boolean) v : v instanceof String && "true".equalsIgnoreCase((String) v); + } +} diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java index f49490822b..40d7f13e0f 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java @@ -2906,9 +2906,9 @@ private static void validateOriginNode(MetaRoot root, MetaObject obj, checkPassthroughType(field, fromTarget.field, from, origin.isConvert(), obj, origin.getSource()); String via = origin.getVia(); if (via != null && !via.isEmpty()) { - java.util.List hops = - validateViaPath(via, root, obj, field.getName(), origin.getSource()).hops(); - checkPassthroughCardinality(hops, obj, field.getName(), origin.getSource()); + WalkedViaPath walked = + validateViaPath(via, root, obj, field.getName(), origin.getSource()); + checkPassthroughCardinality(walked.hops(), obj, field.getName(), origin.getSource(), walked.reverse()); } else if (!isValueHost) { // FR-024 §6 — no @via: derive the base entity; a @from targeting the // base relation itself is a plain base column; else infer single-hop. @@ -3022,9 +3022,9 @@ private static void validateOriginNode(MetaRoot root, MetaObject obj, + ": requires an explicit @via (a quantifier has no @of to infer the path from).", ErrorCode.ERR_INVALID_ORIGIN, src); } - java.util.List hops = - validateViaPath(via, root, obj, field.getName(), src).hops(); - checkAggregateCardinality(hops, obj, field.getName(), src); + WalkedViaPath walked = + validateViaPath(via, root, obj, field.getName(), src); + checkAggregateCardinality(walked.hops(), obj, field.getName(), src, walked.reverse()); return; } @@ -3102,7 +3102,7 @@ private static void validateOriginNode(MetaRoot root, MetaObject obj, // validateViaPath with its own error, so nothing downstream runs and no // second, misleadingly-scoped error is produced. WalkedViaPath woWalked = validateViaPath(woVia, root, obj, field.getName(), src); - checkAggregateCardinality(woWalked.hops(), obj, field.getName(), src); + checkAggregateCardinality(woWalked.hops(), obj, field.getName(), src, woWalked.reverse()); // @orderBy keys resolve against the @via TERMINAL entity, not @of. validateOrderByKeys(originOrderBy(origin), woWalked.terminal(), obj, field.getName(), "origin.aggregate @agg:collect", src, @@ -3133,9 +3133,9 @@ private static void validateOriginNode(MetaRoot root, MetaObject obj, String via = origin.getVia(); if (via != null && !via.isEmpty()) { - java.util.List hops = - validateViaPath(via, root, obj, field.getName(), src).hops(); - checkAggregateCardinality(hops, obj, field.getName(), src); + WalkedViaPath walked = + validateViaPath(via, root, obj, field.getName(), src); + checkAggregateCardinality(walked.hops(), obj, field.getName(), src, walked.reverse()); return; } // FR-024 §6 — no @via on an aggregate: inference applies only when @of @@ -3242,9 +3242,9 @@ private static void validateOriginNode(MetaRoot root, MetaObject obj, // @via — explicit (validated + cardinality) or single-hop-unique inferred. String via = origin.getVia(); if (via != null && !via.isEmpty()) { - java.util.List hops = - validateViaPath(via, root, obj, field.getName(), src).hops(); - checkAggregateCardinality(hops, obj, field.getName(), src); + WalkedViaPath walked = + validateViaPath(via, root, obj, field.getName(), src); + checkAggregateCardinality(walked.hops(), obj, field.getName(), src, walked.reverse()); } else { // (A value host never reaches here — the #210 assembly-origin // check above already rejected origin.first on a value.) @@ -4454,7 +4454,8 @@ private static OriginTarget validateFromOrOfPath(String pathAttr, MetaRoot root, * because a second walk means a second copy of the ADR-0042 package-resolution rule — * mirrors the TS {@code WalkedViaPath}. */ - private record WalkedViaPath(java.util.List hops, MetaObject terminal) { } + private record WalkedViaPath(java.util.List hops, MetaObject terminal, + java.util.Set reverse) { } private static WalkedViaPath validateViaPath(String viaAttr, MetaRoot root, MetaObject projection, String fieldName, @@ -4494,6 +4495,7 @@ private static WalkedViaPath validateViaPath(String viaAttr, MetaRoot root, // each successful relationship hop appends a segment. java.util.List validSegments = new java.util.ArrayList<>(); validSegments.add(entityName); + java.util.Set reverse = java.util.Collections.newSetFromMap(new java.util.IdentityHashMap<>()); for (int i = 1; i < segments.length; i++) { String relName = segments[i]; // FR-024: a hop may name a relationship OR a reference-only FK @@ -4501,6 +4503,34 @@ private static WalkedViaPath validateViaPath(String viaAttr, MetaRoot root, MetaRelationship rel = findRelationship(currentObj, relName); MetaData hop = (rel != null) ? rel : findReference(currentObj, relName); if (hop == null) { + // A hop may walk an identity.reference BACKWARDS, from the entity it targets to + // the entity that declares it, named by that entity's collection name + // (`Patient.consents` for Consent.patient). A declared relationship or reference + // of the same name wins (above). + java.util.List reversed = + ReverseReferenceHops.find(currentObj, relName, rootObjects(root)); + if (reversed.size() == 1) { + validSegments.add(relName); + hops.add(reversed.get(0).reference()); + reverse.add(reversed.get(0).reference()); + currentObj = reversed.get(0).holder(); + continue; + } + if (reversed.size() > 1) { + StringBuilder names = new StringBuilder(); + for (ReverseReferenceHops.Hop r : reversed) { + if (names.length() > 0) names.append(", "); + names.append('"').append(r.reference().getShortName()).append('"'); + } + throw new MetaDataException( + "ERR_AMBIGUOUS_PATH: origin.@via \"" + viaAttr + "\" on " + + projectionName + "." + fieldName + ": the reverse hop \"" + relName + "\" from " + + currentObj.getName() + " matches " + reversed.size() + + " identity.reference nodes onto it (" + names + ") — ambiguous. Declare a relationship on " + + currentObj.getName() + " for this hop, or keep one reference.", + ErrorCode.ERR_AMBIGUOUS_PATH, + ResolvedSource.from(envelope, referrer, viaAttr)); + } String prefix = String.join(".", validSegments); throw new MetaDataException( ErrorMessageConstants.ERR_INVALID_ORIGIN @@ -4551,7 +4581,7 @@ private static WalkedViaPath validateViaPath(String viaAttr, MetaRoot root, currentObj = nextObj; } // currentObj is the terminal: every earlier exit threw. - return new WalkedViaPath(hops, currentObj); + return new WalkedViaPath(hops, currentObj, reverse); } // ========================================================================= @@ -4577,6 +4607,13 @@ private static WalkedViaPath validateViaPath(String viaAttr, MetaRoot root, * * @param referrerPkg the effective package of the node carrying the ref ("" for root-level) */ + /** Every root-level object, in declaration order. */ + private static java.util.List rootObjects(MetaRoot root) { + java.util.List out = new java.util.ArrayList<>(); + for (MetaData c : root.getChildren(MetaData.class, false)) if (c instanceof MetaObject) out.add((MetaObject) c); + return out; + } + public static MetaObject resolveRootObject(MetaRoot root, String ref, String referrerPkg) { if (ref == null) return null; // ADR-0059 R2 — the one resolver: ::Rest, ..::Rest, pkg::Name, bare Name. An over-dropping @@ -4733,6 +4770,13 @@ private static String shortNameOf(MetaData child) { * read the RAW own attr rather than {@link MetaRelationship#getCardinality()} * (which defaults to "one"). */ private static String hopCardinality(MetaData rel) { + return hopCardinality(rel, null); + } + + /** As {@link #hopCardinality(MetaData)}; a reference in {@code reverse} was walked backwards, + * so it reaches every row that references the walked-from one: to-many. */ + private static String hopCardinality(MetaData rel, java.util.Set reverse) { + if (reverse != null && reverse.contains(rel)) return MetaRelationship.CARDINALITY_MANY; if (isReferenceHop(rel)) return MetaRelationship.CARDINALITY_ONE; return rel.hasMetaAttr(MetaRelationship.ATTR_CARDINALITY) ? rel.getMetaAttr(MetaRelationship.ATTR_CARDINALITY).getValueAsString() @@ -4869,8 +4913,14 @@ private static java.util.List inferViaSingleHop( /** ADR-0029 dec 6 — a passthrough via-path must be to-one at every hop. */ private static void checkPassthroughCardinality(java.util.List hops, MetaObject obj, String fieldName, com.metaobjects.source.ErrorSource originSource) { + checkPassthroughCardinality(hops, obj, fieldName, originSource, null); + } + + private static void checkPassthroughCardinality(java.util.List hops, + MetaObject obj, String fieldName, com.metaobjects.source.ErrorSource originSource, + java.util.Set reverse) { for (MetaData rel : hops) { - if (MetaRelationship.CARDINALITY_MANY.equals(hopCardinality(rel))) { + if (MetaRelationship.CARDINALITY_MANY.equals(hopCardinality(rel, reverse))) { throw new MetaDataException( "ERR_ORIGIN_CARDINALITY" + ": origin.passthrough on " + obj.getName() + "." + fieldName @@ -4883,10 +4933,16 @@ private static void checkPassthroughCardinality(java.util.List hops, /** ADR-0029 dec 6 — an aggregate via-path must have ≥1 to-many hop (conservative). */ private static void checkAggregateCardinality(java.util.List hops, MetaObject obj, String fieldName, com.metaobjects.source.ErrorSource originSource) { + checkAggregateCardinality(hops, obj, fieldName, originSource, null); + } + + private static void checkAggregateCardinality(java.util.List hops, + MetaObject obj, String fieldName, com.metaobjects.source.ErrorSource originSource, + java.util.Set reverse) { if (hops.isEmpty()) return; boolean provablyToOne = true; for (MetaData rel : hops) { - if (!MetaRelationship.CARDINALITY_ONE.equals(hopCardinality(rel))) { provablyToOne = false; break; } + if (!MetaRelationship.CARDINALITY_ONE.equals(hopCardinality(rel, reverse))) { provablyToOne = false; break; } } if (provablyToOne) { throw new MetaDataException( diff --git a/server/java/metadata/src/main/java/com/metaobjects/object/CollectionNames.java b/server/java/metadata/src/main/java/com/metaobjects/object/CollectionNames.java new file mode 100644 index 0000000000..5a0a262db7 --- /dev/null +++ b/server/java/metadata/src/main/java/com/metaobjects/object/CollectionNames.java @@ -0,0 +1,34 @@ +package com.metaobjects.object; + +import com.metaobjects.database.NamingStrategies; +import com.metaobjects.database.PhysicalNaming; + +/** + * An object's collection name: its own {@code @collectionName}, else the project's pluralizer + * ({@code naming.collections}) over its name. Each artifact applies its own casing (a reverse + * {@code @via} hop: lowerFirst). Mirrors TS {@code collectionNameOf} / C# {@code CollectionNames}. + */ +public final class CollectionNames { + + private CollectionNames() {} + + /** + * The collection name of {@code obj}, in its own casing. Own, not resolving: a name inherited + * from an abstract base would hand every subtype one collection. + */ + public static String of(MetaObject obj) { + String declared = declared(obj); + if (declared != null) return declared; + String configured = PhysicalNaming.of(obj).collections(); + return NamingStrategies.pluralizer(configured != null ? configured : NamingStrategies.DEFAULT_PLURALIZER) + .apply(obj.getShortName()); + } + + /** The collection name {@code obj} declares ({@code @collectionName}), or {@code null}. */ + public static String declared(MetaObject obj) { + // ADR-0039: own — a collection name is the declaring object's alone (see of). + if (!obj.hasMetaAttr(MetaObject.ATTR_COLLECTION_NAME, false)) return null; + Object v = obj.getMetaAttr(MetaObject.ATTR_COLLECTION_NAME, false).getValue(); + return v instanceof String && !((String) v).isEmpty() ? (String) v : null; + } +} diff --git a/server/python/src/metaobjects/loader/validation_passes.py b/server/python/src/metaobjects/loader/validation_passes.py index 31ffdbb9dd..e3e4a24755 100644 --- a/server/python/src/metaobjects/loader/validation_passes.py +++ b/server/python/src/metaobjects/loader/validation_passes.py @@ -65,6 +65,7 @@ FIELD_ATTR_DB_COLUMN_TYPE, VALID_DB_COLUMN_TYPES, ) +from ..meta.core.object.collection_names import collection_name_of from ..meta.core.object.meta_object import MetaObject from ..meta.core.relationship.meta_relationship import MetaRelationship from ..meta.meta_data import MetaData @@ -1527,6 +1528,37 @@ def _find_reference(obj: MetaObject, name: str) -> MetaData | None: return None +def _reverse_hop_name(holder: MetaData) -> str: + """The name a ``@via`` hop takes when it walks an identity.reference BACKWARDS, from the + referenced entity to the one that declares it: lowerFirst of the holder's collection name + (``consents`` for ``Consent``, so ``Patient.consents`` walks Consent.patient backwards).""" + plural = collection_name_of(holder) + return plural[:1].lower() + plural[1:] + + +def _find_reverse_reference_hops( + target: MetaData, hop_name: str, root: MetaData +) -> list[tuple[MetaData, MetaData]]: + """Every (holder, identity.reference) where a concrete root object whose reverse hop name is + *hop_name* declares a reference onto *target*. One is the hop; none means the hop is not a + reverse; more than one is ambiguous. A declared relationship or reference of the same name on + *target* always wins; callers look those up first. Mirrors TS ``findReverseReferenceHops``.""" + found: list[tuple[MetaData, MetaData]] = [] + for holder in root.children(): + if holder.type != TYPE_OBJECT or holder.is_abstract or _reverse_hop_name(holder) != hop_name: + continue + # ADR-0039: resolving — an inherited reference counts. + for ref in holder.children(): + if ( + ref.type == TYPE_IDENTITY + and ref.sub_type == IDENTITY_SUBTYPE_REFERENCE + # Compare the bound target, never a short name. + and bound_ref_target(ref, IDENTITY_REFERENCE_ATTR_REFERENCES) is target + ): + found.append((holder, ref)) + return found + + def _is_reference_hop(hop: MetaData) -> bool: """True for an ``identity.reference`` node (a ``@via`` reference hop).""" return hop.type == TYPE_IDENTITY and hop.sub_type == IDENTITY_SUBTYPE_REFERENCE @@ -1612,6 +1644,8 @@ class WalkedViaPath(NamedTuple): hops: list[MetaData] terminal: MetaData + #: ids of the hops that walk an identity.reference backwards (to-many by construction). + reverse: frozenset[int] = frozenset() def _validate_via_path( @@ -1671,12 +1705,37 @@ def _validate_via_path( # resolve so authors can fix multi-hop typos quickly. valid_segments: list[str] = [current_name] hops: list[MetaData] = [] + reverse: set[int] = set() for rel_name in segments[1:]: rels = _relationships_by_name(current_entity) # FR-024: a hop may name a relationship OR a reference-only FK # (identity.reference) — the reference IS a navigable many-to-one edge. rel_node = rels.get(rel_name) or _find_reference(current_entity, rel_name) if rel_node is None: + # A hop may walk an identity.reference BACKWARDS, from the entity it targets to the + # entity that declares it, named by that entity's collection name + # (``Patient.consents`` for Consent.patient). A declared relationship or reference of + # the same name wins (above). + reversed_hops = _find_reverse_reference_hops(current_entity, rel_name, root) + if len(reversed_hops) == 1: + holder, ref = reversed_hops[0] + valid_segments.append(rel_name) + hops.append(ref) + reverse.add(id(ref)) + current_entity = holder + continue + if len(reversed_hops) > 1: + names = ", ".join(f'"{r.name}"' for _, r in reversed_hops) + errors.append( + MetaError( + f'{context} @via=\'{via}\' — the reverse hop "{rel_name}" from {current_entity.name} ' + f"matches {len(reversed_hops)} identity.reference nodes onto it ({names}) — ambiguous. " + f"Declare a relationship on {current_entity.name} for this hop, or keep one reference.", + ErrorCode.ERR_AMBIGUOUS_PATH, + envelope=resolved_source(origin_node.source, referrer, via), + ) + ) + return None prefix = ".".join(valid_segments) errors.append( MetaError( @@ -1731,7 +1790,7 @@ def _validate_via_path( current_entity = next_entity # current_entity is the terminal: every earlier exit returned None. - return WalkedViaPath(hops, current_entity) + return WalkedViaPath(hops, current_entity, frozenset(reverse)) # --------------------------------------------------------------------------- @@ -1741,9 +1800,12 @@ def _validate_via_path( # --------------------------------------------------------------------------- -def _hop_cardinality(rel: MetaData) -> str | None: +def _hop_cardinality(rel: MetaData, reverse: frozenset[int] = frozenset()) -> str | None: """A hop's effective @cardinality, or None when not declared. A reference hop - (a forward FK) is inherently to-one — a child names the parent it points at.""" + (a forward FK) is inherently to-one — a child names the parent it points at; walked + backwards (in *reverse*) it is to-many.""" + if id(rel) in reverse: + return CARDINALITY_MANY if _is_reference_hop(rel): return CARDINALITY_ONE # ADR-0039: resolving (@cardinality may be inherited via extends) — mirrors the @@ -1918,12 +1980,16 @@ def _infer_via_single_hop( def _check_passthrough_cardinality( - hops: list[MetaData], field_name: str, origin_source: object, errors: list[MetaError] + hops: list[MetaData], + field_name: str, + origin_source: object, + errors: list[MetaError], + reverse: frozenset[int] = frozenset(), ) -> None: """ADR-0029 decision 6 — a passthrough via-path must be effectively to-one at EVERY hop. A hop is to-many only when it DECLARES @cardinality "many".""" for rel in hops: - if _hop_cardinality(rel) == CARDINALITY_MANY: + if _hop_cardinality(rel, reverse) == CARDINALITY_MANY: errors.append( MetaError( f"origin.passthrough on {field_name}: @via hop '{rel.name}' is to-many " @@ -1937,14 +2003,18 @@ def _check_passthrough_cardinality( def _check_aggregate_cardinality( - hops: list[MetaData], field_name: str, origin_source: object, errors: list[MetaError] + hops: list[MetaData], + field_name: str, + origin_source: object, + errors: list[MetaError], + reverse: frozenset[int] = frozenset(), ) -> None: """ADR-0029 decision 6 — an aggregate via-path must contain at least one to-many hop. Conservative: fires only when PROVABLY to-one (every hop declares @cardinality "one").""" if not hops: return - if all(_hop_cardinality(rel) == CARDINALITY_ONE for rel in hops): + if all(_hop_cardinality(rel, reverse) == CARDINALITY_ONE for rel in hops): errors.append( MetaError( f"origin.aggregate on {field_name}: every @via hop is to-one " @@ -2236,9 +2306,10 @@ def _validate_origin_paths( via = origin.attr(ORIGIN_ATTR_VIA) if isinstance(via, str) and via: walked = _validate_via_path(via, ctx, root, host_pkg, errors, origin, referrer) - hops = walked.hops if walked is not None else None - if hops is not None: - _check_passthrough_cardinality(hops, node.name, origin.source, errors) + if walked is not None: + _check_passthrough_cardinality( + walked.hops, node.name, origin.source, errors, walked.reverse + ) elif from_target is not None and not is_value_host: # FR-024 §6 — no @via: derive the base entity; a @from on the # base relation itself is a plain base column (no checks); @@ -2333,9 +2404,8 @@ def _validate_origin_paths( ErrorCode.ERR_INVALID_ORIGIN, envelope=src)) else: walked = _validate_via_path(via, ctx, root, host_pkg, errors, origin, referrer) - hops = walked.hops if walked is not None else None - if hops is not None: - _check_aggregate_cardinality(hops, node.name, src, errors) + if walked is not None: + _check_aggregate_cardinality(walked.hops, node.name, src, errors, walked.reverse) continue # --- @of: REQUIRED for count/sum/avg/min/max; OPTIONAL for collect --- @@ -2411,7 +2481,7 @@ def _validate_origin_paths( walked = _validate_via_path( via_attr, ctx, root, host_pkg, errors, origin, referrer) if walked is not None: - _check_aggregate_cardinality(walked.hops, node.name, src, errors) + _check_aggregate_cardinality(walked.hops, node.name, src, errors, walked.reverse) # @orderBy keys resolve against the @via TERMINAL entity, not @of. _validate_order_by_keys( order_by, walked.terminal, obj, node.name, @@ -2442,9 +2512,8 @@ def _validate_origin_paths( via = origin.attr(ORIGIN_ATTR_VIA) if isinstance(via, str) and via: walked = _validate_via_path(via, ctx, root, host_pkg, errors, origin, referrer) - hops = walked.hops if walked is not None else None - if hops is not None: - _check_aggregate_cardinality(hops, node.name, src, errors) + if walked is not None: + _check_aggregate_cardinality(walked.hops, node.name, src, errors, walked.reverse) continue # FR-024 §6 — no @via on an aggregate: inference applies only # when @of targets a non-base entity. (A value host never @@ -2554,9 +2623,8 @@ def _resolve_field(name: str, _base: MetaData = base) -> str | None: via = origin.attr(ORIGIN_ATTR_VIA) if isinstance(via, str) and via: walked = _validate_via_path(via, ctx, root, host_pkg, errors, origin, referrer) - hops = walked.hops if walked is not None else None - if hops is not None: - _check_aggregate_cardinality(hops, node.name, src, errors) + if walked is not None: + _check_aggregate_cardinality(walked.hops, node.name, src, errors, walked.reverse) elif of_target is not None: # (A value host never reaches here — the #210 assembly-origin # check above already rejected origin.first on a value.) diff --git a/server/python/src/metaobjects/meta/core/object/collection_names.py b/server/python/src/metaobjects/meta/core/object/collection_names.py new file mode 100644 index 0000000000..55e3a636aa --- /dev/null +++ b/server/python/src/metaobjects/meta/core/object/collection_names.py @@ -0,0 +1,27 @@ +"""An object's collection name, in the object's own casing: its own ``@collectionName``, else the +project's pluralizer (``naming.collections``) over its name. Each artifact then applies its own +casing (a REST segment: snake_case; a reverse ``@via`` hop: lowerFirst). Mirrors the TypeScript +``collectionNameOf``. +""" +from __future__ import annotations + +from metaobjects.meta.core.object.object_constants import OBJECT_ATTR_COLLECTION_NAME +from metaobjects.meta.meta_data import MetaData + + +def declared_collection_name(obj: MetaData) -> str | None: + """The collection name *obj* declares itself; ``None`` when it declares none.""" + # ADR-0039: own — a collection name is the declaring object's alone; one inherited from an + # abstract base would give every subtype one collection. + own = obj.own_attrs().get(OBJECT_ATTR_COLLECTION_NAME) + return own if isinstance(own, str) and own else None + + +def collection_name_of(obj: MetaData) -> str: + """*obj*'s own ``@collectionName``, else the configured pluralizer over its name.""" + from metaobjects.naming import naming_rules_of, pluralizer + + declared = declared_collection_name(obj) + if declared is not None: + return declared + return pluralizer(naming_rules_of(obj).collections)(obj.name) diff --git a/server/typescript/packages/metadata/src/core/relationship/find-reference.ts b/server/typescript/packages/metadata/src/core/relationship/find-reference.ts index f723236acc..cf2a0eb1ac 100644 --- a/server/typescript/packages/metadata/src/core/relationship/find-reference.ts +++ b/server/typescript/packages/metadata/src/core/relationship/find-reference.ts @@ -10,6 +10,7 @@ import type { MetaObject } from "../object/meta-object.js"; import type { MetaReferenceIdentity } from "../identity/meta-identity.js"; import { refTarget } from "../../naming-refs.js"; import { IDENTITY_REFERENCE_ATTR_REFERENCES } from "../identity/identity-constants.js"; +import { collectionNameOf } from "../../naming.js"; export interface ReferenceLookup { /** The entity whose identity.reference points at `other`. */ @@ -67,3 +68,45 @@ export function findReferenceBetween( ): ReferenceLookup | undefined { return findReferencesBetween(a, b)[0]; } + +/** + * The name a `@via` hop takes when it walks the REVERSE of an + * identity.reference, from the referenced entity back to the one that declares it: + * the holder's collection name, lowerFirst() — its @collectionName, else the project's pluralizer — `consents` for + * `Consent`, so `Patient.consents` walks Consent.patient backwards. One rule for every port. + */ +export function reverseHopName(holder: MetaObject): string { + // the holder's collection name — its @collectionName, else the project's pluralizer. + const plural = collectionNameOf(holder); + return plural.charAt(0).toLowerCase() + plural.slice(1); +} + +/** A reverse `@via` hop: the reference walked backwards and the entity that declares it. */ +export interface ReverseReferenceHop { + /** The concrete object whose identity.reference targets the walked-from entity. */ + readonly holder: MetaObject; + readonly referenceIdentity: MetaReferenceIdentity; +} + +/** + * Every identity.reference that targets `target` from a concrete object whose + * {@link reverseHopName} is `hopName` — the candidates a reverse hop `Target.hopName` names. + * One is the hop; none means the hop is not a reverse; more than one is ambiguous, and the + * caller refuses it naming them. A declared relationship or reference of the same name on + * `target` always wins; callers look those up first. + */ +export function findReverseReferenceHops( + target: MetaObject, + hopName: string, + objects: readonly MetaObject[], +): ReverseReferenceHop[] { + const found: ReverseReferenceHop[] = []; + for (const holder of objects) { + if (holder.isAbstract || reverseHopName(holder) !== hopName) continue; + for (const ref of holder.referenceIdentities()) { + // ADR-0059 R7: compare the bound target, never a short name. + if (refTarget(ref, IDENTITY_REFERENCE_ATTR_REFERENCES) === target) found.push({ holder, referenceIdentity: ref }); + } + } + return found; +} diff --git a/server/typescript/packages/metadata/src/index.ts b/server/typescript/packages/metadata/src/index.ts index 60461b178b..b2880f7827 100644 --- a/server/typescript/packages/metadata/src/index.ts +++ b/server/typescript/packages/metadata/src/index.ts @@ -162,7 +162,10 @@ export type { IdentityPassthroughResolution } from "./core/identity/validate-ide // Relationship export { MetaRelationship } from "./core/relationship/meta-relationship.js"; // Cross-entity reference lookup -export { findReferenceBetween, findReferencesBetween } from "./core/relationship/find-reference.js"; +export { + findReferenceBetween, findReferencesBetween, findReverseReferenceHops, reverseHopName, + type ReverseReferenceHop, +} from "./core/relationship/find-reference.js"; export type { ReferenceLookup } from "./core/relationship/find-reference.js"; export { referenceCandidatesFor, diff --git a/server/typescript/packages/metadata/src/loader/validation-passes.ts b/server/typescript/packages/metadata/src/loader/validation-passes.ts index b11b5dbf30..957eaa11f7 100644 --- a/server/typescript/packages/metadata/src/loader/validation-passes.ts +++ b/server/typescript/packages/metadata/src/loader/validation-passes.ts @@ -68,7 +68,7 @@ import { } from "../core/object/object-constants.js"; import { MetaSource } from "../persistence/source/meta-source.js"; import { resolveColumnName, resolveFlattenedColumnName } from "../naming.js"; -import { isMetaField, isMetaObject } from "../shared/node-guards.js"; +import { isMetaField, isMetaObject, isMetaRoot } from "../shared/node-guards.js"; import { LAYOUT_SUBTYPE_DATA_GRID, LAYOUT_DATA_GRID_ATTR_DEFAULT_SORT_FIELD, @@ -170,6 +170,7 @@ import { REQUIREMENT_ATTR_SUPERSEDED_BY, REQUIREMENT_STATUS_RETIRED, } from "../core/requirement/requirement-constants.js"; +import { findReverseReferenceHops } from "../core/relationship/find-reference.js"; // --------------------------------------------------------------------------- // Layout dataGrid @defaultSortField validation @@ -693,6 +694,9 @@ function _validateFromPath( interface WalkedViaPath { hops: MetaData[]; terminal: MetaData; + /** The hops walked BACKWARDS: an identity.reference of the next entity, walked from + * the entity it targets, so the hop is to-many. */ + reverse: ReadonlySet; } /** @@ -751,11 +755,38 @@ function _validateViaPath( // entity name; each successful relationship hop appends a segment. const validSegments: string[] = [entityName]; const hops: MetaData[] = []; + const reverse = new Set(); for (const relName of relSegments) { // FR-024: a hop may name a relationship OR a reference-only FK // (identity.reference) — the reference IS a navigable many-to-one edge. const rel = _findRelationship(currentObj, relName) ?? _findReference(currentObj, relName); if (!rel) { + // A hop may walk an identity.reference BACKWARDS, from the entity it targets to the + // entity that declares it, named by that entity's collection name (`Patient.consents` for + // Consent.patient). A declared relationship or reference of the same name wins (above). + const reversed = findReverseReferenceHops(currentObj as MetaObject, relName, isMetaRoot(root) ? root.objects() : []); + if (reversed.length === 1) { + validSegments.push(relName); + hops.push(reversed[0]!.referenceIdentity); + reverse.add(reversed[0]!.referenceIdentity); + currentObj = reversed[0]!.holder; + continue; + } + if (reversed.length > 1) { + const names = reversed.map((r) => `"${r.referenceIdentity.name}"`).join(", "); + errors.push( + new ParseError( + `origin.@via "${viaAttr}" on ${projectionName}.${fieldName}: the reverse hop "${relName}" from ${currentObj.name} ` + + `matches ${reversed.length} identity.reference nodes onto it (${names}) — ambiguous. Declare a relationship on ` + + `${currentObj.name} for this hop, or keep one reference.`, + { + code: "ERR_AMBIGUOUS_PATH", + source: resolvedSource(originSource, referrer, viaAttr), + }, + ), + ); + return undefined; + } const prefix = validSegments.join("."); errors.push( new ParseError( @@ -814,7 +845,7 @@ function _validateViaPath( currentObj = nextObj; } // currentObj is the terminal: every earlier exit returned undefined. - return { hops, terminal: currentObj }; + return { hops, terminal: currentObj, reverse }; } // --------------------------------------------------------------------------- @@ -824,7 +855,9 @@ function _validateViaPath( /** A hop's effective @cardinality, or undefined when not declared. A reference * hop (a forward FK) is inherently to-one — a child names the parent it points at. */ -function _hopCardinality(rel: MetaData): string | undefined { +function _hopCardinality(rel: MetaData, reverse?: ReadonlySet): string | undefined { + // A reference walked backwards reaches every row that references the walked-from one. + if (reverse?.has(rel) === true) return CARDINALITY_MANY; if (_isReferenceHop(rel)) return CARDINALITY_ONE; const v = rel.attr(RELATIONSHIP_ATTR_CARDINALITY); return typeof v === "string" ? v : undefined; @@ -1023,9 +1056,10 @@ function _checkPassthroughCardinality( fieldName: string, originSource: ErrorSource, errors: ParseError[], + reverse?: ReadonlySet, ): void { for (const rel of hops) { - if (_hopCardinality(rel) === CARDINALITY_MANY) { + if (_hopCardinality(rel, reverse) === CARDINALITY_MANY) { errors.push( new ParseError( `origin.passthrough on ${obj.name}.${fieldName}: @via hop "${rel.name}" is to-many ` + @@ -1050,9 +1084,10 @@ function _checkAggregateCardinality( fieldName: string, originSource: ErrorSource, errors: ParseError[], + reverse?: ReadonlySet, ): void { if (hops.length === 0) return; - const provablyToOne = hops.every((rel) => _hopCardinality(rel) === CARDINALITY_ONE); + const provablyToOne = hops.every((rel) => _hopCardinality(rel, reverse) === CARDINALITY_ONE); if (provablyToOne) { errors.push( new ParseError( @@ -1331,7 +1366,7 @@ export function validateOriginPaths(root: MetaData): ParseError[] { if (typeof via === "string" && via !== "") { const walked = _validateViaPath(via, root, obj, field.name, origin.source, errors); if (walked !== undefined) { - _checkPassthroughCardinality(walked.hops, obj, field.name, origin.source, errors); + _checkPassthroughCardinality(walked.hops, obj, field.name, origin.source, errors, walked.reverse); } } else if (fromTarget !== undefined && !isValueHost) { // FR-024 §6 — no @via: derive the base entity; a @from targeting @@ -1417,7 +1452,7 @@ export function validateOriginPaths(root: MetaData): ParseError[] { { code: "ERR_INVALID_ORIGIN", source: src })); } else { const walked = _validateViaPath(via, root, obj, field.name, src, errors); - if (walked !== undefined) _checkAggregateCardinality(walked.hops, obj, field.name, src, errors); + if (walked !== undefined) _checkAggregateCardinality(walked.hops, obj, field.name, src, errors, walked.reverse); } continue; } @@ -1491,7 +1526,7 @@ export function validateOriginPaths(root: MetaData): ParseError[] { // no second, misleadingly-scoped error is emitted. const via = _validateViaPath(viaAttr, root, obj, field.name, src, errors); if (via !== undefined) { - _checkAggregateCardinality(via.hops, obj, field.name, src, errors); + _checkAggregateCardinality(via.hops, obj, field.name, src, errors, via.reverse); // @orderBy keys resolve against the @via TERMINAL entity, not @of. _validateOrderByKeys(orderBy, via.terminal, obj, field.name, "origin.aggregate @agg:collect", src, errors, "ERR_COLLECT_WHOLE_OBJECT"); @@ -1521,7 +1556,7 @@ export function validateOriginPaths(root: MetaData): ParseError[] { if (typeof via === "string" && via !== "") { const walked = _validateViaPath(via, root, obj, field.name, src, errors); if (walked !== undefined) { - _checkAggregateCardinality(walked.hops, obj, field.name, src, errors); + _checkAggregateCardinality(walked.hops, obj, field.name, src, errors, walked.reverse); } continue; } @@ -1615,7 +1650,7 @@ export function validateOriginPaths(root: MetaData): ParseError[] { const via = origin.ownAttr(ORIGIN_FIRST_ATTR_VIA); if (typeof via === "string" && via !== "") { const walked = _validateViaPath(via, root, obj, field.name, src, errors); - if (walked !== undefined) _checkAggregateCardinality(walked.hops, obj, field.name, src, errors); + if (walked !== undefined) _checkAggregateCardinality(walked.hops, obj, field.name, src, errors, walked.reverse); } else if (ofTarget !== undefined) { // (A value host never reaches here — the #210 assembly-origin // check above already rejected origin.first on a value.) From 849f9550af0221fcad7fa7a1d28d7d12edddad03 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 12 Nov 2026 02:59:03 -0500 Subject: [PATCH 2/3] no-mistakes(review): Fix reverse-hop message, doc and Java isAbstract dup --- docs/features/relationships.md | 10 ++++++---- server/csharp/MetaObjects/Loader/ValidationPasses.cs | 5 ++++- .../com/metaobjects/loader/ReverseReferenceHops.java | 9 +-------- .../java/com/metaobjects/loader/ValidationPhase.java | 7 +++++-- .../python/src/metaobjects/loader/validation_passes.py | 8 ++++++-- .../packages/metadata/src/loader/validation-passes.ts | 5 ++++- 6 files changed, 26 insertions(+), 18 deletions(-) diff --git a/docs/features/relationships.md b/docs/features/relationships.md index 3e493d7df1..aceaeafac9 100644 --- a/docs/features/relationships.md +++ b/docs/features/relationships.md @@ -173,10 +173,12 @@ reverse-engineered model can `@via` its reference directly: ``` A reference hop is inherently **to-one** (a child names the parent it points at), so it -is valid in a `passthrough` and rejected in an `aggregate`. Inverse navigation -(parent → many children) still needs a `relationship.composition` on the parent — a bare -FK has no inverse edge. Explicit `@via` resolves either kind; single-hop-unique -inference stays relationship-only. +is valid in a `passthrough` and rejected in an `aggregate`. An origin `@via` hop may +also walk an `identity.reference` **backwards** (parent → many children), named by the +holder's collection name in lowerCamel (`Patient.consents`). A reverse hop is to-many, so +it is valid in an `aggregate` and rejected in a `passthrough` (`ERR_ORIGIN_CARDINALITY`); +two candidate holders give `ERR_AMBIGUOUS_PATH`, and a declared relationship or reference +of the same name wins. Single-hop-unique inference stays relationship-only. ## When one entity has two references to the same target diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs index 782b6d850b..04612bb0c5 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs @@ -1368,7 +1368,10 @@ private static void CheckPassthroughCardinality( { errors.Add(new MetaError( $"origin.passthrough on {obj.Name}.{fieldName}: @via hop \"{rel.Name}\" is to-many " + - $"(@cardinality \"{CARDINALITY_MANY}\") — a row-multiplying passthrough — you meant aggregate (ADR-0029).", + (reverse?.Contains(rel) == true + ? $"(reverse hop over identity.reference \"{rel.Name}\")" + : $"(@cardinality \"{CARDINALITY_MANY}\")") + + " — a row-multiplying passthrough — you meant aggregate (ADR-0029).", ErrorCode.ERR_ORIGIN_CARDINALITY, Envelope: originSource)); return; diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ReverseReferenceHops.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ReverseReferenceHops.java index 412a16f312..3b7dea9a00 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ReverseReferenceHops.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ReverseReferenceHops.java @@ -39,7 +39,7 @@ public static String hopName(MetaObject holder) { public static List find(MetaObject target, String hopName, List objects) { List found = new ArrayList<>(); for (MetaObject holder : objects) { - if (isAbstract(holder) || !hopName.equals(hopName(holder))) continue; + if (ValidationPhase.isAbstract(holder) || !hopName.equals(hopName(holder))) continue; // ADR-0039: resolving — a reference may be inherited through extends. for (MetaData c : holder.getChildren(MetaData.class, true)) { if (!(c instanceof MetaIdentity) || !MetaIdentity.SUBTYPE_REFERENCE.equals(c.getSubType())) continue; @@ -51,11 +51,4 @@ public static List find(MetaObject target, String hopName, List } return found; } - - private static boolean isAbstract(MetaData node) { - // ADR-0039: own — abstractness is a declaration-layer marker. - if (!node.hasMetaAttr(MetaData.ATTR_IS_ABSTRACT, false)) return false; - Object v = node.getMetaAttr(MetaData.ATTR_IS_ABSTRACT, false).getValue(); - return v instanceof Boolean ? (Boolean) v : v instanceof String && "true".equalsIgnoreCase((String) v); - } } diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java index 40d7f13e0f..00200fe66d 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java @@ -4040,7 +4040,7 @@ private static boolean subtypeSupportsFiltering(String st) { * boolean-true. Reads only the own attribute (not effective) — matches the * own-only validation contract used throughout this class. */ - private static boolean isAbstract(MetaData node) { + static boolean isAbstract(MetaData node) { if (!node.hasMetaAttr(MetaData.ATTR_IS_ABSTRACT, false)) { return false; } @@ -4924,7 +4924,10 @@ private static void checkPassthroughCardinality(java.util.List hops, throw new MetaDataException( "ERR_ORIGIN_CARDINALITY" + ": origin.passthrough on " + obj.getName() + "." + fieldName - + ": @via hop \"" + rel.getName() + "\" is to-many — you meant aggregate (ADR-0029).", + + ": @via hop \"" + rel.getName() + "\" is to-many" + + (reverse != null && reverse.contains(rel) + ? " (reverse hop over identity.reference \"" + rel.getName() + "\")" : "") + + " — you meant aggregate (ADR-0029).", ErrorCode.ERR_ORIGIN_CARDINALITY, originSource); } } diff --git a/server/python/src/metaobjects/loader/validation_passes.py b/server/python/src/metaobjects/loader/validation_passes.py index e3e4a24755..d3c566cb96 100644 --- a/server/python/src/metaobjects/loader/validation_passes.py +++ b/server/python/src/metaobjects/loader/validation_passes.py @@ -1993,8 +1993,12 @@ def _check_passthrough_cardinality( errors.append( MetaError( f"origin.passthrough on {field_name}: @via hop '{rel.name}' is to-many " - f'(@cardinality "{CARDINALITY_MANY}") — a row-multiplying passthrough — ' - f"you meant aggregate (ADR-0029).", + + ( + f'(reverse hop over identity.reference "{rel.name}")' + if id(rel) in reverse + else f'(@cardinality "{CARDINALITY_MANY}")' + ) + + " — a row-multiplying passthrough — you meant aggregate (ADR-0029).", ErrorCode.ERR_ORIGIN_CARDINALITY, envelope=origin_source, ) diff --git a/server/typescript/packages/metadata/src/loader/validation-passes.ts b/server/typescript/packages/metadata/src/loader/validation-passes.ts index 957eaa11f7..fe976c190f 100644 --- a/server/typescript/packages/metadata/src/loader/validation-passes.ts +++ b/server/typescript/packages/metadata/src/loader/validation-passes.ts @@ -1063,7 +1063,10 @@ function _checkPassthroughCardinality( errors.push( new ParseError( `origin.passthrough on ${obj.name}.${fieldName}: @via hop "${rel.name}" is to-many ` + - `(@cardinality "${CARDINALITY_MANY}") — a row-multiplying passthrough — you meant aggregate (ADR-0029).`, + (reverse?.has(rel) + ? `(reverse hop over identity.reference "${rel.name}")` + : `(@cardinality "${CARDINALITY_MANY}")`) + + ` — a row-multiplying passthrough — you meant aggregate (ADR-0029).`, { code: "ERR_ORIGIN_CARDINALITY", source: originSource }, ), ); From babb69c0abfbc17303f6a96071f4fc4a3f152879 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Thu, 12 Nov 2026 02:59:46 -0500 Subject: [PATCH 3/3] no-mistakes(review): apply reviewer inline fixes --- .../com/metaobjects/loader/ValidationPhase.java | 14 +++++++------- .../src/core/relationship/find-reference.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java index 00200fe66d..b26e8eeed1 100644 --- a/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java +++ b/server/java/metadata/src/main/java/com/metaobjects/loader/ValidationPhase.java @@ -4588,6 +4588,13 @@ private static WalkedViaPath validateViaPath(String viaAttr, MetaRoot root, // ADR-0042 — package-local object-reference resolution. // ========================================================================= + /** Every root-level object, in declaration order. */ + private static java.util.List rootObjects(MetaRoot root) { + java.util.List out = new java.util.ArrayList<>(); + for (MetaData c : root.getChildren(MetaData.class, false)) if (c instanceof MetaObject) out.add((MetaObject) c); + return out; + } + /** * ADR-0042 — resolve a metadata OBJECT reference to a top-level {@link MetaObject} * under the package-local contract, or {@code null} when nothing matches: @@ -4607,13 +4614,6 @@ private static WalkedViaPath validateViaPath(String viaAttr, MetaRoot root, * * @param referrerPkg the effective package of the node carrying the ref ("" for root-level) */ - /** Every root-level object, in declaration order. */ - private static java.util.List rootObjects(MetaRoot root) { - java.util.List out = new java.util.ArrayList<>(); - for (MetaData c : root.getChildren(MetaData.class, false)) if (c instanceof MetaObject) out.add((MetaObject) c); - return out; - } - public static MetaObject resolveRootObject(MetaRoot root, String ref, String referrerPkg) { if (ref == null) return null; // ADR-0059 R2 — the one resolver: ::Rest, ..::Rest, pkg::Name, bare Name. An over-dropping diff --git a/server/typescript/packages/metadata/src/core/relationship/find-reference.ts b/server/typescript/packages/metadata/src/core/relationship/find-reference.ts index cf2a0eb1ac..6c4ad9af13 100644 --- a/server/typescript/packages/metadata/src/core/relationship/find-reference.ts +++ b/server/typescript/packages/metadata/src/core/relationship/find-reference.ts @@ -72,7 +72,7 @@ export function findReferenceBetween( /** * The name a `@via` hop takes when it walks the REVERSE of an * identity.reference, from the referenced entity back to the one that declares it: - * the holder's collection name, lowerFirst() — its @collectionName, else the project's pluralizer — `consents` for + * the holder's collection name (its @collectionName, else the project's pluralizer), lowerFirst — `consents` for * `Consent`, so `Patient.consents` walks Consent.patient backwards. One rule for every port. */ export function reverseHopName(holder: MetaObject): string {