Skip to content

Commit de91c18

Browse files
dmealingclaude
andcommitted
merge: adopter codegen defects found on 1.0.4 (C#, Java, CLI)
Five fixes found by build-testing the published 1.0.4 toolchain, the same stream that found the two TypeScript projection-view defects already on main. Merged ahead of the release candidate so the cross-port arm tests FIXED ports rather than re-finding defects already closed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JGmCLHbQKuLBPjjhWC43jk
2 parents b854bd5 + fecc13a commit de91c18

21 files changed

Lines changed: 1712 additions & 40 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,133 @@ edit (two registered `description` strings) and was ruled a hold, as 1.0.4's was
9090
nested ref names the hop that broke (*"'app::Task.title' has no member 'display'."*). The
9191
did-you-mean hint still appears when the object itself does not resolve.
9292

93+
- **C#: a `field.decimal` reached through a nested object generated code that did not
94+
compile.** `ExtractDelegateEmitter.ScalarReader` — the reader map for the runtime-DELEGATING
95+
extract mirror — had no `Decimal` branch, so a decimal fell through to the `DlgString`
96+
default while `Fr010FieldMapping.ScalarMirrorType` typed the same mirror property `decimal?`:
97+
`error CS0029: Cannot implicitly convert type 'string' to 'decimal?'`. A decimal **array**
98+
broke the same way with `CS0266` (`IReadOnlyList<string?>` → `IReadOnlyList<decimal?>`),
99+
because the delegating mirror kind-types array elements through `ScalarMirrorType` too. Only
100+
the delegating path was affected — that is the path for a field reached through a nested
101+
object or an array-of-objects, so a flat top-level payload went through the self-contained
102+
reader, which always had its Decimal branch. Fixed by adding the branch plus a `DlgDecimal`
103+
helper mirroring `ExtractMap.AsDecimal`'s never-throws contract (an out-of-range double
104+
degrades to null rather than throwing `OverflowException` mid-parse, which is the point of a
105+
*lenient* extract). `ScalarMirrorType`'s doc-comment had claimed the self-contained and
106+
delegating mirrors were "in lock-step" with nothing testing it; a new gate now compiles
107+
generated output for **every** scalar subtype in `FIELD_SUBTYPES`, in both the single and the
108+
array position, so a newly registered subtype cannot quietly take a mismatched default.
109+
110+
- **C#: the generated `AppDbContext` failed to compile when an entity name collided with a
111+
`DbSet` property name.** The reference-FK configuration named its column through
112+
`nameof(<Owner>.<Prop>)`, an expression sitting inside the `DbContext` class body — where C#
113+
simple-name lookup binds `<Owner>` to a *member* of the context before it considers a type of
114+
the same name, and the context declares one `DbSet` per entity. Reachable from stock
115+
metadata, not just exotic input: `Pluralize("Address") == "Addresses"`, so a model carrying
116+
both an `Address` and an `Addresses` entity emitted `nameof(Addresses.AddressId)`, bound
117+
`Addresses` to `DbSet<Address>`, and failed with `CS1061`. Now emitted as a typed lambda —
118+
`.HasForeignKey(e => e.AddressId)`, or `.HasForeignKey(e => new { e.OrgId, e.SiteId })` for a
119+
composite — whose parameter is local and therefore unshadowable, and which unlike a bare
120+
string literal keeps the property name compile-checked. The M:N `UsingEntity<Through>(...)`
121+
sides carried the identical hazard and were converted too.
122+
123+
- **`meta init --refresh-docs` no longer reports a scaffold it did not perform.** On an
124+
established project the refresh-only path did the right thing — agent context refreshed in
125+
place, metadata, `.metaobjects/config.json`, `codegen/generators/` and
126+
`metaobjects.config.ts` all left alone, nothing created — but then printed the full first-run
127+
banner: "Initialized metaobjects/ + .metaobjects/ + metaobjects.config.ts", "Codegen
128+
generators copied to codegen/generators/", and next-steps telling you to set
129+
`"type": "module"`. It read as though the command had just scaffolded over your project. The
130+
output now reports the refresh and states what was left untouched. `--refresh-docs` on a
131+
repo that is *not* yet initialized still falls through to a full init and still prints the
132+
scaffold banner, so the branch keys off what actually happened rather than off the flag.
133+
134+
- **Java: the generated extract mapper did not compile for most scalar subtypes.** A
135+
responding `template.prompt` types its `<Template>Response` record through
136+
`SpringTypeMapper.javaTypeName`, which is richly kind-typed (`BigDecimal`, `LocalDate`,
137+
`LocalTime`, `Instant`, `UUID`, `URI`, `InetAddress`, `Float`, `Long` for currency) — while
138+
`SpringOutputParserGenerator`'s mapper recognised only Integer/Long/Double/Boolean and fell
139+
through to `ExtractMap.asString` for everything else, with **every** scalar array going to
140+
`asStringList`. Each mismatch is `incompatible types` at compile time: **9 of 15 scalar
141+
subtypes as a single component, 13 of 15 as an array**. It shipped because no test payload in
142+
the module carried a decimal, date, time, timestamp, currency, uuid, uri or inet — and
143+
because javac reports only the FIRST bad argument of a constructor call, so even a fixture
144+
with several would have looked like one defect (which is exactly how it was first reported,
145+
as a decimal-only bug). The record stays strictly typed per ADR-0052; the generated parser now
146+
coerces on the way in, with never-throws helpers emitted only where used. A new per-subtype
147+
gate compiles generated output for every subtype in both positions.
148+
149+
- **Java: `MetaObjectExtractor.assemble` violated its own documented never-throws contract.**
150+
Its javadoc promises the assembled object comes back with malformed data already classified
151+
into the report and no exception — but the per-field write goes through `DataConverter`, which
152+
throws on a value it cannot convert. One unconvertible component therefore destroyed the
153+
entire extract, the opposite of the lenient tier's purpose: a model answering `"31/12/2026"`
154+
for one date should cost you that date, not the twenty fields beside it that parsed. The
155+
component is now left unset, which is what "lost" already means everywhere else in that pass.
156+
157+
- **Java: an unparseable decimal became `0` instead of null.** `DataConverter.toBigDecimal`
158+
caught the `NumberFormatException` and returned `BigDecimal.ZERO` — the one wrong answer
159+
available, because `0` is a plausible amount, so unreadable input reached a money field as a
160+
real-looking value with nothing to distinguish it from a genuine zero. Every sibling
161+
converter (`toInt` / `toLong` / `toDouble` / `toFloat`) throws for the same input; decimal
162+
was the lone exception. It now throws too, and the lenient extract tier's never-throws guard
163+
turns that into a lost component — so the answer there is `null`, which is the honest one.
164+
165+
- **Java/Kotlin: a declared `field.decimal @isArray` was silently dropped.** `DataTypes` had no
166+
`DECIMAL_ARRAY`, and `arrayTypeFor(DECIMAL)` mapped decimal to ITSELF with a comment saying
167+
arrays-of-decimal "aren't in the metamodel" so the declaration would "degrade gracefully". It
168+
did not degrade gracefully: the loader accepts the declaration, all five ports generate code
169+
for it, and C# populates it correctly (now pinned by value in
170+
`Fr010DelegatingMirrorLockStepTests`) — so this was a JVM gap presented as a design position,
171+
not a metamodel rule. Added `DECIMAL_ARRAY` plus a `toBigDecimalArray` that element-converts
172+
through `toBigDecimal` rather than a double, so precision survives the round trip. The new
173+
member takes id 22 rather than a slot beside `DECIMAL=10`: those ids are persisted
174+
identifiers, and renumbering the existing members to keep the enum tidy would silently
175+
repoint stored data. Kotlin inherits the fix — it consumes the same `metadata` module — and
176+
C#, TypeScript and Python needed no change (that conversion table is JVM-only; TS binds
177+
`field.decimal` to `string` and Python to `Decimal`).
178+
179+
- **Java: `DataConverter.toDate` could not parse an ISO-8601 date.** The `String` arm was a bare
180+
`Long.parseLong`, so it accepted epoch milliseconds and nothing else — a plain `"2026-03-04"`
181+
threw `NumberFormatException`. Since that is the form `normalization.md` puts on the wire, no
182+
`field.date` or `field.timestamp` could be assembled from its own wire representation. It now
183+
accepts epoch millis (tried first, and only for an all-digit string, so every value that
184+
parsed before still parses identically) plus ISO instant, zone-less date-time, and date-only
185+
forms. Unparseable input still throws, preserving the failure mode for callers outside the
186+
lenient tier.
187+
188+
### Added
189+
190+
- **C#: `DbContextGenerator.EmitsReferenceForeignKeys`, an opt-out for the reference-FK
191+
configuration.** The navigation-less `HasOne<Target>()` overload that configuration uses is
192+
correct *because* the stock entity generator emits no reference navigations (ADR-0038
193+
replaced reverse navigation with explicit FK finders) — a premise this generator cannot
194+
detect, and one that does not hold for an adopter who substitutes their own entity generator
195+
and emits navigations. There, EF's conventions discover those navigations and build their own
196+
relationship over the same FK column, and a navigation-less config claiming that column
197+
leaves the convention-built one unable to identify its dependent ("The dependent side could
198+
not be determined for the one-to-one relationship between 'X.Y' and 'Y.X'") — failing model
199+
validation, which takes down every query in the application rather than just that
200+
relationship. Override the property to `false` and configure those relationships yourself;
201+
nothing else the generator emits changes. Documented in
202+
[`relationships.md`](docs/features/relationships.md).
203+
204+
- **The extract engine's `FieldKind` vocabulary is now gated across ports**
205+
(`scripts/check-extract-field-kinds.mjs`, `gates` lane). Every port's extract engine runs
206+
`fixtures/extract-conformance/`, and that corpus's `schema.json` `kind` values ARE this
207+
vocabulary — so a port that adds or drops a kind changes what the shared corpus can express
208+
while every existing fixture keeps passing. The drift is invisible by construction, and it had
209+
already happened: C# carries a `Decimal` kind no other port has and no fixture exercises. The
210+
gate reads each port's real definition rather than a copied list (an unrecognised declaration
211+
shape fails loudly instead of reporting an empty set, which would read as "no drift"), and it
212+
fails if Kotlin ever grows its own `FieldKind` — it ships no extract engine, driving the shared
213+
Java one. Deviations pass only when
214+
[`expected-field-kinds.json`](fixtures/extract-conformance/expected-field-kinds.json) records
215+
them with a reason; the C# one is recorded there, including why deleting it is not the
216+
one-liner it looks like (`ExtractMap.AsDecimal` has no string arm, so an engine that stopped
217+
producing `decimal` would silently return null for every decimal). A recorded deviation that
218+
is no longer true also fails, so the record cannot go stale.
219+
93220
## [1.0.4] — 2026-09-14
94221

95222
_All four registries publish: npm `1.0.4` (full lockstep across all 14 `@metaobjectsdev/*`

‎docs/features/relationships.md‎

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -393,16 +393,46 @@ KEY ... ON DELETE CASCADE` in the Postgres DDL.
393393
```csharp
394394
// generated/AppDbContext.cs (excerpt)
395395
modelBuilder.Entity<Post>().HasOne<Author>().WithMany()
396-
.HasForeignKey(nameof(Post.AuthorId)).OnDelete(DeleteBehavior.Cascade);
396+
.HasForeignKey(e => e.AuthorId).OnDelete(DeleteBehavior.Cascade);
397397
```
398398

399+
The FK property is named by a typed lambda, not `nameof(Post.AuthorId)`. This line sits
400+
inside the generated `DbContext` class body, where C# simple-name lookup binds `Post` to a
401+
*member* of the context before it considers the type — and the context declares one `DbSet`
402+
property per entity. So an entity whose type name equals any `DbSet` property name made
403+
`nameof` read the `DbSet` and fail to compile (`CS1061`), which stock metadata can reach:
404+
`Pluralize("Address") == "Addresses"`, so a model with both an `Address` and an `Addresses`
405+
entity broke. A lambda parameter is local, so nothing can shadow it, and unlike a bare string
406+
literal it keeps the property name compile-checked. A composite reference emits
407+
`.HasForeignKey(e => new { e.OrgId, e.SiteId })`.
408+
399409
The action rides on the call that establishes the foreign key, never a later
400410
`GetForeignKeys(...)` mutation: EF Core reconciles TPH relationships *after*
401411
`OnModelCreating` returns and can replace the foreign-key metadata object, which
402412
silently discards a post-hoc assignment ([#294](https://github.com/metaobjectsdev/metaobjects/issues/294)).
403413
`WithMany()` is inverse-less because the port emits no reverse collection
404414
navigations at all — reverse traversal is the explicit FK finders of ADR-0038.
405415

416+
That premise is load-bearing, and it is worth stating as a premise: the
417+
navigation-**less** `HasOne<Target>()` overload is correct *because* the stock entity
418+
generator emits no reference navigations. If you substitute your own entity generator and it
419+
**does** emit navigation properties, EF's conventions discover them and build their own
420+
relationship over the same FK column — and this navigation-less configuration claiming that
421+
column leaves the convention-built one unable to identify its dependent
422+
(`The dependent side could not be determined for the one-to-one relationship between 'X.Y'
423+
and 'Y.X'`). That fails model validation, and a failed model takes down every query in the
424+
application, not just the one relationship. The generator cannot detect the situation, so it
425+
is an opt-out: override `EmitsReferenceForeignKeys` to `false` on `DbContextGenerator` and
426+
configure those relationships yourself against the navigations only you know about. Nothing
427+
else the generator emits is affected.
428+
429+
```csharp
430+
sealed class AppDbContextGenerator : DbContextGenerator
431+
{
432+
protected override bool EmitsReferenceForeignKeys => false;
433+
}
434+
```
435+
406436
`@onUpdate` has no EF Core representation (`DeleteBehavior` covers deletes only),
407437
so it stays a DDL-level fact emitted by the TypeScript-owned migration engine.
408438

‎fixtures/extract-conformance/README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@ is byte-identical across ports; raw coercion is not required to be.
1818
Each `fields[]` entry: `name`, `kind` (`STRING|INT|LONG|DOUBLE|BOOLEAN|ENUM|OBJECT`),
1919
`required` (bool). Kind-specific keys:
2020

21+
> **The `kind` vocabulary is a cross-port contract, and it is now gated.** Those seven values
22+
> are the coercion targets every port's extract engine understands, so a port that adds or
23+
> drops one changes what this corpus can express while every existing case keeps passing —
24+
> invisible drift by construction. It had already happened: C# carries a `Decimal` kind no
25+
> other port has and no fixture exercises. The machine-readable set lives in
26+
> [`expected-field-kinds.json`](expected-field-kinds.json), checked by
27+
> `scripts/check-extract-field-kinds.mjs` in `ci-local.sh`'s `gates` lane, which reads each
28+
> port's real definition rather than a copied list. Deviations pass only when that file
29+
> records them with a stated reason; the C# one is recorded there along with why it was not
30+
> simply deleted. Kotlin has no entry on purpose — it ships no extract engine, driving the
31+
> shared Java one through `metadata-ktx`, so a Kotlin `FieldKind` would itself be the drift,
32+
> and the gate fails if one appears.
33+
2134
- `INT|LONG|DOUBLE` — optional `min` / `max` (clamp range).
2235
- `ENUM` — `enumValues` (member symbols) plus the FR-011 coercion-pipeline keys:
2336
- `enumAlias` — `{ synonym: MEMBER }` (FR-010); keys matched under the field's mode.
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
{
2+
"$comment": [
3+
"The extract engine's coercion-target vocabulary (FieldKind), pinned across ports.",
4+
"This is the machine-readable form of the `kind` values documented in README.md ->",
5+
"'schema.json field keys'. Every port's extract engine runs the same corpus, so a port",
6+
"that quietly adds or drops a kind changes what that corpus can even express.",
7+
"",
8+
"It was unpinned until now, and it had already drifted: C# carries a Decimal kind that",
9+
"no other port has and no fixture exercises. Nothing failed, because nothing looked.",
10+
"Gated by scripts/check-extract-field-kinds.mjs in ci-local.sh's `gates` lane."
11+
],
12+
"canonical": ["STRING", "INT", "LONG", "DOUBLE", "BOOLEAN", "ENUM", "OBJECT"],
13+
"ports": {
14+
"java": "server/java/render/src/main/java/com/metaobjects/render/extract/FieldKind.java",
15+
"csharp": "server/csharp/MetaObjects.Render/Extract/FieldKind.cs",
16+
"typescript": "server/typescript/packages/render/src/extract/types.ts",
17+
"python": "server/python/src/metaobjects/render/extract/types.py"
18+
},
19+
"kotlinReusesJava": true,
20+
"$kotlinNote": "Kotlin ships no extract engine of its own — metadata-ktx drives the shared Java one, so there is no Kotlin FieldKind to check and adding one would itself be the drift.",
21+
"sanctionedDeviations": {
22+
"csharp": {
23+
"extra": ["DECIMAL"],
24+
"missing": [],
25+
"recordedOn": "2026-09-13",
26+
"reason": [
27+
"C#'s extract engine coerces a field.decimal to a native `decimal` through this kind,",
28+
"and MetaObjects.Render's ExtractMap.AsDecimal reads it. It is NOT dead vocabulary.",
29+
"",
30+
"It is a deviation rather than the canonical shape because the outcome it buys does not",
31+
"require an engine kind: Java reaches the same place (a typed BigDecimal on the response",
32+
"record) by leaving the engine's STRING coercion alone and converting in GENERATED code.",
33+
"That keeps the engine's vocabulary small and puts the port's native-type choice at the",
34+
"port boundary, which is where ADR-0019 says it belongs.",
35+
"",
36+
"Not removed here because removal is not the one-line change it looks like: AsDecimal has",
37+
"no string arm, so an engine that stopped producing `decimal` would make it return null",
38+
"for EVERY decimal — silently. Reconciling properly means either propagating DECIMAL to",
39+
"the other four engines or moving C# onto the generated-coercion pattern, and either is a",
40+
"cross-port behaviour change that wants its own spec.",
41+
"",
42+
"This entry exists so the divergence is a reviewed decision with a stated cost, not an",
43+
"accident nobody can see. Anything NOT listed here fails the gate."
44+
]
45+
}
46+
}
47+
}

0 commit comments

Comments
 (0)