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 CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -418,7 +418,7 @@ Preserve the following contracts exactly across all language ports:

**Metamodel subtype vocabularies (must be identical across languages):** the `registry-conformance` gate (`fixtures/registry-conformance/`) is the structural enforcer of this rule — each port emits its registry as a canonical manifest byte-matched to `expected-registry.json`. **All five ports (TS / C# / Java / Kotlin / Python) are live + green** (SP-G Java/Kotlin reconciliation complete; the JVM runners compose from the defined metamodel provider set so codegen-base/om classpath SPI does not pollute the measured vocabulary). See `fixtures/registry-conformance/README.md`.
- Filter operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `like`, `isNull`
- Object subtypes: `entity` (owns data: own identity, writable sources, lifecycle), `value` (pure shape: NO identity, NO source, ever; constructed — by caller/assembly/embedding — never populated; may `extends` entity fields for shape), `projection` (derived read-only representation: fields `extends`-bound / origin-derived / self-declared-under-external-assembly, all read-only at subtype level; identity optional and MUST extend an entity identity; sources restricted to read-only `@kind`s; the declared field set IS the exposure — inclusive list, fail-closed). A field carrying `origin.*` is derived ⇒ read-only wherever it lives (incl. on entities). An entity's primary source must be a writable `@kind` (read-only kinds only in read role). See [ADR-0028](spec/decisions/ADR-0028-object-taxonomy-projection-value-purity.md). (FR-024 Phase E — `object.projection`/`value` are registered in `expected-registry.json` and the projection/value validation passes [identity pass-through, value-purity, projection-licensing, `@via` inference/cardinality, extends/origin agreement, derived-field providability] are enforced cross-port in all 5 ports. The **B4b** entity-primary-source-readonly cutover [the "writable `@kind`" clause above] + the projection codegen fan-out remain — tracked in #10.)
- Object subtypes: `entity` (owns data: own identity, writable sources, lifecycle), `value` (pure shape: NO identity, NO source, ever; constructed — by caller/assembly/embedding — never populated; may `extends` entity fields for shape), `projection` (derived read-only representation: fields `extends`-bound / origin-derived / self-declared-under-external-assembly, all read-only at subtype level; identity optional and MUST extend an entity identity; sources restricted to read-only `@kind`s; the declared field set IS the exposure — inclusive list, fail-closed). A field carrying `origin.*` is derived ⇒ read-only wherever it lives (incl. on entities). An entity's primary source must be a writable `@kind` (read-only kinds only in read role). See [ADR-0028](spec/decisions/ADR-0028-object-taxonomy-projection-value-purity.md). (FR-024 Phase E — `object.projection`/`value` are registered in `expected-registry.json` and the projection/value validation passes [identity pass-through, value-purity, projection-licensing, `@via` inference/cardinality, extends/origin agreement, derived-field providability] are enforced cross-port in all 5 ports. The **B4b** entity-primary-source-readonly cutover [the "writable `@kind`" clause above — `ERR_ENTITY_PRIMARY_SOURCE_READONLY`] + the projection codegen fan-out (read-only DTOs for view-kind projections; FR-015 proc-callables for proc-kind projections; api-docs label `object.projection` units as `projection` and document their generated `<Name>Dto`) are now shipped cross-port; the remaining FR-024 work is the declared-API surface — tracked in #10.)
- Source subtypes: `rdb` (paradigm; ADR-0007). The pre-v2 `dbTable`/`dbView` subtypes are RETIRED — `source.rdb` + `@kind: table|view|materializedView|storedProc|tableFunction` is the form, with read-only-ness derived from `@kind`. Multi-source via `@role` (exactly one `primary` per object). Source physical name = `@table` (NOT `@name`); field physical name = `@column` (renamed from `@dbColumn`). Referential actions on relationships: `@onDelete` / `@onUpdate`.
- Origin subtypes: `passthrough`, `aggregate`
- Relationship subtypes: `association`, `aggregation`, `composition`. Cardinality via `@cardinality: one|many`; target via `@objectRef`. **M:N (FR-018) slim vocabulary:** `@cardinality: "many"` + `@objectRef` (target) + `@through` (the junction/through entity — a third entity that MUST declare two `identity.reference` children, one per FK side). The relationship's FK fields are **derived** from those references (the `identity.reference` SSOT for FK direction), never restated. `@sourceRefField` (optional) disambiguates a *directed* self-join by naming the source-side FK field on the junction (the other reference is the target side). `@symmetric` (optional boolean) marks an *undirected* self-join (union-on-read) — valid only when `@objectRef` == the declaring entity, and mutually exclusive with `@sourceRefField`. The pre-FR-018 `@joinEntity`/`@joinFields` attrs are REMOVED. Validation errors: symmetric-on-hetero / symmetric+sourceRefField → `ERR_BAD_ATTR_VALUE`; junction-missing-two-references / sourceRefField-not-matching / M:N-attr-on-1:N → `ERR_INVALID_RELATIONSHIP`.
Expand Down
37 changes: 24 additions & 13 deletions docs/features/source-kinds.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,19 @@ dispatch.
| `table` | No | Yes (when `@kind` omitted) | Persisted entity |
| `view` | Yes | – | Projection — read-only Zod, read-only routes, read-only finders |
| `materializedView` | Yes | – | Same as `view`, refresh discipline is host-app's concern |
| `storedProc` | Yes | – | Read-only entity backed by a stored procedure |
| `tableFunction` | Yes | – | Read-only entity backed by a parameterized table-valued function |
| `storedProc` | Yes | – | Read-only projection backed by a stored procedure |
| `tableFunction` | Yes | – | Read-only projection backed by a parameterized table-valued function |

Read-only-ness drives the codegen dispatch: a `view` entity emits read-only Zod, no
mutation routes, and read-only finders. A `table` emits the full CRUD surface.
Read-only-ness drives the codegen dispatch: a `view`-kind projection emits read-only
Zod, no mutation routes, and read-only finders. A `table` emits the full CRUD surface.

A read-only `@kind` (`view` / `materializedView` / `storedProc` / `tableFunction`) may
only be an object's **primary** source on an `object.projection` — a derived, read-only
representation (see [ADR-0028](../../spec/decisions/ADR-0028-object-taxonomy-projection-value-purity.md)).
An `object.entity`'s primary source must be a writable `@kind` (`table`); declaring an
entity over a read-only primary source is a load error
(`ERR_ENTITY_PRIMARY_SOURCE_READONLY`). A read-only source may still appear on an entity
in a non-primary read `@role`.

## Authoring

Expand All @@ -39,9 +47,14 @@ mutation routes, and read-only finders. A `table` emits the full CRUD surface.

### View — projection over an existing entity

A read-only view is declared as an `object.projection`, not an `object.entity` (an
entity's primary source must be writable). A projection's identity is optional and, when
present, MUST extend an entity identity; the example below omits it (a keyless read
model).

```json
{
"object.entity": {
"object.projection": {
"name": "AuthorView",
"children": [
{ "source.rdb": { "@kind": "view", "@table": "v_author" } },
Expand All @@ -51,8 +64,7 @@ mutation routes, and read-only finders. A `table` emits the full CRUD surface.
"children": [ { "origin.passthrough": { "@from": "Author.name" } } ] } },
{ "field.long": { "name": "postCount",
"children": [ { "origin.aggregate": {
"@agg": "count", "@of": "Post.id", "@via": "Author.posts" } } ] } },
{ "identity.primary": { "@fields": "id" } }
"@agg": "count", "@of": "Post.id", "@via": "Author.posts" } } ] } }
]
}
}
Expand All @@ -66,13 +78,12 @@ vocabulary (`passthrough`, `aggregate`, `collection`).

```json
{
"object.entity": {
"object.projection": {
"name": "AuthorStats",
"children": [
{ "source.rdb": { "@kind": "storedProc", "@table": "sp_author_stats" } },
{ "field.long": { "name": "authorId" } },
{ "field.long": { "name": "publishedCount" } },
{ "identity.primary": { "@fields": "authorId" } }
{ "field.long": { "name": "publishedCount" } }
]
}
}
Expand Down Expand Up @@ -139,8 +150,8 @@ non-null.

### Java

OMDB resolves the physical name via `@table`; `@kind: "view"` flips the entity to
read-only at the ObjectManager layer (mutating ops throw). The `CREATE VIEW` body
OMDB resolves the physical name via `@table`; a `@kind: "view"` source on an
`object.projection` is read-only at the ObjectManager layer (mutating ops throw). The `CREATE VIEW` body
is emitted by the TS toolchain (`meta migrate`) from the `origin.*` aggregate /
passthrough metadata; the `meta:migrate` Maven goal was removed.

Expand Down Expand Up @@ -172,7 +183,7 @@ object AuthorViewTable : Table("v_author") {

`MetaObjects.Codegen` emits `OwnsOne` / `DbSet` wiring as appropriate; for `@kind:
"view"` the generated `AppDbContext` calls `entity.ToView("v_author")`. The
`meta migrate` command emits a `CREATE VIEW` body for projection entities.
`meta migrate` command emits a `CREATE VIEW` body for projections.

```csharp
// generated/AppDbContext.cs (excerpt) — view registration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ public sealed class CSharpApiDocsAccuracyTests
{ "field.object": { "name": "home", "@objectRef": "Address" } },
{ "identity.primary": { "@fields": "id", "@generation": "increment" } }
]}},
{ "object.projection": { "name": "AuthorSummary", "children": [
{ "source.rdb": { "@kind": "view", "@table": "v_author_summary" } },
{ "field.string": { "name": "name" } },
{ "field.long": { "name": "tagCount" } }
]}},
{ "object.value": { "name": "SummaryPayload", "children": [
{ "field.string": { "name": "summary", "@required": true } }
]}},
Expand Down Expand Up @@ -144,8 +149,10 @@ public void Fixture_loads_and_documents_the_expected_units()
{
var (model, _) = BuildAndGenerate(tpl);
var nodes = model.Units.Select(u => u.Node).OrderBy(n => n, StringComparer.Ordinal).ToList();
// Author + Address + SummaryPayload + SummaryOutput; BaseNode (abstract) is absent.
Assert.Equal(new[] { "Address", "Author", "SummaryOutput", "SummaryPayload" }, nodes);
// Author + Address + AuthorSummary (projection) + SummaryPayload + SummaryOutput;
// BaseNode (abstract) is absent.
Assert.Equal(
new[] { "Address", "Author", "AuthorSummary", "SummaryOutput", "SummaryPayload" }, nodes);
}
finally { Directory.Delete(tpl, recursive: true); }
}
Expand Down Expand Up @@ -238,6 +245,49 @@ public void Value_object_is_documented_as_model_only()
finally { Directory.Delete(tpl, recursive: true); }
}

[Fact]
public void Projection_is_documented_as_read_model_with_readonly_dbset_no_write_surface()
{
var tpl = WriteTemplates();
try
{
var (model, all) = BuildAndGenerate(tpl);
var summary = model.Units.Single(u => u.Node == "AuthorSummary");

// A view-kind object.projection → a read-model unit with a read-only DbSet +
// read routes, but NO write surfaces (no Validation / FilterAllowlist). The
// DbSet + read routes ARE generated for a projection (DbView != null), so the
// builder must document them — the bug was the `if (entity)` gate hiding them.
Assert.Equal("projection", summary.Kind);
var kinds = summary.Symbols.Select(s => s.Kind).ToHashSet();
Assert.Contains(ApiSymbolKind.Model, kinds);
Assert.Contains(ApiSymbolKind.DataAccess, kinds);
Assert.DoesNotContain(ApiSymbolKind.Validation, kinds);
Assert.DoesNotContain(ApiSymbolKind.Filter, kinds);

// Forward-confirm the documented DbSet is really declared on the AppDbContext...
var dbSet = summary.Symbols.Single(s => s.Kind == ApiSymbolKind.DataAccess);
Assert.True(ContainsDeclaration(all, dbSet.Name),
$"documented projection DbSet '{dbSet.Name}' is not declared in the generated C#");

// ...every documented REST verb maps to a real route registration (read verbs
// only — a read-only projection generates no POST/PATCH/PUT/DELETE)...
foreach (var sym in summary.Symbols.Where(s => s.Kind == ApiSymbolKind.Rest))
{
var parts = sym.Name.Split(' ', 2);
Assert.Equal("GET", parts[0]);
var remainder = parts[1]["/api".Length..];
Assert.True(all.Contains("MapGet(prefix + \"" + remainder + "\""),
$"documented projection REST '{sym.Name}' has no matching MapGet registration");
}

// ...and NO filter allowlist CLASS is declared for the projection (the builder
// documents no FILTER symbol, matching FilterAllowlistGenerator skipping it).
Assert.False(ContainsDeclaration(all, "AuthorSummaryFilterAllowlist"));
}
finally { Directory.Delete(tpl, recursive: true); }
}

[Fact]
public void Abstract_object_is_not_documented()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
// array string "tags" (isArray) → .PrimitiveCollection()
// field.object homeAddress @storage flattened → OwnsOne(...) per-property column names
// field.object config (default storage) → OwnsOne(...).ToJson(...)
// - object.entity ProgramSummary with source.dbView → .ToView(...) (no HasNoKey — keyed)
// - object.projection ProgramSummary (view-kind source, keyless) → .ToView(...).HasNoKey()
//
// The test DOES NOT include RoutesGenerator output: routes import ASP.NET Core
// shared-framework types that are not available in the TRUSTED_PLATFORM_ASSEMBLIES
Expand Down Expand Up @@ -51,11 +51,10 @@ public class DbContextCompileTests
{ "field.object": { "name": "config", "@objectRef": "Address" } },
{ "identity.primary": { "@fields": "id" } }
]}},
{ "object.entity": { "name": "ProgramSummary", "children": [
{ "object.projection": { "name": "ProgramSummary", "children": [
{ "source.rdb": { "@kind": "view", "@table": "v_program_summary" } },
{ "field.long": { "name": "id" } },
{ "field.int": { "name": "weekCount" } },
{ "identity.primary": { "@fields": "id" } }
{ "field.int": { "name": "weekCount" } }
]}}
]}}
""";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -146,11 +146,10 @@ public void Callable_parameterRef_value_object_is_emitted_as_a_poco()
{ "object.value": { "name": "ReportArgs", "children": [
{ "field.long": { "name": "year", "@required": true } }
]}},
{ "object.entity": { "name": "ReportRow", "children": [
{ "object.projection": { "name": "ReportRow", "children": [
{ "source.rdb": { "@table": "fn_report", "@kind": "storedProc", "@parameterRef": "ReportArgs" } },
{ "field.long": { "name": "id" } },
{ "field.string": { "name": "label" } },
{ "identity.primary": { "@fields": "id" } }
{ "field.string": { "name": "label" } }
]}}
]}}
""";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,10 @@ public void View_kind_projection_entity_gets_no_allowlist()
{ "field.string": { "name": "name", "@filterable": true } },
{ "identity.primary": { "@fields": "id" } }
]}},
{ "object.entity": { "name": "AuthorView", "extends": "Author", "children": [
{ "source.rdb": { "@kind": "view", "@table": "v_authors" } }
{ "object.projection": { "name": "AuthorView", "children": [
{ "source.rdb": { "@kind": "view", "@table": "v_authors" } },
{ "field.long": { "name": "id" } },
{ "field.string": { "name": "name" } }
]}}
]}}
""";
Expand Down
6 changes: 3 additions & 3 deletions server/csharp/MetaObjects.Codegen.Tests/Fr015CodegenTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -34,16 +34,16 @@ public class Fr015CodegenTests
{ "field.int": { "name": "caseId", "@required": true } },
{ "field.timestamp": { "name": "asOfDate" } }
]}},
{ "object.entity": { "name": "PhaseSummary", "children": [
{ "object.projection": { "name": "PhaseSummary", "children": [
{ "source.rdb": { "@kind": "storedProc", "@proc": "fn_phase_summary", "@schema": "analytics", "@parameterRef": "PhaseSummaryArgs" } },
{ "field.long": { "name": "phaseId" } },
{ "field.string": { "name": "phaseName" } }
]}},
{ "object.entity": { "name": "ActivePhases", "children": [
{ "object.projection": { "name": "ActivePhases", "children": [
{ "source.rdb": { "@kind": "tableFunction", "@function": "fn_active_phases", "@parameterRef": "PhaseSummaryArgs" } },
{ "field.long": { "name": "phaseId" } }
]}},
{ "object.entity": { "name": "AllPhases", "children": [
{ "object.projection": { "name": "AllPhases", "children": [
{ "source.rdb": { "@kind": "storedProc", "@proc": "fn_all_phases" } },
{ "field.long": { "name": "phaseId" } }
]}},
Expand Down
12 changes: 9 additions & 3 deletions server/csharp/MetaObjects.Codegen.Tests/ProjectionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,17 @@ public class ProjectionTests
// TagCount: keyless projection (dbView, no identity).
private const string Model = """
{ "metadata.root": { "package": "acme", "children": [
{ "object.entity": { "name": "ProgramSummary", "children": [
{ "source.rdb": { "@kind": "view", "@table": "v_program_summary" } },
{ "object.entity": { "name": "Program", "children": [
{ "source.rdb": { "@kind": "table", "@table": "programs" } },
{ "field.long": { "name": "id" } },
{ "field.int": { "name": "weekCount" } },
{ "identity.primary": { "@fields": "id" } }
{ "identity.primary": { "name": "pk", "@fields": "id" } }
]}},
{ "object.projection": { "name": "ProgramSummary", "children": [
{ "source.rdb": { "@kind": "view", "@table": "v_program_summary" } },
{ "field.long": { "name": "id", "extends": "Program.id" } },
{ "field.int": { "name": "weekCount" } },
{ "identity.primary": { "name": "pk", "extends": "Program.pk" } }
]}},
{ "object.projection": { "name": "TagCount", "children": [
{ "source.rdb": { "@kind": "view", "@table": "v_tag_count" } },
Expand Down
Loading
Loading