Skip to content

Commit 8fb10f5

Browse files
committed
fix(loader): inline object-valued attr loads as attr.properties in Python, C# and Java
An undeclared inline attribute whose value is a JSON object is the registered attr.properties bag, the same node the explicit attr.properties child form produces. TypeScript already loaded it that way; the other loaders each diverged differently: - Python loaded it as attr.base and failed strict mode with ERR_UNKNOWN_ATTR. - C# rejected the value with ERR_BAD_ATTR_VALUE. - Java (and Kotlin, which shares its loader) loaded it but stored the bag as a JSON-text string, so the canonical output differed. Each parser now materializes the bag at the inline-attribute site. Gated by the new shared attr-properties-inline conformance fixture. Nothing that loaded before stops loading; no new error codes or vocabulary.
1 parent e2456aa commit 8fb10f5

9 files changed

Lines changed: 110 additions & 6 deletions

File tree

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ PyPI has had no product change since `0.25.0` — nothing is broken.
7575
- **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.
7676

7777
**Cross-port conformance corpora** (every port runs the shared corpus):
78-
- Metamodel: `fixtures/conformance/` (361 fixtures; 25 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
78+
- Metamodel: `fixtures/conformance/` (362 fixtures; 25 shared corpora in total — per-corpus counts + the corpus x port matrix live in `docs/CONFORMANCE.md`). TS / C# / Java / Python all green.
7979
- Render: `fixtures/render-conformance/`. TS / C# / Java / Kotlin / Python byte-identical.
8080
- 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 **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".)
8181
- 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. Two sub-corpora run the **generated lane only, on all five ports** — `write-through/` and `projection/` (F22: a view-only `object.projection` serves GET list + GET by id and answers every write verb with `405 {"error": "method_not_allowed"}`). 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.

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,14 @@ it until 1.1 ships._
6464

6565
### Fixed
6666

67+
- **Python, C#, Java: an inline object-valued attribute now loads as an `attr.properties` bag.**
68+
`"@store": { "collection": "orders" }` on a node that declares no `@store` is the registered
69+
property bag, the same thing the explicit `{ "attr.properties": { "name": ..., "value": {...} } }`
70+
child produces. TypeScript always loaded it that way. Python loaded it as `attr.base` and
71+
failed a strict load with `ERR_UNKNOWN_ATTR`; C# rejected the value with `ERR_BAD_ATTR_VALUE`;
72+
Java (and so Kotlin) loaded it but stored the bag as a JSON-text string. All four loaders now
73+
agree, gated by the `attr-properties-inline` conformance fixture. Nothing that loaded before
74+
stops loading.
6775
- **Java OMDB reads a projection whose view is named by `@view`.** The read mapping took the
6876
view name from `@table` only, so a projection declared with the kind-matching `@view` alias
6977
had no read mapping. It now resolves the source's physical name (`@view`, then the legacy

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,7 @@ metaobjects/
261261
├── CLAUDE.md # project instructions for Claude
262262
├── spec/ # canonical metamodel docs, ADRs, roadmap
263263
├── fixtures/ # 25 cross-language conformance corpora — the oracle
264-
│ ├── conformance/ # metamodel (loader + serializer + navigation), 361 fixtures
264+
│ ├── conformance/ # metamodel (loader + serializer + navigation), 362 fixtures
265265
│ ├── yaml-conformance/ # YAML authoring desugar
266266
│ ├── render-conformance/ # FR-004 byte-identical render oracle
267267
│ ├── verify-conformance/ # FR-004 template-drift gate

‎docs/CONFORMANCE.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ regenerate with `ls -d fixtures/<corpus>/*/ | wc -l` for directory-shaped corpor
2626

2727
| Corpus | Fixtures | TS | Java | Kotlin | C# | Python |
2828
|---|---|---|---|---|---|---|
29-
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 361 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
29+
| [`fixtures/conformance/`](../fixtures/conformance/) (metamodel) | 362 | ✓ | ✓ | inherits via `metadata-ktx` | ✓ | ✓ |
3030
| [`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 |
3131
| [`fixtures/verify-conformance/`](../fixtures/verify-conformance/) | 31 | ✓ | ✓ | inherits via Java | ✓ | ✓ |
3232
| [`fixtures/verify-strict-conformance/`](../fixtures/verify-strict-conformance/) | 1 | ✓ | — | — | — | ✓ |
@@ -202,7 +202,7 @@ unit-test runners (`bun test`, `dotnet test`, `pytest`, `mvn test`) pull Docker.
202202

203203
## Fixture-to-doc mapping
204204

205-
### `fixtures/conformance/` — metamodel loader + canonical serializer (361)
205+
### `fixtures/conformance/` — metamodel loader + canonical serializer (362)
206206

207207
| Fixture prefix | Feature doc |
208208
|---|---|
@@ -397,7 +397,7 @@ own those two functions), and
397397

398398
## Orphaned fixtures (tested but not yet documented)
399399

400-
The fixtures in the nine corpora mapped above (metamodel 361 + yaml 16 + verify 31
400+
The fixtures in the nine corpora mapped above (metamodel 362 + yaml 16 + verify 31
401401
+ render 15 + persistence 39 + api-contract 61 + source-resolution 25 + scope 10 +
402402
dependency 23) each map to a feature doc. None are orphaned today. The remaining
403403
corpora in the totals table gate tooling contracts (registry manifests, provider
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"metadata.root": {
3+
"package": "acme",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Order",
8+
"@store": {
9+
"collection": "orders"
10+
},
11+
"children": [
12+
{
13+
"field.long": {
14+
"name": "id"
15+
}
16+
},
17+
{
18+
"identity.primary": {
19+
"name": "id",
20+
"@fields": [
21+
"id"
22+
]
23+
}
24+
}
25+
]
26+
}
27+
}
28+
]
29+
}
30+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"metadata.root": {
3+
"package": "acme",
4+
"children": [
5+
{
6+
"object.entity": {
7+
"name": "Order",
8+
"@store": { "collection": "orders" },
9+
"children": [
10+
{
11+
"field.long": {
12+
"name": "id"
13+
}
14+
},
15+
{
16+
"identity.primary": {
17+
"name": "id",
18+
"@fields": ["id"]
19+
}
20+
}
21+
]
22+
}
23+
}
24+
]
25+
}
26+
}

‎server/csharp/MetaObjects/Parser.cs‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1597,6 +1597,26 @@ private static void ApplyInlineAttrsAndUnknownKeys(
15971597
.AttrsOf(model.Type, model.SubType)
15981598
.FirstOrDefault(a => a.Name == attrName);
15991599

1600+
// An UNDECLARED @-attr whose value is a JSON object is the registered
1601+
// `attr.properties` bag — materialize it exactly as the explicit
1602+
// `{"attr.properties": {...}}` child form does (structural attr child +
1603+
// SetAttr map entry), so the strict-attr pass exempts it. Mirrors TS
1604+
// parser-core inferUndeclaredAttrSubType.
1605+
if (attrSpec is null
1606+
&& rawVal.ValueKind == JsonValueKind.Object
1607+
&& st.Registry.Find(TYPE_ATTR, ATTR_SUBTYPE_PROPERTIES) is { } bagDef)
1608+
{
1609+
object bag = DataConverter.ToAttrObject(rawVal);
1610+
MetaData bagModel = bagDef.Factory(bagDef.TypeId, attrName);
1611+
st.Builder.PushKey(key);
1612+
bagModel.SetSource(st.CurrentSource());
1613+
st.Builder.Pop();
1614+
bagModel.SetAttr(RESERVED_KEY_VALUE, bag);
1615+
model.AddChild(bagModel);
1616+
model.SetAttr(attrName, bag);
1617+
continue;
1618+
}
1619+
16001620
object? value;
16011621
try
16021622
{

‎server/java/metadata/src/main/java/com/metaobjects/loader/parser/json/CanonicalJsonParser.java‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1432,6 +1432,20 @@ private void processAttributes(MetaData md, JsonObject body) {
14321432
// This lets the base parser's existing [...] → comma-delimited → isArray=true
14331433
// path fire correctly, without any hardcoded attribute names.
14341434
JsonElement rawValue = entry.getValue();
1435+
// An UNDECLARED @-attr whose value is a JSON object is the registered
1436+
// attr.properties bag — materialize it as that subtype, exactly as the
1437+
// explicit {"attr.properties": {...}} child form does. The base parser's
1438+
// value-shape inference knows only scalars and arrays and would store the
1439+
// bag as a JSON-text STRING attr. Mirrors TS inferUndeclaredAttrSubType.
1440+
if (isExemptPropertiesAttr(rawValue) && !isDeclaredAttribute(md, attrName)) {
1441+
MetaAttribute<?> bag = (MetaAttribute<?>) getTypeRegistry().createInstance(
1442+
MetaAttribute.TYPE_ATTR,
1443+
com.metaobjects.attr.PropertiesAttribute.SUBTYPE_PROPERTIES, attrName);
1444+
md.addChild(bag);
1445+
bag.setValueAsString(jsonElementToString(rawValue));
1446+
tagAttrChildWithJsonSource(md, attrName);
1447+
continue;
1448+
}
14351449
// Cross-port: object-valued attrs (e.g. attr.filter) MUST be authored as
14361450
// a JSON object. The legacy form `@filter: "..."` (string-quoted JSON) is
14371451
// rejected here so the loader surfaces ERR_BAD_ATTR_VALUE before the

‎server/python/src/metaobjects/parser.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from typing import cast
66

77
from .errors import ErrorCode, MetaError
8-
from .meta.core.attr.attr_constants import ATTR_SUBTYPE_STRINGARRAY
8+
from .meta.core.attr.attr_constants import ATTR_SUBTYPE_PROPERTIES, ATTR_SUBTYPE_STRINGARRAY
99
from .meta.meta_data import MetaData
1010
from .meta.meta_root import MetaRoot
1111
from .naming_refs import REF_BEARING_ATTR_NAMES, is_relative_ref
@@ -460,6 +460,12 @@ def _build(
460460
if schema is not None and schema.is_array
461461
else (schema.value_type if schema else None)
462462
)
463+
# An UNDECLARED attr whose value is a JSON object is the registered
464+
# `attr.properties` bag, exactly as the explicit
465+
# `{"attr.properties": {...}}` child form loads it — so strict-attr
466+
# exempts it. Mirrors TS parser-core inferUndeclaredAttrSubType.
467+
if schema is None and isinstance(value, dict):
468+
attr_sub_type = ATTR_SUBTYPE_PROPERTIES
463469
node.set_attr(attr_name, value, sub_type=attr_sub_type)
464470
# FR5a / ADR-0009 — stamp the just-constructed MetaAttribute node with
465471
# its origin envelope. Mirrors C# Parser.cs:1039 (attrModel.SetSource).

0 commit comments

Comments
 (0)