From a52b934c0a18b11223c75e3e3245e3808545e090 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 3 Nov 2026 20:31:47 -0500 Subject: [PATCH 1/4] fix(metadata): exempt @sql views from the duplicate-column check in every port --- CHANGELOG.md | 9 +++++ .../expected.json | 39 +++++++++++++++++++ .../input/meta.demo.json | 16 ++++++++ .../providers.json | 1 + .../expected-errors.json | 15 +++++++ .../input/meta.demo.json | 16 ++++++++ .../providers.json | 1 + .../MetaObjects/Loader/ValidationPasses.cs | 15 ++++++- .../metaobjects/loader/ValidationPhase.java | 25 +++++++++++- .../metaobjects/loader/validation_passes.py | 27 ++++++++++++- .../metadata/src/loader/validation-passes.ts | 24 +++++++++++- .../column-name-collision-message.test.ts | 26 +++++++++++++ 12 files changed, 206 insertions(+), 8 deletions(-) create mode 100644 fixtures/conformance/column-name-shared-in-sql-view-ok/expected.json create mode 100644 fixtures/conformance/column-name-shared-in-sql-view-ok/input/meta.demo.json create mode 100644 fixtures/conformance/column-name-shared-in-sql-view-ok/providers.json create mode 100644 fixtures/conformance/error-column-name-collision-derived-view/expected-errors.json create mode 100644 fixtures/conformance/error-column-name-collision-derived-view/input/meta.demo.json create mode 100644 fixtures/conformance/error-column-name-collision-derived-view/providers.json create mode 100644 server/typescript/packages/metadata/test/column-name-collision-message.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b13247ac..3f54af18d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,15 @@ here.** ### Fixed +- **`ERR_COLUMN_NAME_COLLISION` no longer rejects a read-only view whose columns come from `@sql`.** + The 1.2.0 check treated every field of a sourced object as a column MetaObjects creates. For a + `source.rdb @kind: view` with an `@sql` body, the author's SQL defines the columns and the fields + only read them, so two fields on one column are legal and nothing can be written. That object is + now exempt in all four ports (TypeScript, Java, C#, Python). A derived view (no `@sql`), which + emits a select list from its fields, is still checked. When both colliding fields already carry an + explicit `@column`, the message now says to rename one field or point one `@column` at a different + column, instead of suggesting a `@column` they already have. The error code is unchanged. New + conformance fixtures: `column-name-shared-in-sql-view-ok` and `error-column-name-collision-derived-view`. - **`meta migrate` refuses, at plan time, to drop a column a view it does not own still reads.** With `--from-db`, a declared-`@unmanaged` view or one outside the scoped schemas whose live body names the dropped column now blocks the `drop-column`, and the message names the view and the diff --git a/fixtures/conformance/column-name-shared-in-sql-view-ok/expected.json b/fixtures/conformance/column-name-shared-in-sql-view-ok/expected.json new file mode 100644 index 000000000..388777ebf --- /dev/null +++ b/fixtures/conformance/column-name-shared-in-sql-view-ok/expected.json @@ -0,0 +1,39 @@ +{ + "metadata.root": { + "package": "demo", + "children": [ + { + "object.projection": { + "name": "UserView", + "children": [ + { + "source.rdb": { + "@kind": "view", + "@sql": "SELECT u.id, u.username FROM app_user u", + "@view": "user_view" + } + }, + { + "field.long": { + "name": "id", + "@column": "id" + } + }, + { + "field.string": { + "name": "userName", + "@column": "username" + } + }, + { + "field.string": { + "name": "displayName", + "@column": "username" + } + } + ] + } + } + ] + } +} diff --git a/fixtures/conformance/column-name-shared-in-sql-view-ok/input/meta.demo.json b/fixtures/conformance/column-name-shared-in-sql-view-ok/input/meta.demo.json new file mode 100644 index 000000000..5314b05bf --- /dev/null +++ b/fixtures/conformance/column-name-shared-in-sql-view-ok/input/meta.demo.json @@ -0,0 +1,16 @@ +{ + "metadata.root": { + "package": "demo", + "children": [ + { "object.projection": { + "name": "UserView", + "children": [ + { "source.rdb": { "@kind": "view", "@view": "user_view", "@sql": "SELECT u.id, u.username FROM app_user u" } }, + { "field.long": { "name": "id", "@column": "id" } }, + { "field.string": { "name": "userName", "@column": "username" } }, + { "field.string": { "name": "displayName", "@column": "username" } } + ] + }} + ] + } +} diff --git a/fixtures/conformance/column-name-shared-in-sql-view-ok/providers.json b/fixtures/conformance/column-name-shared-in-sql-view-ok/providers.json new file mode 100644 index 000000000..aefa027b6 --- /dev/null +++ b/fixtures/conformance/column-name-shared-in-sql-view-ok/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types", "metaobjects-db"] diff --git a/fixtures/conformance/error-column-name-collision-derived-view/expected-errors.json b/fixtures/conformance/error-column-name-collision-derived-view/expected-errors.json new file mode 100644 index 000000000..a19f7ad42 --- /dev/null +++ b/fixtures/conformance/error-column-name-collision-derived-view/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_COLUMN_NAME_COLLISION", + "source": { + "format": "json", + "files": [ + "meta.demo.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.projection'].children[3]['field.string']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-column-name-collision-derived-view/input/meta.demo.json b/fixtures/conformance/error-column-name-collision-derived-view/input/meta.demo.json new file mode 100644 index 000000000..50ac7e909 --- /dev/null +++ b/fixtures/conformance/error-column-name-collision-derived-view/input/meta.demo.json @@ -0,0 +1,16 @@ +{ + "metadata.root": { + "package": "demo", + "children": [ + { "object.projection": { + "name": "UserView", + "children": [ + { "source.rdb": { "@kind": "view", "@view": "user_view" } }, + { "field.long": { "name": "id", "@column": "id" } }, + { "field.string": { "name": "userName", "@column": "username" } }, + { "field.string": { "name": "displayName", "@column": "username" } } + ] + }} + ] + } +} diff --git a/fixtures/conformance/error-column-name-collision-derived-view/providers.json b/fixtures/conformance/error-column-name-collision-derived-view/providers.json new file mode 100644 index 000000000..aefa027b6 --- /dev/null +++ b/fixtures/conformance/error-column-name-collision-derived-view/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 13f06857b..8c6a1d74a 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs @@ -4445,6 +4445,14 @@ void Walk(MetaData node) // declaring at least one source (a source-less object generates nothing); derived // fields and field.object (may flatten to several columns) are skipped. // ADR-0039: effective children, so an inherited field collides like an own one. + // Exempt: an object read through source.rdb @kind: view with an @sql body. The author's + // SQL defines that view's columns and the fields only READ them, so two fields on one + // column create no duplicate column and nothing can be written. A derived view (no @sql) + // emits a select list from its fields and stays checked. + private static bool ReadsThroughSqlView(MetaData obj) => + obj.Children().OfType().Any(s => + s.EffectiveKind == SourceConstants.SOURCE_KIND_VIEW && s.SqlBody is not null); + public static IReadOnlyList ValidateColumnNameCollisions(MetaData root) { var errors = new List(); @@ -4452,6 +4460,7 @@ public static IReadOnlyList ValidateColumnNameCollisions(MetaData roo { if (obj.IsAbstract) continue; if (!obj.Children().Any(c => c.Type == TYPE_SOURCE)) continue; + if (ReadsThroughSqlView(obj)) continue; var seen = new Dictionary(StringComparer.Ordinal); foreach (var field in obj.Children().OfType()) { @@ -4464,8 +4473,10 @@ public static IReadOnlyList ValidateColumnNameCollisions(MetaData roo } errors.Add(new MetaError( $"fields \"{first.Name}\" and \"{field.Name}\" on \"{obj.Name}\" both resolve to " + - $"physical column \"{column}\". Rename one field, or give one an explicit @column " + - "so the two columns differ.", + $"physical column \"{column}\". " + + (first.DbColumn is { Length: > 0 } && field.DbColumn is { Length: > 0 } + ? "Rename one field, or point one @column at a different column." + : "Rename one field, or give one an explicit @column so the two columns differ."), ErrorCode.ERR_COLUMN_NAME_COLLISION, Envelope: field.Source)); } 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 1ddc61efb..5ae4db749 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 @@ -5409,11 +5409,30 @@ private static void walkRetiredRequirementLinks(MetaData node) { * generates nothing); derived fields and {@code field.object} (may flatten to several * columns) are skipped. ADR-0039: resolving accessors, so an inherited field collides like * an own one.

+ * + *

Exempt: an object read through {@code source.rdb @kind: view} with an {@code @sql} + * body. The author's SQL defines that view's columns and the fields only READ them, so two + * fields on one column create no duplicate column and nothing can be written. A derived + * view (no {@code @sql}) emits a select list from its fields and stays checked.

*/ + private static boolean readsThroughSqlView(MetaObject obj) { + for (MetaSource s : obj.getChildren(MetaSource.class, true)) { + if (MetaSource.KIND_VIEW.equals(s.getEffectiveKind()) && s.getSqlBody() != null) return true; + } + return false; + } + + private static boolean hasExplicitColumn(MetaField field) { + if (!field.hasMetaAttr(CoreDBMetaDataProvider.COLUMN, true)) return false; + String v = field.getMetaAttr(CoreDBMetaDataProvider.COLUMN, true).getValueAsString(); + return v != null && !v.isEmpty(); + } + static void validateColumnNameCollisions(MetaRoot root) { for (MetaObject obj : root.getChildren(MetaObject.class, false)) { if (isAbstract(obj)) continue; if (obj.getChildren(MetaSource.class, true).isEmpty()) continue; + if (readsThroughSqlView(obj)) continue; java.util.Map> seen = new java.util.HashMap<>(); for (MetaField field : obj.getChildren(MetaField.class, true)) { if (ObjectField.SUBTYPE_OBJECT.equals(field.getSubType()) || field.isDerived()) continue; @@ -5424,8 +5443,10 @@ static void validateColumnNameCollisions(MetaRoot root) { ErrorMessageConstants.ERR_COLUMN_NAME_COLLISION + ": fields \"" + first.getName() + "\" and \"" + field.getName() + "\" on \"" + obj.getName() + "\" both resolve to physical column \"" - + column + "\". Rename one field, or give one an explicit @column so the" - + " two columns differ.", + + column + "\". " + + (hasExplicitColumn(first) && hasExplicitColumn(field) + ? "Rename one field, or point one @column at a different column." + : "Rename one field, or give one an explicit @column so the two columns differ."), ErrorCode.ERR_COLUMN_NAME_COLLISION, field.getSource()); } } diff --git a/server/python/src/metaobjects/loader/validation_passes.py b/server/python/src/metaobjects/loader/validation_passes.py index 34e1886d7..ae43a2339 100644 --- a/server/python/src/metaobjects/loader/validation_passes.py +++ b/server/python/src/metaobjects/loader/validation_passes.py @@ -64,6 +64,7 @@ from ..meta.meta_data import MetaData from ..meta.persistence.source.meta_source import MetaSource from ..meta.persistence.source.source_constants import ( + SOURCE_KIND_VIEW, SOURCE_READ_ONLY_KINDS, SOURCE_ROLE_PRIMARY, ) @@ -4198,6 +4199,12 @@ def _validate_index_lookup_fields(root: MetaData, errors: list[MetaError]) -> No # --------------------------------------------------------------------------- +def _has_explicit_column(field: MetaData) -> bool: + from ..meta.core.field import field_constants as fc + col = field.get_meta_attr(fc.FIELD_ATTR_COLUMN) + return isinstance(col, str) and col != "" + + def _validate_column_name_collisions(root: MetaData, errors: list[MetaError]) -> None: """ERR_COLUMN_NAME_COLLISION: two fields of one stored object resolving to the SAME physical column (``userId`` + ``userID`` both snake_case to ``user_id``, or two @@ -4208,6 +4215,11 @@ def _validate_column_name_collisions(root: MetaData, errors: list[MetaError]) -> honoured. Scope: a concrete object declaring at least one source (a source-less object generates nothing); derived fields and ``field.object`` (may flatten to several columns) are skipped. ADR-0039: effective children (inherited fields count). + + Exempt: an object read through ``source.rdb @kind: view`` with an ``@sql`` body. The + author's SQL defines that view's columns and the fields only READ them, so two fields + on one column create no duplicate column and nothing can be written. A derived view + (no ``@sql``) emits a select list from its fields and stays checked. """ from ..naming import resolve_column_name from ..meta.core.field.meta_field import MetaField @@ -4216,6 +4228,13 @@ def _validate_column_name_collisions(root: MetaData, errors: list[MetaError]) -> continue if not any(c.type == TYPE_SOURCE for c in obj.children()): continue + if any( + isinstance(c, MetaSource) + and c.effective_kind() == SOURCE_KIND_VIEW + and c.sql_body() is not None + for c in obj.children() + ): + continue seen: dict[str, MetaData] = {} for field in obj.children(): if not isinstance(field, MetaField) or field.sub_type == FIELD_SUBTYPE_OBJECT \ @@ -4230,8 +4249,12 @@ def _validate_column_name_collisions(root: MetaData, errors: list[MetaError]) -> code=ErrorCode.ERR_COLUMN_NAME_COLLISION, message=( f'fields "{first.name}" and "{field.name}" on "{obj.name}" both resolve ' - f'to physical column "{column}". Rename one field, or give one an ' - f"explicit @column so the two columns differ." + f'to physical column "{column}". ' + + ( + "Rename one field, or point one @column at a different column." + if _has_explicit_column(first) and _has_explicit_column(field) + else "Rename one field, or give one an explicit @column so the two columns differ." + ) ), envelope=field.source, )) diff --git a/server/typescript/packages/metadata/src/loader/validation-passes.ts b/server/typescript/packages/metadata/src/loader/validation-passes.ts index 116eed45a..dccf2c5d3 100644 --- a/server/typescript/packages/metadata/src/loader/validation-passes.ts +++ b/server/typescript/packages/metadata/src/loader/validation-passes.ts @@ -87,7 +87,8 @@ import { FIELD_SUBTYPE_TIMESTAMP, FIELD_SUBTYPE_UUID, } from "../core/field/field-constants.js"; -import { FIELD_ATTR_DB_INDEXED, IDENTITY_ATTR_EXPR } from "../persistence/db/db-constants.js"; +import { FIELD_ATTR_COLUMN, FIELD_ATTR_DB_INDEXED, IDENTITY_ATTR_EXPR } from "../persistence/db/db-constants.js"; +import { SOURCE_KIND_VIEW } from "../persistence/source/source-constants.js"; import { IDENTITY_ATTR_FIELDS, IDENTITY_SUBTYPE_REFERENCE, @@ -2777,13 +2778,29 @@ export function validateRetiredRequirementLinks(root: MetaData): ParseError[] { // object generates nothing); derived fields (origin.*) and field.object (which may // flatten to several columns) are not columns of this table and are skipped. // ADR-0039: effective fields, so an inherited field collides like an own one. +// Exempt: an object read through `source.rdb @kind: view` with an `@sql` body. The +// author's SQL defines that view's columns and the fields only READ them, so two +// fields on one column create no duplicate column and nothing can be written. A +// derived view (no `@sql`) emits a select list from its fields and stays checked. // --------------------------------------------------------------------------- +function readsThroughSqlView(obj: MetaData): boolean { + return obj.children().some( + (c) => c instanceof MetaSource && c.effectiveKind === SOURCE_KIND_VIEW && c.sqlBody !== undefined, + ); +} + +function hasExplicitColumn(field: MetaData): boolean { + const col = field.attr(FIELD_ATTR_COLUMN); + return typeof col === "string" && col !== ""; +} + export function validateColumnNameCollisions(root: MetaData): ParseError[] { const errors: ParseError[] = []; for (const obj of root.children().filter((c) => c.type === TYPE_OBJECT)) { if (obj.isAbstract) continue; if (!obj.children().some((c) => c.type === TYPE_SOURCE)) continue; + if (readsThroughSqlView(obj)) continue; const seen = new Map(); for (const field of obj.children()) { if (!isMetaField(field) || field.subType === FIELD_SUBTYPE_OBJECT || field.isDerived()) continue; @@ -2796,7 +2813,10 @@ export function validateColumnNameCollisions(root: MetaData): ParseError[] { errors.push( new ParseError( `fields "${first.name}" and "${field.name}" on "${obj.name}" both resolve to physical column ` + - `"${column}". Rename one field, or give one an explicit @column so the two columns differ.`, + `"${column}". ` + + (hasExplicitColumn(first) && hasExplicitColumn(field) + ? "Rename one field, or point one @column at a different column." + : "Rename one field, or give one an explicit @column so the two columns differ."), { code: "ERR_COLUMN_NAME_COLLISION", source: field.source }, ), ); diff --git a/server/typescript/packages/metadata/test/column-name-collision-message.test.ts b/server/typescript/packages/metadata/test/column-name-collision-message.test.ts new file mode 100644 index 000000000..45ece7f8c --- /dev/null +++ b/server/typescript/packages/metadata/test/column-name-collision-message.test.ts @@ -0,0 +1,26 @@ +import { expect, test } from "bun:test"; +import { join } from "node:path"; +import { tsAdapter } from "./conformance/adapter.js"; + +const CORPUS = join(import.meta.dir, "../../../../../fixtures/conformance"); +const PROVIDERS = ["metaobjects-core-types", "metaobjects-db"]; + +async function messages(fixture: string): Promise { + const out = await tsAdapter.loadFixture(join(CORPUS, fixture, "input"), PROVIDERS); + return (out.errors ?? []).map((e) => e.message ?? ""); +} + +test("two explicit @column on one column: advice does not suggest adding @column", async () => { + const [msg] = await messages("error-column-name-collision-derived-view"); + expect(msg).toContain("point one @column at a different column"); + expect(msg).not.toContain("give one an explicit @column"); +}); + +test("implicit collision still suggests an explicit @column", async () => { + const [msg] = await messages("error-column-name-collision"); + expect(msg).toContain("give one an explicit @column"); +}); + +test("an @sql view may share a column between fields", async () => { + expect(await messages("column-name-shared-in-sql-view-ok")).toEqual([]); +}); From 43e2911132e7e3db248e9cb8e8bf297f446f5dc5 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 3 Nov 2026 20:38:06 -0500 Subject: [PATCH 2/4] no-mistakes(review): Narrow SQL-view column exemption to read-only objects; gate wording --- CHANGELOG.md | 8 +- .../expected-errors.json | 15 +++ .../input/meta.demo.json | 18 ++++ .../providers.json | 1 + .../ColumnNameCollisionMessageTests.cs | 68 +++++++++++++ .../MetaObjects/Loader/ValidationPasses.cs | 19 ++-- .../metaobjects/loader/ValidationPhase.java | 16 +-- .../ColumnNameCollisionMessageTest.java | 98 +++++++++++++++++++ .../metaobjects/loader/validation_passes.py | 19 ++-- .../test_column_name_collision_message.py | 77 +++++++++++++++ .../metadata/src/loader/validation-passes.ts | 16 +-- 11 files changed, 324 insertions(+), 31 deletions(-) create mode 100644 fixtures/conformance/error-column-name-collision-table-with-sql-view/expected-errors.json create mode 100644 fixtures/conformance/error-column-name-collision-table-with-sql-view/input/meta.demo.json create mode 100644 fixtures/conformance/error-column-name-collision-table-with-sql-view/providers.json create mode 100644 server/csharp/MetaObjects.Conformance.Tests/ColumnNameCollisionMessageTests.cs create mode 100644 server/java/metadata/src/test/java/com/metaobjects/source/ColumnNameCollisionMessageTest.java create mode 100644 server/python/tests/unit/test_column_name_collision_message.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f54af18d..d218fd9a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,11 +16,13 @@ here.** The 1.2.0 check treated every field of a sourced object as a column MetaObjects creates. For a `source.rdb @kind: view` with an `@sql` body, the author's SQL defines the columns and the fields only read them, so two fields on one column are legal and nothing can be written. That object is - now exempt in all four ports (TypeScript, Java, C#, Python). A derived view (no `@sql`), which - emits a select list from its fields, is still checked. When both colliding fields already carry an + now exempt in all four ports (TypeScript, Java, C#, Python), provided every source of the object + is read-only. A derived view (no `@sql`), which emits a select list from its fields, and an object + with a writable table source plus an `@sql` view replica are still checked. When both colliding fields already carry an explicit `@column`, the message now says to rename one field or point one `@column` at a different column, instead of suggesting a `@column` they already have. The error code is unchanged. New - conformance fixtures: `column-name-shared-in-sql-view-ok` and `error-column-name-collision-derived-view`. + conformance fixtures: `column-name-shared-in-sql-view-ok`, `error-column-name-collision-derived-view` and + `error-column-name-collision-table-with-sql-view`. - **`meta migrate` refuses, at plan time, to drop a column a view it does not own still reads.** With `--from-db`, a declared-`@unmanaged` view or one outside the scoped schemas whose live body names the dropped column now blocks the `drop-column`, and the message names the view and the diff --git a/fixtures/conformance/error-column-name-collision-table-with-sql-view/expected-errors.json b/fixtures/conformance/error-column-name-collision-table-with-sql-view/expected-errors.json new file mode 100644 index 000000000..ce9627715 --- /dev/null +++ b/fixtures/conformance/error-column-name-collision-table-with-sql-view/expected-errors.json @@ -0,0 +1,15 @@ +{ + "errors": [ + { + "code": "ERR_COLUMN_NAME_COLLISION", + "source": { + "format": "json", + "files": [ + "meta.demo.json" + ], + "jsonPath": "$['metadata.root'].children[0]['object.entity'].children[4]['field.string']" + } + } + ], + "warnings": [] +} diff --git a/fixtures/conformance/error-column-name-collision-table-with-sql-view/input/meta.demo.json b/fixtures/conformance/error-column-name-collision-table-with-sql-view/input/meta.demo.json new file mode 100644 index 000000000..1c87fd5e2 --- /dev/null +++ b/fixtures/conformance/error-column-name-collision-table-with-sql-view/input/meta.demo.json @@ -0,0 +1,18 @@ +{ + "metadata.root": { + "package": "demo", + "children": [ + { "object.entity": { + "name": "Account", + "children": [ + { "source.rdb": { "@kind": "table", "@table": "account" } }, + { "source.rdb": { "@kind": "view", "@view": "account_view", "@role": "replica", "@sql": "SELECT a.id, a.username FROM account a" } }, + { "field.long": { "name": "id", "@column": "id" } }, + { "field.string": { "name": "userName", "@column": "username" } }, + { "field.string": { "name": "displayName", "@column": "username" } }, + { "identity.primary": { "name": "id", "@fields": ["id"] } } + ] + }} + ] + } +} diff --git a/fixtures/conformance/error-column-name-collision-table-with-sql-view/providers.json b/fixtures/conformance/error-column-name-collision-table-with-sql-view/providers.json new file mode 100644 index 000000000..aefa027b6 --- /dev/null +++ b/fixtures/conformance/error-column-name-collision-table-with-sql-view/providers.json @@ -0,0 +1 @@ +["metaobjects-core-types", "metaobjects-db"] diff --git a/server/csharp/MetaObjects.Conformance.Tests/ColumnNameCollisionMessageTests.cs b/server/csharp/MetaObjects.Conformance.Tests/ColumnNameCollisionMessageTests.cs new file mode 100644 index 000000000..cb2d32991 --- /dev/null +++ b/server/csharp/MetaObjects.Conformance.Tests/ColumnNameCollisionMessageTests.cs @@ -0,0 +1,68 @@ +// ERR_COLUMN_NAME_COLLISION advice wording and the @sql view exemption. +// +// Mirrors typescript/packages/metadata/test/column-name-collision-message.test.ts. + +using MetaObjects; +using MetaObjects.Loader; +using Xunit; + +namespace MetaObjects.Conformance.Tests; + +public class ColumnNameCollisionMessageTests +{ + private static string[] CollisionMessages(string sourceChild, string fieldA, string fieldB) + { + var doc = $$""" + { "metadata.root": { "package": "demo", "children": [ + { "object.entity": { "name": "Account", "children": [ + {{sourceChild}}, + { "field.long": { "name": "id" } }, + {{fieldA}}, + {{fieldB}}, + { "identity.primary": { "name": "id", "@fields": ["id"] } } + ] } } + ] } } + """; + return MetaDataLoader.FromString(doc, MetaDataFormat.Json).Errors + .Where(e => e.Code == ErrorCode.ERR_COLUMN_NAME_COLLISION) + .Select(e => e.Message) + .ToArray(); + } + + private const string Table = """{ "source.rdb": { "@kind": "table", "@table": "account" } }"""; + + [Fact] + public void Two_explicit_columns_on_one_column_do_not_suggest_adding_a_column() + { + var msg = Assert.Single(CollisionMessages(Table, + """{ "field.string": { "name": "userName", "@column": "username" } }""", + """{ "field.string": { "name": "displayName", "@column": "username" } }""")); + Assert.Contains("point one @column at a different column", msg); + Assert.DoesNotContain("give one an explicit @column", msg); + } + + [Fact] + public void Implicit_collision_still_suggests_an_explicit_column() + { + var msg = Assert.Single(CollisionMessages(Table, + """{ "field.string": { "name": "userId" } }""", + """{ "field.string": { "name": "userID" } }""")); + Assert.Contains("give one an explicit @column", msg); + } + + [Fact] + public void Sql_view_may_share_a_column_between_fields() + { + var doc = """ + { "metadata.root": { "package": "demo", "children": [ + { "object.projection": { "name": "UserView", "children": [ + { "source.rdb": { "@kind": "view", "@view": "v", "@sql": "SELECT id, username FROM t" } }, + { "field.long": { "name": "id", "@column": "id" } }, + { "field.string": { "name": "userName", "@column": "username" } }, + { "field.string": { "name": "displayName", "@column": "username" } } + ] } } + ] } } + """; + Assert.Empty(MetaDataLoader.FromString(doc, MetaDataFormat.Json).Errors); + } +} diff --git a/server/csharp/MetaObjects/Loader/ValidationPasses.cs b/server/csharp/MetaObjects/Loader/ValidationPasses.cs index 8c6a1d74a..7d11a0ca4 100644 --- a/server/csharp/MetaObjects/Loader/ValidationPasses.cs +++ b/server/csharp/MetaObjects/Loader/ValidationPasses.cs @@ -4445,13 +4445,18 @@ void Walk(MetaData node) // declaring at least one source (a source-less object generates nothing); derived // fields and field.object (may flatten to several columns) are skipped. // ADR-0039: effective children, so an inherited field collides like an own one. - // Exempt: an object read through source.rdb @kind: view with an @sql body. The author's - // SQL defines that view's columns and the fields only READ them, so two fields on one - // column create no duplicate column and nothing can be written. A derived view (no @sql) - // emits a select list from its fields and stays checked. - private static bool ReadsThroughSqlView(MetaData obj) => - obj.Children().OfType().Any(s => - s.EffectiveKind == SourceConstants.SOURCE_KIND_VIEW && s.SqlBody is not null); + // Exempt: an object whose every source is read-only and at least one is a + // source.rdb @kind: view with an @sql body. The author's SQL defines that view's columns + // and the fields only READ them, so two fields on one column create no duplicate column + // and nothing can be written. A derived view (no @sql) emits a select list from its + // fields, and an object with a writable source has a real table whose columns + // MetaObjects creates; both stay checked. + private static bool ReadsThroughSqlView(MetaData obj) + { + var sources = obj.Children().OfType().ToList(); + return sources.All(s => s.IsReadOnly()) && + sources.Any(s => s.EffectiveKind == SourceConstants.SOURCE_KIND_VIEW && s.SqlBody is not null); + } public static IReadOnlyList ValidateColumnNameCollisions(MetaData root) { 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 5ae4db749..f4c4b8c61 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 @@ -5410,16 +5410,20 @@ private static void walkRetiredRequirementLinks(MetaData node) { * columns) are skipped. ADR-0039: resolving accessors, so an inherited field collides like * an own one.

* - *

Exempt: an object read through {@code source.rdb @kind: view} with an {@code @sql} - * body. The author's SQL defines that view's columns and the fields only READ them, so two - * fields on one column create no duplicate column and nothing can be written. A derived - * view (no {@code @sql}) emits a select list from its fields and stays checked.

+ *

Exempt: an object whose every source is read-only and at least one is a + * {@code source.rdb @kind: view} with an {@code @sql} body. The author's SQL defines that + * view's columns and the fields only READ them, so two fields on one column create no + * duplicate column and nothing can be written. A derived view (no {@code @sql}) emits a + * select list from its fields, and an object with a writable source has a real table whose + * columns MetaObjects creates; both stay checked.

*/ private static boolean readsThroughSqlView(MetaObject obj) { + boolean sqlView = false; for (MetaSource s : obj.getChildren(MetaSource.class, true)) { - if (MetaSource.KIND_VIEW.equals(s.getEffectiveKind()) && s.getSqlBody() != null) return true; + if (!s.isReadOnly()) return false; + if (MetaSource.KIND_VIEW.equals(s.getEffectiveKind()) && s.getSqlBody() != null) sqlView = true; } - return false; + return sqlView; } private static boolean hasExplicitColumn(MetaField field) { diff --git a/server/java/metadata/src/test/java/com/metaobjects/source/ColumnNameCollisionMessageTest.java b/server/java/metadata/src/test/java/com/metaobjects/source/ColumnNameCollisionMessageTest.java new file mode 100644 index 000000000..c60512241 --- /dev/null +++ b/server/java/metadata/src/test/java/com/metaobjects/source/ColumnNameCollisionMessageTest.java @@ -0,0 +1,98 @@ +/* + * Copyright 2003 Doug Mealing LLC dba Meta Objects + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.metaobjects.source; + +import com.metaobjects.ErrorCode; +import com.metaobjects.MetaDataException; +import com.metaobjects.loader.InMemoryStringSource; +import com.metaobjects.loader.MetaDataLoader; +import com.metaobjects.registry.SharedRegistryTestBase; +import org.junit.Test; + +import java.net.URI; +import java.util.List; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.fail; + +/** + * ERR_COLUMN_NAME_COLLISION advice wording and the {@code @sql} view exemption. Mirrors + * {@code packages/metadata/test/column-name-collision-message.test.ts} in the TS reference port. + */ +public class ColumnNameCollisionMessageTest extends SharedRegistryTestBase { + + private static final String TABLE = "{ \"source.rdb\": { \"@kind\": \"table\", \"@table\": \"account\" } }"; + + private static String doc(String source, String fieldA, String fieldB) { + return "{ \"metadata.root\": { \"package\": \"demo\", \"children\": [" + + " { \"object.entity\": { \"name\": \"Account\", \"children\": [" + + source + "," + + " { \"field.long\": { \"name\": \"id\" } }," + + fieldA + "," + fieldB + "," + + " { \"identity.primary\": { \"name\": \"id\", \"@fields\": [\"id\"] } }" + + " ] } } ] } }"; + } + + private void load(String canonical, String id) { + MetaDataLoader loader = createTestLoader("ColumnNameCollisionMessageTest", + java.util.Collections.emptyList()); + loader.load(List.of(new InMemoryStringSource(canonical, id))); + } + + private String collisionMessage(String canonical, String id) { + try { + load(canonical, id); + } catch (MetaDataException e) { + assertTrue("expected ERR_COLUMN_NAME_COLLISION; got " + e.getMessage(), + e.getCode().filter(c -> c == ErrorCode.ERR_COLUMN_NAME_COLLISION).isPresent()); + return e.getMessage(); + } + fail("expected ERR_COLUMN_NAME_COLLISION"); + return null; + } + + @Test + public void twoExplicitColumnsOnOneColumnDoNotSuggestAddingAColumn() { + String msg = collisionMessage(doc(TABLE, + "{ \"field.string\": { \"name\": \"userName\", \"@column\": \"username\" } }", + "{ \"field.string\": { \"name\": \"displayName\", \"@column\": \"username\" } }"), "explicit.json"); + assertTrue(msg, msg.contains("point one @column at a different column")); + assertFalse(msg, msg.contains("give one an explicit @column")); + } + + @Test + public void implicitCollisionStillSuggestsAnExplicitColumn() { + String msg = collisionMessage(doc(TABLE, + "{ \"field.string\": { \"name\": \"userId\" } }", + "{ \"field.string\": { \"name\": \"userID\" } }"), "implicit.json"); + assertTrue(msg, msg.contains("give one an explicit @column")); + } + + @Test + public void sqlViewMayShareAColumnBetweenFields() { + load(""" + { "metadata.root": { "package": "demo", "children": [ + { "object.projection": { "name": "UserView", "children": [ + { "source.rdb": { "@kind": "view", "@view": "v", "@sql": "SELECT id, username FROM t" } }, + { "field.long": { "name": "id", "@column": "id" } }, + { "field.string": { "name": "userName", "@column": "username" } }, + { "field.string": { "name": "displayName", "@column": "username" } } + ] } } + ] } } + """, "sqlview.json"); + } +} diff --git a/server/python/src/metaobjects/loader/validation_passes.py b/server/python/src/metaobjects/loader/validation_passes.py index ae43a2339..bb2c548ce 100644 --- a/server/python/src/metaobjects/loader/validation_passes.py +++ b/server/python/src/metaobjects/loader/validation_passes.py @@ -4216,10 +4216,12 @@ def _validate_column_name_collisions(root: MetaData, errors: list[MetaError]) -> object generates nothing); derived fields and ``field.object`` (may flatten to several columns) are skipped. ADR-0039: effective children (inherited fields count). - Exempt: an object read through ``source.rdb @kind: view`` with an ``@sql`` body. The - author's SQL defines that view's columns and the fields only READ them, so two fields - on one column create no duplicate column and nothing can be written. A derived view - (no ``@sql``) emits a select list from its fields and stays checked. + Exempt: an object whose every source is read-only and at least one is a + ``source.rdb @kind: view`` with an ``@sql`` body. The author's SQL defines that view's + columns and the fields only READ them, so two fields on one column create no duplicate + column and nothing can be written. A derived view (no ``@sql``) emits a select list + from its fields, and an object with a writable source has a real table whose columns + MetaObjects creates; both stay checked. """ from ..naming import resolve_column_name from ..meta.core.field.meta_field import MetaField @@ -4228,11 +4230,10 @@ def _validate_column_name_collisions(root: MetaData, errors: list[MetaError]) -> continue if not any(c.type == TYPE_SOURCE for c in obj.children()): continue - if any( - isinstance(c, MetaSource) - and c.effective_kind() == SOURCE_KIND_VIEW - and c.sql_body() is not None - for c in obj.children() + sources = [c for c in obj.children() if isinstance(c, MetaSource)] + if all(s.is_read_only() for s in sources) and any( + s.effective_kind() == SOURCE_KIND_VIEW and s.sql_body() is not None + for s in sources ): continue seen: dict[str, MetaData] = {} diff --git a/server/python/tests/unit/test_column_name_collision_message.py b/server/python/tests/unit/test_column_name_collision_message.py new file mode 100644 index 000000000..24245a0ce --- /dev/null +++ b/server/python/tests/unit/test_column_name_collision_message.py @@ -0,0 +1,77 @@ +"""ERR_COLUMN_NAME_COLLISION advice wording and the @sql view exemption. + +Mirrors ``server/typescript/packages/metadata/test/column-name-collision-message.test.ts``. +""" +from __future__ import annotations + +import json + +from metaobjects import load_string +from metaobjects.errors import ErrorCode + +TABLE = {"source.rdb": {"@kind": "table", "@table": "account"}} + + +def _collision_messages(source: dict, field_a: dict, field_b: dict) -> list[str]: + doc = { + "metadata.root": { + "package": "demo", + "children": [ + { + "object.entity": { + "name": "Account", + "children": [ + source, + {"field.long": {"name": "id"}}, + field_a, + field_b, + {"identity.primary": {"name": "id", "@fields": ["id"]}}, + ], + } + } + ], + } + } + result = load_string(json.dumps(doc)) + return [e.message for e in result.errors if e.code == ErrorCode.ERR_COLUMN_NAME_COLLISION] + + +def test_two_explicit_columns_on_one_column_do_not_suggest_adding_a_column() -> None: + (msg,) = _collision_messages( + TABLE, + {"field.string": {"name": "userName", "@column": "username"}}, + {"field.string": {"name": "displayName", "@column": "username"}}, + ) + assert "point one @column at a different column" in msg + assert "give one an explicit @column" not in msg + + +def test_implicit_collision_still_suggests_an_explicit_column() -> None: + (msg,) = _collision_messages( + TABLE, + {"field.string": {"name": "userId"}}, + {"field.string": {"name": "userID"}}, + ) + assert "give one an explicit @column" in msg + + +def test_sql_view_may_share_a_column_between_fields() -> None: + doc = { + "metadata.root": { + "package": "demo", + "children": [ + { + "object.projection": { + "name": "UserView", + "children": [ + {"source.rdb": {"@kind": "view", "@view": "v", "@sql": "SELECT id, username FROM t"}}, + {"field.long": {"name": "id", "@column": "id"}}, + {"field.string": {"name": "userName", "@column": "username"}}, + {"field.string": {"name": "displayName", "@column": "username"}}, + ], + } + } + ], + } + } + assert load_string(json.dumps(doc)).errors == [] diff --git a/server/typescript/packages/metadata/src/loader/validation-passes.ts b/server/typescript/packages/metadata/src/loader/validation-passes.ts index dccf2c5d3..3d72cc712 100644 --- a/server/typescript/packages/metadata/src/loader/validation-passes.ts +++ b/server/typescript/packages/metadata/src/loader/validation-passes.ts @@ -2778,15 +2778,19 @@ export function validateRetiredRequirementLinks(root: MetaData): ParseError[] { // object generates nothing); derived fields (origin.*) and field.object (which may // flatten to several columns) are not columns of this table and are skipped. // ADR-0039: effective fields, so an inherited field collides like an own one. -// Exempt: an object read through `source.rdb @kind: view` with an `@sql` body. The -// author's SQL defines that view's columns and the fields only READ them, so two -// fields on one column create no duplicate column and nothing can be written. A -// derived view (no `@sql`) emits a select list from its fields and stays checked. +// Exempt: an object whose every source is read-only and at least one is a +// `source.rdb @kind: view` with an `@sql` body. The author's SQL defines that view's +// columns and the fields only READ them, so two fields on one column create no +// duplicate column and nothing can be written. A derived view (no `@sql`) emits a +// select list from its fields, and an object with a writable source has a real table +// whose columns MetaObjects creates; both stay checked. // --------------------------------------------------------------------------- function readsThroughSqlView(obj: MetaData): boolean { - return obj.children().some( - (c) => c instanceof MetaSource && c.effectiveKind === SOURCE_KIND_VIEW && c.sqlBody !== undefined, + const sources = obj.children().filter((c): c is MetaSource => c instanceof MetaSource); + return ( + sources.every((s) => s.isReadOnly()) && + sources.some((s) => s.effectiveKind === SOURCE_KIND_VIEW && s.sqlBody !== undefined) ); } From 144ed93990d243000a2a61be591208860f64ac29 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 3 Nov 2026 20:39:25 -0500 Subject: [PATCH 3/4] no-mistakes(review): Refresh conformance fixture count to 426 --- AGENTS.md | 2 +- docs/CONFORMANCE.md | 6 +++--- examples/showcase/site-payload.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 31c4c7fa8..ee0104546 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/` (423 fixtures; 35 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/` (426 fixtures; 35 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green. - Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical. - Persistence: `fixtures/persistence-conformance/`. **Query** scenarios run on every port (TS / C# / Java / Kotlin / Python), each provisioning its test DB by executing the committed, TS-produced `canonical/schema.postgres.sql` (Postgres only — Derby dropped for the cross-port query corpus, ADR-0015; the FR-044 `report-*` scenarios also run on SQLite, MySQL and D1 where a port has the engine, see [docs/CONFORMANCE.md](docs/CONFORMANCE.md)). The **migration** scenarios are exercised by **TS only** (TS owns schema migrations). **The corpus now gates WRITES, not just reads (SP-H):** an `op: roundtrip` scenario type INSERTs through each port's runtime/ORM write codec (NOT raw SQL), reads the row back, and asserts the wire-normalized value. The `AllTypes` entity (`roundtrip-all-types.yaml`) carries one field of **every** persistable `field.*` subtype — string/int/long/double/float/decimal/boolean/date/time/timestamp(+tz)/currency/enum/uuid/object — plus an **array-of-VO** `field.object @isArray @storage:jsonb` column (`labels`, written as 2-element / empty-`[]` / single-element arrays across the three rows) — so every subtype write+read (incl. the array-of-value-object jsonb codec) round-trips through every port against Testcontainers PG. (`field.byte`/`field.short`/`field.class` were cut as non-functional registration-only stubs — the matrix tracks only genuinely-supported subtypes; see `fixtures/registry-conformance/README.md` → "Per-subtype write-round-trip matrix".) - API-contract: `fixtures/api-contract-conformance/`. TS / C# / Java / Kotlin / Python all green — each port runs **two lanes**: a hand-rolled reference server AND its **generated** API artifact booted over HTTP (the deployed controller/routes; TS+C# full-stack vs Testcontainers PG, Java/Kotlin/Python generated controller + in-memory repo behind the consumer seam). The generated fan-out found 10 real deployment bugs golden snapshots missed. Three sub-corpora run the **generated lane only, on all five ports** — `write-through/`, `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`) and `report/` (FR-044: a view-backed `object.report` serves GET list, answers `POST` with that same `405`, and mounts no `/{id}` route). That is deliberate, not a gap: what is under test is whether a port's GENERATOR emits those routes, and a hand-rolled reference server would answer every scenario by construction. The `m2m/` sub-corpus also gates **TPH x M:N together** (base-declared, subtype-declared, abstract-mid-declared, a non-subtype source onto a subtype TARGET, and the cross-subtype source id answering `200 []`) — the two corpora were originally built disjoint (`tph/` had no relationships, `m2m/` no discriminators), which is precisely how that defect class survived. diff --git a/docs/CONFORMANCE.md b/docs/CONFORMANCE.md index d7e915756..16d963fd0 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) | 423 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ | +| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 426 | ✓ | ✓ | 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 | ✓ | — | — | — | ✓ | @@ -285,7 +285,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker. ## Fixture-to-doc mapping -### `fixtures/conformance/` — metamodel loader + canonical serializer (423) +### `fixtures/conformance/` — metamodel loader + canonical serializer (426) | Fixture prefix | Feature doc | |---|---| @@ -611,7 +611,7 @@ files into a real Cube. ## Orphaned fixtures (tested but not yet documented) -The fixtures in the nine corpora mapped above (metamodel 423 + yaml 16 + verify 45 +The fixtures in the nine corpora mapped above (metamodel 426 + yaml 16 + verify 45 + render 18 + persistence 59 + api-contract 119 + source-resolution 25 + scope 10 + dependency 37) each map to a feature doc, and so do the two requirement corpora, `shared-model-conformance` and the `cube-model` corpus, whose case lists live in their own READMEs. None are orphaned today. The remaining diff --git a/examples/showcase/site-payload.json b/examples/showcase/site-payload.json index 6e9ec3f63..7330d646f 100644 --- a/examples/showcase/site-payload.json +++ b/examples/showcase/site-payload.json @@ -7,7 +7,7 @@ "metamodel": "1.2" }, "counts": { - "fixtures": 423, + "fixtures": 426, "corpora": 35, "baseTypes": 17 }, From 8915d3d45dd288abe3594568abf80bf03e1909a8 Mon Sep 17 00:00:00 2001 From: Doug Mealing Date: Tue, 3 Nov 2026 23:09:27 -0500 Subject: [PATCH 4/4] no-mistakes(document): Refreshed collision error-code description; other docs already current --- fixtures/conformance/ERROR-CODES.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fixtures/conformance/ERROR-CODES.json b/fixtures/conformance/ERROR-CODES.json index d2c931bba..278b1b50f 100644 --- a/fixtures/conformance/ERROR-CODES.json +++ b/fixtures/conformance/ERROR-CODES.json @@ -38,7 +38,7 @@ "ERR_EXTENDS_ORIGIN_MISMATCH": "FR-024 (ADR-0029 decision 7): a field declares both an entity-nested extends (shape lineage) and an origin.passthrough @from (data lineage) and they disagree — the resolved @from target is not the field's resolved extends target (nor anywhere on its extends chain). Host-agnostic (projections, entities, values). origin.aggregate is never judged (it computes something new); a top-level abstract extends target is never judged (shape-only reuse makes no lineage claim).", "ERR_PASSTHROUGH_TYPE_MISMATCH": "#185: a field carrying origin.passthrough @from: \"Entity.field\" declares a field. or array-ness that differs from its resolved source field — a passthrough forwards the value unchanged, so its type must be identical. Host-agnostic (projections, entities, values, stored-proc parameter refs). Nullability is NOT judged (a view over an outer join legitimately widens NOT NULL → nullable). Opt out of a deliberate type change with @convert: true on the origin.passthrough (an acknowledgement — it does not generate a cast); real type-converting projections are origin.expression's job (#159).", "ERR_DERIVED_FIELD_NO_READ_SOURCE": "FR-024 (spec §7): an object.entity field carrying an origin.* child is derived (read-only) and must be providable — the entity must declare at least one source with a read-only @kind (view/materializedView/storedProc/tableFunction, e.g. a @role replica view beside the table primary). Table-only or source-less entities with origin-bearing fields error on the field. Projections and object.value hosts are exempt. Until the Phase-E B4b cutover removes view-primary entities, a read-only-kind PRIMARY source also counts as providable (legacy spelling).", - "ERR_COLUMN_NAME_COLLISION": "Two fields of one stored object (a concrete object declaring a source) resolve to the same physical column name: snake_case folds `userId` and `userID` into `user_id`, or two fields share an explicit @column. Only RESOLVED physical names are compared (explicit @column honoured, inherited fields included, derived and source-less objects exempt). The message names both fields and the column.", + "ERR_COLUMN_NAME_COLLISION": "Two fields of one stored object (a concrete object declaring a source) resolve to the same physical column name: snake_case folds `userId` and `userID` into `user_id`, or two fields share an explicit @column. Only RESOLVED physical names are compared (explicit @column honoured, inherited fields included, derived and source-less objects exempt, and so is an object whose every source is read-only and at least one is a `@sql` view, since the author's SQL defines those columns). The message names both fields and the column.", "ERR_INVALID_TEMPLATE": "A template (prompt/output) declares a @payloadRef that does not resolve, or @requiredSlots that are not fields on its payload.", "ERR_INVALID_RELATIONSHIP": "FR-017: a M:N relationship's slim vocabulary is invalid — @through does not resolve to a junction declaring two identity.reference children (ADR-0042: @through resolves package-locally when bare, exactly when FQN — a bare cross-package @through no longer binds elsewhere), @sourceRefField does not match one of them, or a M:N-only attr (@through/@sourceRefField/@symmetric) is set on a non-M:N (1:N / @cardinality:one) relationship; OR a relationship's @objectRef does not resolve to any object in the loaded tree (a dangling target).", "ERR_INVALID_REFERENCE": "An identity.reference @references names an FK target object that does not resolve to any object in the loaded tree (a dangling cross-reference between metadata).",