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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- **Typed value-object jsonb columns work end-to-end across all persistence ports (single + array-of-VO).** A `field.object @storage:jsonb` column — a single value-object OR an `@isArray` array-of-VO — now round-trips through every port's runtime/ORM write+read codec (TS Kysely, C# EF Core, Java OMDB/Gson, Kotlin Exposed, Python pg8000). Gated by a new array-of-VO dimension in the persistence-conformance `AllTypes` `op: roundtrip` scenario: a `labels` column (`field.object @isArray @storage:jsonb`) written as a 2-element, empty-`[]` (≠ `null`), and single-element array across three rows — the gap that had let a non-compiling / wrong array serializer ship in three ports. The single-VO jsonb path was already cross-port green; this closes the array-of-VO half.

### Fixed
- **Load-order-independent super-resolution for dotted `extends` to an inherited member, all five ports (#188).** Deferred super-resolution ran a single pre-order walk over the physical declaration tree, so a dotted ref to an INHERITED member (`extends: Owner.member` where `Owner` inherits `member` via its OWN `extends`) only resolved when the owner's extends chain happened to be wired first — green under one directory-scan order, `ERR_UNRESOLVED_SUPER` under another (Node vs Bun `readdir`). Resolution is now ON DEMAND with memoization + cycle detection: before a dotted ref reads the owner's effective `children()`, the owner's whole extends chain is resolved first, and the resolved target's chain is resolved too (multi-level inheritance). The result is a pure function of the source SET, independent of enumeration order. The TS SDK's `listMetadataFiles` also sorts its raw `readdir` entries so every declaration-order-preserving artifact (serialization) is stable across runtimes — a deterministic-enumeration floor (the other ports' directory sources already sort). Tier-1 invariants are unchanged (`ERR_UNRESOLVED_SUPER` / `ERR_EXTENDS_TARGET_MISMATCH`, failure envelope, target-mismatch contract byte-identical). Gated by a new `extends-dotted-inherited-member-load-order` conformance fixture (RED→GREEN in every port) + a TS shuffle-invariance test (six permutations → identical model) + a duplicate-failure regression test.
- **C# / Java / Python array-of-VO jsonb write+read codecs.** C# EF Core model finalization threw `'ICollection must be a non-interface reference type'` on an `@isArray` `field.object @storage:jsonb` column — `DbContextGenerator` now emits `.OwnsMany(...).ToJson(...)` when `field.ResolvedIsArray()` (the `EntityGenerator` emits `ICollection<VO>`), with a coupled empty-`[]`-vs-`null` nullability fix. Java OMDB threw `Expected BEGIN_OBJECT but was BEGIN_ARRAY` — `GenericSQLDriver.deserializeJsonb` now branches on `isArray` (target `TypeToken.getParameterized(List.class, VO)` via `jsonbTargetType`) and the read path stores the resulting `List` through `setObjectArray` (not the scalar `setObject`). Python's `_coerce_write_value` now `json.dumps`s both dict and list jsonb-storage `field.object`/`field.map` values to a JSON text string — pg8000 binds a native `dict` to jsonb fine, but adapts a native `list` as a Postgres ARRAY literal (`{...,...}`) which the JSONB column rejects with `22P02`.

### Changed
Expand Down
9 changes: 9 additions & 0 deletions docs/features/abstracts-and-inheritance.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ This means:
- **Forward references are fine.** A field that `extends: shortSlug` can
appear in a file the loader processes before the file that declares
`shortSlug`.
- **Dotted refs to inherited members resolve in any order.** A field
`extends: Owner.member` works even when `member` reaches `Owner` only
through `Owner`'s own `extends:`. Super-resolution wires the owner's chain on
demand before reading its effective members, so file/enumeration order never
changes the result (#188).
- **Multi-level chains work.** `Author extends BaseEntity extends Auditable`
flattens to one effective shape. Each level can add children or override
attrs.
Expand Down Expand Up @@ -375,6 +380,10 @@ inheritance chain.
extends B extends C, full chain flattening
- [`extends-cross-file`](../../fixtures/conformance/extends-cross-file/) — forward
reference across files
- [`extends-dotted-inherited-member-load-order`](../../fixtures/conformance/extends-dotted-inherited-member-load-order/)
— a dotted `extends:` to a member the owner reaches only through its own
`extends:` resolves regardless of file order (load-order-independent
super-resolution, #188)
- [`extends-abstract-base`](../../fixtures/conformance/extends-abstract-base/) —
abstract entities aren't instantiable; codegen skips them
- [`error-extends-nonexistent`](../../fixtures/conformance/error-extends-nonexistent/)
Expand Down
7 changes: 6 additions & 1 deletion docs/features/loaders.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,12 @@ name = author.field("name")
4. **Overlay merge.** Files sharing the same `package` + object `name` are merged.
Last-writer-wins on attribute conflicts; structural children accumulate.
5. **Super-resolve.** `extends:` is resolved after all files load (deferred, not
eager) so a child can extend a base declared in any other file.
eager) so a child can extend a base declared in any other file. Resolution is
**order-independent** — a pure function of the source set — so a dotted
`extends:` to a member the owner reaches through its OWN `extends:` (e.g.
`extends: Owner.member` where `Owner` inherits `member`) resolves regardless of
file enumeration order; the owner's chain is wired on demand before its members
are read (#188).
6. **Validate.** Per-node validation runs; reserved-attr collisions, missing
required attrs, bad enum members all surface here with `ERR_*` codes (see
[`fixtures/conformance/ERROR-CODES.json`](../../fixtures/conformance/ERROR-CODES.json)).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
{
"metadata.root": {
"package": "acme::shop",
"children": [
{
"object.projection": {
"name": "CustomerView",
"children": [
{
"source.rdb": {
"@kind": "view",
"@view": "v_customers"
}
},
{
"field.uuid": {
"name": "id",
"extends": "acme::shop::Customer.id",
"@required": true,
"children": [
{
"origin.passthrough": {
"@from": "acme::shop::Customer.id"
}
}
]
}
},
{
"field.string": {
"name": "name",
"children": [
{
"origin.passthrough": {
"@from": "acme::shop::Customer.name"
}
}
]
}
},
{
"identity.primary": {
"name": "pk",
"extends": "acme::shop::Customer.pk",
"@fields": [
"id"
]
}
}
]
}
},
{
"object.entity": {
"name": "Customer",
"extends": "acme::common::BaseEntity",
"children": [
{
"field.uuid": {
"name": "id",
"@required": true
}
},
{
"identity.primary": {
"name": "pk",
"@fields": [
"id"
]
}
},
{
"source.rdb": {
"@table": "customers"
}
},
{
"field.string": {
"name": "name",
"@required": true
}
}
]
}
},
{
"object.entity": {
"name": "BaseEntity",
"abstract": true,
"children": [
{
"field.uuid": {
"name": "id",
"@required": true
}
},
{
"identity.primary": {
"name": "pk",
"@fields": [
"id"
]
}
}
]
}
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
{
"metadata.root": {
"package": "acme::shop",
"children": [
{
"object.projection": {
"name": "CustomerView",
"children": [
{
"source.rdb": {
"@kind": "view",
"@view": "v_customers"
}
},
{
"field.uuid": {
"name": "id",
"extends": "acme::shop::Customer.id",
"children": [
{
"origin.passthrough": {
"@from": "acme::shop::Customer.id"
}
}
]
}
},
{
"field.string": {
"name": "name",
"children": [
{
"origin.passthrough": {
"@from": "acme::shop::Customer.name"
}
}
]
}
},
{
"identity.primary": {
"name": "pk",
"extends": "acme::shop::Customer.pk",
"@fields": [
"id"
]
}
}
]
}
},
{
"object.entity": {
"name": "Customer",
"extends": "acme::common::BaseEntity",
"children": [
{
"source.rdb": {
"@table": "customers"
}
},
{
"field.string": {
"name": "name",
"@required": true
}
}
]
}
},
{
"object.entity": {
"name": "BaseEntity",
"abstract": true,
"children": [
{
"field.uuid": {
"name": "id",
"@required": true
}
},
{
"identity.primary": {
"name": "pk",
"@fields": [
"id"
]
}
}
]
}
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"metadata.root": {
"package": "acme::shop",
"children": [
{
"object.projection": {
"name": "CustomerView",
"children": [
{ "source.rdb": { "@kind": "view", "@view": "v_customers" } },
{
"field.uuid": {
"name": "id",
"extends": "acme::shop::Customer.id",
"children": [
{ "origin.passthrough": { "@from": "acme::shop::Customer.id" } }
]
}
},
{
"field.string": {
"name": "name",
"children": [
{ "origin.passthrough": { "@from": "acme::shop::Customer.name" } }
]
}
},
{
"identity.primary": {
"name": "pk",
"extends": "acme::shop::Customer.pk",
"@fields": ["id"]
}
}
]
}
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"metadata.root": {
"package": "acme::shop",
"children": [
{
"object.entity": {
"name": "Customer",
"extends": "acme::common::BaseEntity",
"children": [
{ "source.rdb": { "@table": "customers" } },
{ "field.string": { "name": "name", "@required": true } }
]
}
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"metadata.root": {
"package": "acme::common",
"children": [
{
"object.entity": {
"name": "BaseEntity",
"abstract": true,
"children": [
{ "field.uuid": { "name": "id", "@required": true } },
{ "identity.primary": { "name": "pk", "@fields": ["id"] } }
]
}
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
["metaobjects-core-types", "metaobjects-db"]
Loading
Loading