Skip to content

Commit 46d8e54

Browse files
dmealingclaude
andcommitted
feat(codegen-kotlin): generated Spring controller stamps @autoset (ADR-0045, #203)
The shipped Kotlin generated Spring controller did its own inline Exposed writes with zero @autoset awareness — so an adopter deploying it silently lost the timestamp-stamping FR (#203 shipped it only in KotlinRepositoryGenerator, which the controller never uses). Surfaced while scoping the cross-port @autoset gate. ADR-0045 records the durable rule: the generated API surface (the controller/routes an adopter deploys) must itself guarantee every metamodel write semantic — no consumer-supplied seam may sit between the guarantee and the wire. Persistence-layer stamping stays as defense-in-depth for non-HTTP writes. KotlinSpringControllerGenerator now: - insert stamps BOTH onCreate + onUpdate columns from ONE captured now()-val per temporal type (a fresh row's createdAt == updatedAt exactly), ignoring the caller; - @autoset columns are excluded from the PATCH-settable set (a caller can no longer overwrite createdAt/updatedAt); - a patch bumps every onUpdate column on EVERY request (even an empty body) and never rewrites onCreate; - byte-identical output for entities with no @autoset field (snapshot suite 14/14). nowExpr hoisted to KotlinTypeMapper (the type->"Type.now()" mapping's natural home; no circular dep, unlike KotlinGenUtil). New controller-level @autoset tests in KotlinAutoSetStampingTest (11/11). TPH+@autoset controller stamping is a documented follow-up (not exercised by the corpus; no existing test covers it). This is the Kotlin leg of the ADR-0045 rollout; the Python router leg and the shared api-contract @autoset gate follow (no scenario lands here, so no lane reds). Refs #203, #229. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XeGSV3StPCcJGZNNJ4ZfAb
1 parent e34d371 commit 46d8e54

4 files changed

Lines changed: 156 additions & 2 deletions

File tree

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

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,33 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
179179
(it !is ObjectField || isJsonbObjectColumn(it)) &&
180180
// #214: a DERIVED (origin.*) field on a write-through entity has no column on the
181181
// write table — it is computed by the read view — so it is never a write/patch target.
182-
!(writeThrough && KotlinGenUtil.isDerivedField(it))
182+
!(writeThrough && KotlinGenUtil.isDerivedField(it)) &&
183+
// #203/ADR-0045: an @autoSet column is server-owned — the generated controller
184+
// stamps it (the API surface owns the write semantic); a caller cannot set it via PATCH.
185+
!KotlinGenUtil.isAutoSetField(it)
183186
}
184187
val hasPatchFields = patchSettableFields.isNotEmpty()
185188

189+
// #203/ADR-0045: @autoSet columns are stamped by the generated controller, not bound from
190+
// the caller. insert stamps BOTH onCreate+onUpdate (a fresh row's updatedAt == createdAt —
191+
// captured from ONE now() per temporal type so they are exactly equal); a patch re-stamps
192+
// onUpdate on EVERY write (even an empty body) and never rewrites onCreate. An @autoSet
193+
// field is a real write-table column (never derived), so only the PK / write-through-derived
194+
// guards apply.
195+
val insertAutoSetFields = entity.metaFields.filter {
196+
KotlinGenUtil.isAutoSetField(it) && it.name != pkFieldName &&
197+
!(writeThrough && KotlinGenUtil.isDerivedField(it))
198+
}
199+
val onUpdateAutoSetFields = insertAutoSetFields.filter {
200+
KotlinGenUtil.autoSetPolicy(it) == KotlinGenUtil.AUTO_SET_ON_UPDATE
201+
}
202+
// One captured now()-val per distinct temporal type, so all same-type @autoSet columns in a
203+
// single insert receive the identical value (createdAt == updatedAt exactly).
204+
val insertNowVal = LinkedHashMap<String, String>() // nowExpr → local val name
205+
insertAutoSetFields.forEach { f ->
206+
insertNowVal.getOrPut(KotlinTypeMapper.nowExpr(f)) { "autoSetNow${insertNowVal.size}" }
207+
}
208+
186209
// Sort allowlist: every scalar field is sortable. Skip ObjectField (no SQL column
187210
// surface on the Exposed Table; @storage controls a separate column shape) and the
188211
// `field.string @dbColumnType=jsonb` open bag — a JSONB value is not a scalar sort
@@ -398,6 +421,8 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
398421
append(" @PostMapping\n")
399422
append(" fun create(@RequestBody dto: ${shortName}): ResponseEntity<Any> = transaction {\n")
400423
append(" if (validator.validate(dto).isNotEmpty()) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n")
424+
// #203/ADR-0045: capture one now() per temporal type so createdAt == updatedAt exactly.
425+
for ((expr, valName) in insertNowVal) append(" val $valName = $expr\n")
401426
append(" val newId = ${tableObjectName}.insert {\n")
402427
for (field in entity.metaFields) {
403428
// Program D: a field.object value-object jsonb column IS written on create — the
@@ -412,6 +437,12 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
412437
// #214: a DERIVED (origin.*) field on a write-through entity has no column on the
413438
// write table (computed by the view), so it is never written on create.
414439
if (writeThrough && KotlinGenUtil.isDerivedField(field)) continue
440+
// #203/ADR-0045: insert stamps onCreate+onUpdate @autoSet columns from the captured
441+
// now() (the caller's value is ignored — never bound from the dto).
442+
if (KotlinGenUtil.isAutoSetField(field)) {
443+
append(" it[${field.name}] = ${insertNowVal[KotlinTypeMapper.nowExpr(field)]}\n")
444+
continue
445+
}
415446
// NOTE: a `field.string @dbColumnType=jsonb` open bag IS written here (create
416447
// binds it from the @Valid DTO's kotlinx JsonElement property, gated by the
417448
// jsonb-open-bag-roundtrip corpus). PATCH cannot (the raw-JsonNode path can't bind
@@ -449,9 +480,13 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
449480
append(" if (body.has(\"${field.name}\") && body.get(\"${field.name}\").isNull) return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n")
450481
}
451482
}
483+
val mustStampOnUpdate = onUpdateAutoSetFields.isNotEmpty()
452484
if (hasPatchFields) {
453485
val settableNamesList = patchSettableFields.joinToString(", ") { "\"${it.name}\"" }
454-
append(" if (listOf($settableNamesList).any { body.has(it) }) {\n")
486+
// #203/ADR-0045: with onUpdate @autoSet columns the update must run on EVERY patch
487+
// (to bump them) — no "any present" guard; otherwise the guard keeps the update out
488+
// when the caller sent no settable field (byte-identical to the pre-#203 output).
489+
if (!mustStampOnUpdate) append(" if (listOf($settableNamesList).any { body.has(it) }) {\n")
455490
// A present value that cannot bind to its column's Kotlin type (e.g. a JSON object
456491
// for a String column) throws from treeToValue — map that to 400, not a 500. Catch
457492
// the specific Jackson JsonMappingException so no unrelated exception is swallowed.
@@ -502,6 +537,11 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
502537
// Apply the already-bound + validated values. Columns are qualified (`Table.col`)
503538
// so a field named like the `body`/`id` params can't shadow them.
504539
append(" ${tableObjectName}.update({ ${tableObjectName}.${pkFieldName} eq id }) {\n")
540+
// #203/ADR-0045: bump every onUpdate @autoSet column first (server-owned); onCreate
541+
// columns are never touched on update (createdAt is immutable).
542+
for (field in onUpdateAutoSetFields) {
543+
append(" it[${tableObjectName}.${field.name}] = ${KotlinTypeMapper.nowExpr(field)}\n")
544+
}
505545
for (field in patchSettableFields) {
506546
val fn = field.name
507547
val cap = capitalizeFirst(fn)
@@ -516,6 +556,14 @@ open class KotlinSpringControllerGenerator : MultiFileDirectGeneratorBase<MetaOb
516556
append(" } catch (e: com.fasterxml.jackson.databind.JsonMappingException) {\n")
517557
append(" return@transaction ResponseEntity.badRequest().body(mapOf(\"error\" to \"validation\") as Any)\n")
518558
append(" }\n")
559+
// Close the "any present" guard only when it was opened (see mustStampOnUpdate above).
560+
if (!mustStampOnUpdate) append(" }\n")
561+
} else if (mustStampOnUpdate) {
562+
// No caller-settable columns, but onUpdate @autoSet must still bump on every patch.
563+
append(" ${tableObjectName}.update({ ${tableObjectName}.${pkFieldName} eq id }) {\n")
564+
for (field in onUpdateAutoSetFields) {
565+
append(" it[${tableObjectName}.${field.name}] = ${KotlinTypeMapper.nowExpr(field)}\n")
566+
}
519567
append(" }\n")
520568
}
521569
// #214: read the (possibly-updated) row back through the READ object so the response

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,4 +654,18 @@ object KotlinTypeMapper {
654654
else -> null
655655
}
656656
}
657+
658+
/**
659+
* The `now()` expression for [field]'s temporal COLUMN type (issue #203 / ADR-0045) — keyed
660+
* off the column type so it generalizes across every temporal subtype: `field.timestamp`
661+
* (default) → `java.time.Instant.now()`, `@localTime` → `java.time.LocalDateTime.now()`,
662+
* `field.date` → `java.time.LocalDate.now()`, `field.time` → `java.time.LocalTime.now()`. Each
663+
* of those `java.time` types exposes a static `now()`. Fully-qualified so no import bookkeeping
664+
* is needed (the four types would otherwise collide on simple names across generated files).
665+
*/
666+
fun nowExpr(field: com.metaobjects.field.MetaField<*>): String {
667+
val tn = kotlinTypeName(field)
668+
val fqn = (tn as? ClassName)?.canonicalName ?: tn.toString()
669+
return "$fqn.now()"
670+
}
657671
}

server/java/codegen-kotlin/src/test/kotlin/com/metaobjects/generator/kotlin/KotlinAutoSetStampingTest.kt

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,56 @@ class KotlinAutoSetStampingTest {
170170
"a date-only @autoSet must not emit Instant.now(); saw:\n$src")
171171
}
172172

173+
// === #203 / ADR-0045 — the generated Spring CONTROLLER (the deployed API surface) must ALSO
174+
// === stamp @autoSet, not just the repository: the controller does its own inline Exposed writes
175+
// === and never delegates to the repository, so an adopter deploying it must still get stamping.
176+
177+
private fun generateController(name: String, fixture: String, relPath: String): String {
178+
val outDir = Files.createTempDirectory("kautoset-ctl-")
179+
try {
180+
KotlinSpringControllerGenerator().apply { setArgs(mapOf("outputDir" to outDir.toString())) }
181+
.execute(loadString(name, fixture))
182+
val f = outDir.resolve(relPath)
183+
assertTrue(Files.exists(f), "expected generated file $f; files=${Files.walk(outDir).toList()}")
184+
return Files.readString(f)
185+
} finally {
186+
outDir.toFile().deleteRecursively()
187+
}
188+
}
189+
190+
@Test fun `controller insert stamps both @autoSet columns from one captured now (createdAt == updatedAt)`() {
191+
val src = generateController("autoset-event-ctl", autoSetFixture, "acme/events/EventController.kt")
192+
// ONE captured now() val, assigned to BOTH columns → a fresh row's createdAt == updatedAt exactly.
193+
assertTrue("val autoSetNow0 = java.time.Instant.now()" in src,
194+
"insert must capture one now() val per temporal type; saw:\n$src")
195+
assertTrue("it[createdAt] = autoSetNow0" in src && "it[updatedAt] = autoSetNow0" in src,
196+
"insert must stamp BOTH @autoSet columns from the captured val; saw:\n$src")
197+
assertTrue("dto.createdAt" !in src && "dto.updatedAt" !in src,
198+
"an @autoSet column must never be bound from the caller dto; saw:\n$src")
199+
}
200+
201+
@Test fun `controller patch bumps onUpdate on every patch and never rewrites onCreate`() {
202+
val src = generateController("autoset-event-ctl2", autoSetFixture, "acme/events/EventController.kt")
203+
assertTrue("it[EventTable.updatedAt] = java.time.Instant.now()" in src,
204+
"patch must bump the onUpdate column; saw:\n$src")
205+
assertTrue("it[EventTable.createdAt]" !in src,
206+
"patch/update must never touch the onCreate column createdAt; saw:\n$src")
207+
// onUpdate present ⇒ the update runs on EVERY patch (no "any present" guard).
208+
assertTrue(".any { body.has(it) }" !in src,
209+
"an onUpdate @autoSet controller must not gate the update behind an any-present check; saw:\n$src")
210+
// @autoSet columns are not caller-settable in PATCH (never bound from the body).
211+
assertTrue("body.get(\"updatedAt\")" !in src && "body.get(\"createdAt\")" !in src,
212+
"an @autoSet column must not be a PATCH-bindable field; saw:\n$src")
213+
}
214+
215+
@Test fun `a non-@autoSet controller stays byte-identical - no stamping, keeps the patch guard`() {
216+
val src = generateController("autoset-note-ctl", autoSetFixture, "acme/events/NoteController.kt")
217+
assertTrue("autoSetNow" !in src, "a non-@autoSet controller must not capture a now() val; saw:\n$src")
218+
assertTrue(".now()" !in src, "a non-@autoSet controller must not stamp now(); saw:\n$src")
219+
assertTrue(".any { body.has(it) }" in src,
220+
"a non-@autoSet controller keeps the any-present patch guard; saw:\n$src")
221+
}
222+
173223
@Test fun `the generated repository (with entity + table) compiles against Exposed`() {
174224
val outDir = Files.createTempDirectory("kautoset-cr-")
175225
try {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# ADR-0045: The generated API surface owns metamodel write semantics
2+
3+
## Status
4+
5+
**Accepted** (2026-07-23). Fixes the Kotlin + Python legs of `field.timestamp @autoSet` (issue #203 follow-up, #229): the shipped Kotlin generated Spring controller and Python generated FastAPI router did not stamp `@autoSet` timestamps, so an adopter deploying those artifacts silently lost a semantic the changelog says shipped in all five ports. Generalizes the FR-036 decision ("constraint validation must be enforced at the wire tier, not left decorative") from validation to *all* metamodel-guaranteed write semantics.
6+
7+
## Context
8+
9+
`field.timestamp @autoSet: onCreate|onUpdate` declares "**the generated CRUD stamps `now()` — the caller does not.**" #203 shipped this across all five ports, but a scoping pass for the cross-port parity gate (#229) found the stamping is implemented in a **different architectural layer per port**, and two ports' outermost generated write artifact does not honor it:
10+
11+
| Port | `@autoSet` stamped in | Generated **API-surface** artifact guarantees it? |
12+
|---|---|---|
13+
| TS | codegen Zod validators, called by the emitted routes ||
14+
| C# | generated routes (`RoutesGenerator`) ||
15+
| Java | generated controller/DTO (`stampForInsert` / `stampAutoSetOnUpdate`), *then* delegates to the consumer repository seam ||
16+
| Python | `ObjectManager` runtime only — the generated router delegates raw DTOs to a consumer repository seam | ❌ guarantee lives *below* the seam, in a MetaObjects runtime package the adopter may not wire |
17+
| Kotlin | generated Exposed **repository** only — the generated controller does its own inline `transaction { Table.insert{} }` writes, never uses the repository, has zero `@autoSet` awareness | ❌ shipped controller does not stamp |
18+
19+
The generated **API surface** (the REST controller/routes an adopter deploys) is the outermost write artifact. When the `@autoSet` guarantee lives *below* a consumer-supplied seam (Python's repository `Protocol`; Kotlin's ignored repository), a metamodel-guaranteed semantic silently becomes a property of **hand-written consumer code** — a direct violation of "pattern-derivable from metadata = codegen, never hand-code." It also breaks the substrate promise: Python's only shipped stamping is in `ObjectManager` (a MetaObjects runtime package), so an adopter who wires their own persistence into the generated router gets a deployed API that quietly drops a shipped FR. "Generated code runs without MetaObjects" must include running *with the declared semantics* without MetaObjects.
20+
21+
The gap survived because **no `@autoSet` scenario existed in `api-contract-conformance` or `persistence-conformance`** — the wire tier was never gated for it. This is the same class of hole FR-036 closed for constraint validation (decorative in C#/Python until the wire tier was made to enforce it).
22+
23+
## Decision
24+
25+
**The outermost generated write-path artifact an adopter deploys must itself guarantee every metamodel-declared write semantic. No consumer-supplied seam may sit between the guarantee and the wire.**
26+
27+
1. **API surface stamps.** A generated REST controller / route handler stamps `@autoSet` (and enforces the analogous FR-036 constraint checks) in the generated code, before any consumer-supplied persistence seam. Java is the canonical shape: stamp in the controller/DTO, *then* delegate to the repository — layering and semantic-ownership are orthogonal.
28+
2. **Persistence-layer stamping stays** as the carrier for non-HTTP writes (the runtime pillar) and as defense-in-depth. Kotlin's generated repository, Python's `ObjectManager`, and any ORM-tier stamping are retained. Double-stamping is idempotent-harmless (two `now()` reads, same semantics). The rule is **not** "only the API layer stamps"; it is "the generated API artifact never *depends* on an unspecified layer beneath it for a metamodel-guaranteed write semantic."
29+
3. **Conformance gates the wire tier.** The `@autoSet` write contract is gated in `api-contract-conformance` on every port's generated *and* reference lane, with **behavioral** assertions (a fresh insert's `createdAt == updatedAt`; a later update leaves `createdAt` and bumps `updatedAt`; caller-supplied `@autoSet` values are ignored) — field-vs-field comparisons, not exact-match, since `now()` is nondeterministic. The absence of such an assertion vocabulary is what let a non-stamping controller ship green.
30+
4. **Not breaking, no new vocabulary.** `@autoSet` is an existing registered attr; this changes only *where* the guarantee is enforced in generated output. Output for entities with no `@autoSet` field is byte-identical (pinned by no-churn tests).
31+
32+
### Why not the alternatives
33+
34+
- **Rewire the Kotlin controller to delegate to the generated repository** (so the repository's stamping applies): rejected as the vehicle for this fix. The generated controller already handles TPH, #214 write-through read-views, M:N traversal, and FR-009 filters; the generated repository is FR-035 Phase-2 work with explicit gaps (no TPH polymorphic surface, no read-only view repository). Routing the controller through it is a feature-parity project that would churn all generated output and block a semantics bug on Phase-2 completion. It may be reconsidered later on its own merits; it is not this decision.
35+
- **Leave stamping persistence-only + document it**: rejected — the shipped artifact violates a contract the changelog states shipped in all five ports. That is a bug, not a documentable choice.
36+
- **Move *all* ports to persistence-only stamping** (make Python/Kotlin the model): rejected — it inverts the substrate promise (the deployable artifact would depend on a specific persistence layer for a declared semantic) and diverges from TS/C#/Java, where the API surface already owns it.
37+
38+
### Precedent alignment
39+
40+
- **FR-036** (constraint validation must be enforced at the wire tier across all ports) is the same call for the sibling write semantic; `@autoSet` and constraint-validation even interlock (every port's insert schema already makes an `@autoSet @required` field optional-on-POST *because* the server fills it).
41+
- **ADR-0023** (strict provenance) is unaffected: no new vocabulary, purely where a generator enforces an existing attr.
42+
- Ships as a coordinated bug-fix change (Kotlin controller + Python router/model + the api-contract `@autoSet` gate), landed together so no lane reds — per the repo's "never commit a scenario ports can't pass" rule.

0 commit comments

Comments
 (0)