Skip to content

Commit ba869c2

Browse files
dmealingclaude
andcommitted
fix(#214): Kotlin write-through — exclude derived value-object columns; document re-read scope
Cross-port divergence audit follow-ups for the #214 fan-out (Kotlin). - D3 (latent bug): KotlinExposedTableGenerator.buildObjectColumns iterated the object/map fields with no derived filter, and its output was emitted for BOTH the write `<Short>Table` and the read `<Short>View`, so a DERIVED (origin.*) `field.object` / `field.map` became a phantom column on the write table — declared but never written (create/patch skip derived) and never selected (reads use the view). Latent for a nullable derived VO; a @required one would break Exposed insert. Thread `excludeDerived` through buildObjectColumns (mirroring the scalar-column loop's existing guard) so the write table drops derived VO/map columns; the read view keeps them. Byte-identical for a vanilla entity and the view (the guard is a no-op without a derived VO field). - D1/D4 (documentation): the by-PK re-read keys on the SINGLE primary key (a composite-PK write-through entity is out of scope — this repository already skips composite PKs, matching the other ports) and uses Exposed `.single()`, which assumes the replica view surfaces the just-written row (true for a plain `@kind:view`; a `@kind:materializedView`/filtered replica throws — the data- oriented ports degrade to the write row). Both noted in the generator and in KNOWN_GAPS. The remaining audit findings are architecture-driven, not bugs: a guaranteed- non-null aggregate-derived field reads as nullable on the single-shape ports (Java/Kotlin) vs non-null on the two-shape ports (TS/C#/Python) — the documented cost of the shared read/create DTO; and Java's read→view routing lives in the consumer repository (SQL-free port, ADR-0015). Recorded, not changed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NGQ7oSuNcjhsMHWwZzhBwr
1 parent b905527 commit ba869c2

3 files changed

Lines changed: 31 additions & 1 deletion

File tree

server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KNOWN_GAPS.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,3 +81,18 @@ value → set). Notes:
8181
open-bag column untouched. A consumer needing to PATCH an open-bag column overrides
8282
the generated update handler. Typed value-object jsonb (`field.object`) is
8383
object-typed and separately out of the CRUD DTO scope.
84+
85+
## Write-through read-view re-read assumes a 1:1 replica (#214)
86+
87+
**Status:** documented limitation (a cross-port audit finding, D1).
88+
89+
A write-through entity's generated repository/controller re-reads a
90+
create/update through the replica view by primary key using Exposed
91+
`.single()`, which assumes the view surfaces the just-written row. That holds
92+
for a plain `@kind:view` replica (a live query over the write table). A
93+
`@kind:materializedView` (unrefreshed) or a filtered replica that does not
94+
surface the row makes `.single()` throw → HTTP 500, whereas the data-oriented
95+
ports (Python `ObjectManager`, C# routes) degrade to returning the write row
96+
(derived fields absent). A materialized/filtered replica on a write-through
97+
entity is an unusual shape; the graceful table-row fallback (a second, derived-
98+
free row mapper) is deferred until a real consumer needs it.

server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinExposedTableGenerator.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,7 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject
410410
// Views never carry a generated default (they inherit the underlying PK).
411411
val uuidGeneratedPk = primary?.isUuid == true && !isView && singlePrimaryFieldName != null
412412

413-
val objectColumns = buildObjectColumns(entity, primaryFieldSet, loader)
413+
val objectColumns = buildObjectColumns(entity, primaryFieldSet, loader, excludeDerivedFields)
414414
// A `field.string @dbColumnType=jsonb` open bag now decodes to a kotlinx `JsonElement`
415415
// (issue #98) via `{ Json.parseToJsonElement(it) }`, so its table file needs the
416416
// `kotlinx.serialization.json.Json` import too (that open bag is the ONLY column that still
@@ -706,9 +706,16 @@ open class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject
706706
entity: MetaObject,
707707
primaryFieldNames: Set<String>,
708708
loader: MetaDataLoader,
709+
// #214: on the WRITE table of a write-through entity, a DERIVED (origin.*) field.object /
710+
// field.map has no physical column (it is computed by the read view) — exclude it, exactly
711+
// as the scalar-column loop does. Without this a derived value-object column becomes a
712+
// phantom on the write `<Short>Table` (declared but never written/selected; a @required one
713+
// would break Exposed insert). Default false → byte-identical for a vanilla entity + the view.
714+
excludeDerived: Boolean = false,
709715
): List<ObjectColumnSpec> {
710716
val result = mutableListOf<ObjectColumnSpec>()
711717
for (field in entity.metaFields) {
718+
if (excludeDerived && KotlinGenUtil.isDerivedField(field)) continue
712719
// field.map → a single jsonb column (the JSON object). Keys are always strings;
713720
// never flattened, never a native array. Same JSONB emission as a jsonb-stored
714721
// field.object.

server/java/codegen-kotlin/src/main/kotlin/com/metaobjects/generator/kotlin/KotlinRepositoryGenerator.kt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,14 @@ open class KotlinRepositoryGenerator : MultiFileDirectGeneratorBase<MetaObject>(
168168
fun StringBuilder.appendInsertBody(preserve: Boolean) {
169169
// Writes target the write table; the by-PK read-back routes to the read object
170170
// (the view for a write-through entity) so the returned entity carries derived fields.
171+
// #214 cross-port notes: (D4) the re-read keys on the SINGLE primary key
172+
// ($pkFieldName) — a write-through entity with a composite PK is out of scope here
173+
// (this repository already skips composite-PK entities), matching the other ports'
174+
// single-PK re-read. (D1) the re-read uses `.single()` — it assumes the replica view
175+
// surfaces the just-written row, TRUE for a plain `@kind:view` (a live query over the
176+
// table). A `@kind:materializedView` (unrefreshed) or a filtered replica that does not
177+
// surface the row throws here; that is an unsupported write-through replica shape (the
178+
// data-oriented ports degrade to the table row instead) — a documented limitation.
171179
when {
172180
incrementPk -> {
173181
append(" val newId = $writeObj.insert {\n")

0 commit comments

Comments
 (0)