Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,17 @@ 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), provided every source of the object
is read-only. A derived view (no `@sql`), which emits a select list from its fields, and an object
with a writable table source plus an `@sql` view replica are still checked. When both colliding fields already carry an
explicit `@column`, the message now says to rename one field or point one `@column` at a different
column, instead of suggesting a `@column` they already have. The error code is unchanged. New
conformance fixtures: `column-name-shared-in-sql-view-ok`, `error-column-name-collision-derived-view` and
`error-column-name-collision-table-with-sql-view`.
- **`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
Expand Down
6 changes: 3 additions & 3 deletions docs/CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | 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 | ✓ | — | — | — | ✓ |
Expand Down Expand Up @@ -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 |
|---|---|
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion examples/showcase/site-payload.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"metamodel": "1.2"
},
"counts": {
"fixtures": 423,
"fixtures": 426,
"corpora": 35,
"baseTypes": 17
},
Expand Down
2 changes: 1 addition & 1 deletion fixtures/conformance/ERROR-CODES.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.<subType> 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).",
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
]
}
}
]
}
}
Original file line number Diff line number Diff line change
@@ -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" } }
]
}}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
["metaobjects-core-types", "metaobjects-db"]
Original file line number Diff line number Diff line change
@@ -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": []
}
Original file line number Diff line number Diff line change
@@ -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" } }
]
}}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
["metaobjects-core-types", "metaobjects-db"]
Original file line number Diff line number Diff line change
@@ -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": []
}
Original file line number Diff line number Diff line change
@@ -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"] } }
]
}}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
["metaobjects-core-types", "metaobjects-db"]
Original file line number Diff line number Diff line change
@@ -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);
}
}
20 changes: 18 additions & 2 deletions server/csharp/MetaObjects/Loader/ValidationPasses.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4445,13 +4445,27 @@ 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 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<MetaSource>().ToList();
return sources.All(s => s.IsReadOnly()) &&
sources.Any(s => s.EffectiveKind == SourceConstants.SOURCE_KIND_VIEW && s.SqlBody is not null);
}

public static IReadOnlyList<MetaError> ValidateColumnNameCollisions(MetaData root)
{
var errors = new List<MetaError>();
foreach (var obj in root.OwnChildren().Where(c => c.Type == TYPE_OBJECT))
{
if (obj.IsAbstract) continue;
if (!obj.Children().Any(c => c.Type == TYPE_SOURCE)) continue;
if (ReadsThroughSqlView(obj)) continue;
var seen = new Dictionary<string, MetaField>(StringComparer.Ordinal);
foreach (var field in obj.Children().OfType<MetaField>())
{
Expand All @@ -4464,8 +4478,10 @@ public static IReadOnlyList<MetaError> 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));
}
Expand Down
Loading
Loading