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 8b13247ac0..3f54af18db 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 0000000000..388777ebf5
--- /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 0000000000..5314b05bf1
--- /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 0000000000..aefa027b6a
--- /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 0000000000..a19f7ad425
--- /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 0000000000..50ac7e9092
--- /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 0000000000..aefa027b6a
--- /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 13f06857b2..8c6a1d74a2 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 1ddc61efbc..5ae4db7490 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 34e1886d77..ae43a23392 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 116eed45a8..dccf2c5d31 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 0000000000..45ece7f8cb
--- /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 3f54af18db..d218fd9a9a 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 0000000000..ce96277156
--- /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 0000000000..1c87fd5e2a
--- /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 0000000000..aefa027b6a
--- /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 0000000000..cb2d329912
--- /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 8c6a1d74a2..7d11a0ca44 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 5ae4db7490..f4c4b8c615 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 0000000000..c60512241a
--- /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 ae43a23392..bb2c548cee 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 0000000000..24245a0ce6
--- /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 dccf2c5d31..3d72cc712b 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 31c4c7fa80..ee01045468 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 d7e9157567..16d963fd0e 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 6e9ec3f63d..7330d646f0 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 d2c931bbad..278b1b50f0 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).",