Skip to content

Commit 01f7265

Browse files
dmealingclaude
andcommitted
fix(kotlin): a required generated key blocked every create; conflicts 500'd; int-backed enum filters lied
Three defects in the generated Kotlin controller tier, found by building an adopter estate's Kotlin backend from the published 8.0.5-rc.7 as the docs describe - each with every in-repo lane green. 1. Create. The data class is also the create @RequestBody, and a @required generated key made it a non-null ctor param with no default: jackson-module-kotlin rejected every body that (correctly) omitted it, so every create 400'd on the shape real models have (BaseEntity declares id required). An increment key hid it - a missing Long binds 0 - a UUID cannot. Behind it: the handler read a @generation: uuid key back from the insert, which Exposed returns only for a value it inserted or an autoIncrement column ("id is not in record set"). Now a server-owned-on- create field (generated key, @autoset column) is nullable, default null, no @field:NotNull - KotlinGenUtil.isServerOwnedOnCreate, mirroring Java's SERVER_OWNED_ON_CREATE - and the handler mints a uuid key itself, as the TS runtime does. 2. Conflicts. handleConstraintViolation was declared for RuntimeException; Exposed raises ExposedSQLException, a CHECKED java.sql.SQLException, so it never matched and every duplicate key / dangling reference was a 500. It handles java.sql.SQLException too. Nothing had ever run it. 3. Int-backed enum filters. Every enum was compared as TEXT against the member symbol; an @intValueMap column stores the integer, so eq/in matched nothing and ne everything, with a 200. Now compared through the enum-typed column (whose codec writes the integer). Docs: docs/ports/kotlin.md's install block could not build a running app - it now lists exposed-jdbc (runtime), exposed-java-time and exposed-json (the tables import them), and says the controller needs KotlinRelationsGenerator when the model has M:N. The config generator no longer claims a validator runs when validatorEnabled=false. Tests: UuidPkControllerRunTest now makes the uuid key @required, POSTs without it (201, generated id) and POSTs a duplicate unique name (409); EnumFilterControllerRunTest gains an int-backed enum (eq/ne/in/unknown). Both failed before the fix. codegen-kotlin 394/0 (3 snapshots updated), integration-tests-kotlin 161/0; the estate's Kotlin port 43/43 on this build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EaGv4zNk4oT7FooD4eK5MD
1 parent df72be5 commit 01f7265

11 files changed

Lines changed: 212 additions & 23 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,46 @@ edit (two registered `description` strings) and was ruled a hold, as 1.0.4's was
302302
reference server and the Python in-memory seam ignored the filter and returned every row,
303303
and the TS reference server's allowlist gave the discriminator no operators.
304304

305+
- **Kotlin: no POST could create an entity whose server-generated key is `@required` — and
306+
a uuid key could not be created at all.** The generated data class is also the create
307+
`@RequestBody`, and a `@required` key made it a non-null constructor parameter with no
308+
default, so jackson-module-kotlin rejected every body that (correctly) omitted it: HTTP 400
309+
on every create, for the shape every real model has (a shared `BaseEntity` declares `id`
310+
required). An `increment` key hid it — a missing `Long` binds 0 — but a `UUID` cannot.
311+
Behind that sat a second defect: the create handler read a `@generation: uuid` key back
312+
from the insert, and Exposed returns only a value it inserted or an autoIncrement column,
313+
so the key "is not in record set" on any driver that does not hand the database default
314+
back. Now a SERVER-OWNED-on-create field — a generated key, or an `@autoSet` column the
315+
handler stamps — is a nullable, default-null property with no `@field:NotNull` (the same
316+
relaxation a derived field on a write-through entity already had), and the create handler
317+
mints a uuid key itself, as the TypeScript runtime does. Rows read back always carry the
318+
key. **Kotlin adopters with committed generated code should run `mvn metaobjects:generate`**;
319+
code that read `entity.id` as non-null on such an entity now needs `!!` or a null check.
320+
Found by an adopter estate's Kotlin backend.
321+
322+
- **Kotlin: a database constraint violation answered 500, not the contract's 409.** The
323+
generated `handleConstraintViolation` was declared for `RuntimeException`, and Exposed raises
324+
a violation as `ExposedSQLException`, which extends the CHECKED `java.sql.SQLException` — so
325+
the handler never matched and Spring answered every duplicate key and dangling reference with
326+
a 500. It now handles `java.sql.SQLException` too. Nothing had run the handler: it existed
327+
only in snapshots. **Run `mvn metaobjects:generate`.**
328+
329+
- **Kotlin: filters on an int-backed enum (`@intValueMap`) returned the wrong rows with a
330+
200.** The generated filter compared every enum column as TEXT against the member symbol,
331+
which is right for a string-backed enum; an int-backed column stores the declared integer,
332+
so `filter[status][eq]=DELIVERED` compared `'30'` to `'DELIVERED'` — `eq` and `in` matched
333+
nothing and `ne` matched everything. The filter now compares through the enum-typed column,
334+
whose codec writes the declared integer; a symbol naming no member matches no row. **Run
335+
`mvn metaobjects:generate`.**
336+
337+
- **Kotlin docs: the documented install block could not build a running app.** The generated
338+
tables import `exposed-java-time` and `exposed-json`, and `Database.connect(...)` needs
339+
`exposed-jdbc` at runtime; `docs/ports/kotlin.md` listed only `exposed-core`. Its generator
340+
table now lists all three, and says the controller needs `KotlinRelationsGenerator` whenever
341+
the model has a M:N relationship (the traversal routes call helpers only that generator
342+
emits). A `KotlinSpringConfigGenerator` run with `validatorEnabled=false` no longer emits a
343+
KDoc claiming the validator runs.
344+
305345
- **Python runtime: a `field.currency` read from a view answered as a JSON STRING, not
306346
integer minor units.** A table's currency column is BIGINT, which the driver already hands
307347
back as an `int`. A view's need not be: `SUM` over BIGINT is Postgres `numeric`, read back

‎docs/ports/kotlin.md‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,25 @@ Two modules:
3939
<artifactId>exposed-core</artifactId>
4040
<version>${exposed.version}</version>
4141
</dependency>
42+
<!-- The generated tables import these two: instant (tz-aware timestamp) columns come from
43+
exposed-java-time, jsonb columns from exposed-json. exposed-jdbc is what
44+
`Database.connect(...)` needs at RUNTIME — without it the app starts and the first
45+
query fails. -->
46+
<dependency>
47+
<groupId>org.jetbrains.exposed</groupId>
48+
<artifactId>exposed-jdbc</artifactId>
49+
<version>${exposed.version}</version>
50+
</dependency>
51+
<dependency>
52+
<groupId>org.jetbrains.exposed</groupId>
53+
<artifactId>exposed-java-time</artifactId>
54+
<version>${exposed.version}</version>
55+
</dependency>
56+
<dependency>
57+
<groupId>org.jetbrains.exposed</groupId>
58+
<artifactId>exposed-json</artifactId>
59+
<version>${exposed.version}</version>
60+
</dependency>
4261
<!-- Generated typed `field.object @storage:jsonb` / `field.map` columns serialize through a
4362
generated per-package `MetaJsonbMapper.kt` Jackson `ObjectMapper` (no kotlinx-serialization
4463
compiler plugin required). -->
@@ -87,7 +106,7 @@ The 15 generators registered in `codegen-kotlin` (`GeneratorRegistry.kt`):
87106
| `KotlinValidatorGenerator` | `MetadataStartupValidator.kt` + `ExposedTableValidator.kt` | once per project |
88107
| `KotlinSpringConfigGenerator` | `MetadataExposedConfig.kt` — `@Configuration` wiring `Database.connect()` + auto-validator | once per project |
89108
| `KotlinStoredProcGenerator` | Stored-procedure call wrappers | entities with `source.rdb @kind="storedProc"` |
90-
| `KotlinSpringControllerGenerator` | `<Entity>Controller.kt` — Spring `@RestController` (5 CRUD endpoints; cross-port API contract) | entities with `source.rdb @kind="table"` |
109+
| `KotlinSpringControllerGenerator` | `<Entity>Controller.kt` — Spring `@RestController` (5 CRUD endpoints; cross-port API contract). **Select `KotlinRelationsGenerator` with it** when the model has a M:N relationship: the traversal routes call the `<rel>Query` helpers only that generator emits, and without it the controller does not compile | entities with `source.rdb @kind="table"` |
91110

92111
Maven wiring:
93112

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,16 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
198198
// An ASSIGNED primary key is create-REQUIRED whatever @required says — the
199199
// caller is its only source (KotlinGenUtil.isAssignedPrimaryKeyField), so the
200200
// property stays non-null and a create body omitting it cannot bind.
201-
val nullable = tphBase || derivedReadOnly ||
201+
// A SERVER-OWNED-on-create field — the key an increment/uuid identity generates, or an
202+
// @autoSet column the CRUD path stamps — is never the caller's to send, and this one data
203+
// class is also the create @RequestBody. As a non-null param with no default it fails
204+
// jackson-module-kotlin deserialization on a body that correctly omits it, so EVERY
205+
// create answered 400 on an entity whose BaseEntity declares `id` @required — the shape
206+
// real models have. (A missing `Long` silently binds 0, which is how an increment key
207+
// hid it; a `UUID` cannot.) Same relaxation as a derived field on a write-through
208+
// entity: nullable, default null, no @field:NotNull. Rows read back always carry it.
209+
val serverOwned = KotlinGenUtil.isServerOwnedOnCreate(field, obj)
210+
val nullable = tphBase || derivedReadOnly || serverOwned ||
202211
(!KotlinGenUtil.isRequiredField(field) && !KotlinGenUtil.originGuaranteedNonNull(field)
203212
&& !KotlinGenUtil.isAssignedPrimaryKeyField(field))
204213
val propType = if (nullable) baseType.copy(nullable = true) else baseType
@@ -208,7 +217,7 @@ open class KotlinEntityGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
208217
.build()
209218
ctorBuilder.addParameter(param)
210219
val propBuilder = PropertySpec.builder(propName, propType).initializer(propName)
211-
if (!tphBase && !derivedReadOnly) {
220+
if (!tphBase && !derivedReadOnly && !serverOwned) {
212221
for (annotation in validationAnnotations(field)) {
213222
propBuilder.addAnnotation(annotation)
214223
}

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,31 @@ public object KotlinGenUtil {
634634
return !field.hasMetaAttr(MetaField.ATTR_DEFAULT)
635635
}
636636

637+
/**
638+
* True when [field] is SERVER-OWNED on create for [obj]: a component of [obj]'s
639+
* `increment`/`uuid` primary key (the store generates it), or an `@autoSet` column (the
640+
* CRUD path stamps it, issue #203). The caller never supplies either, so a create body
641+
* that omits one is correct — the complement of [isAssignedPrimaryKeyField]. Judged
642+
* against the entity being generated, not `field.parent`: a key idiomatically lives on
643+
* an abstract base and is inherited through `extends`.
644+
*
645+
* Mirrors the Java port's `SERVER_OWNED_ON_CREATE` set (SpringControllerGenerator).
646+
*/
647+
fun isServerOwnedOnCreate(field: MetaField<*>, obj: MetaObject): Boolean {
648+
val pk = obj.primaryIdentity
649+
if (pk != null && pk.isAutoGenerated && pk.fields.contains(field.name)) return true
650+
return autoSetPolicy(field) != null
651+
}
652+
653+
/**
654+
* True when [field] is a `field.enum` stored as its declared INTEGER (`@intValueMap`) rather
655+
* than its member symbol. Read resolving (ADR-0039) — the same test the Exposed table
656+
* generator applies when it emits the INTEGER `customEnumeration` column, so the column and
657+
* the filter that reads it cannot disagree.
658+
*/
659+
fun isIntBackedEnum(field: MetaField<*>): Boolean =
660+
field is com.metaobjects.field.EnumField && field.hasMetaAttr(com.metaobjects.field.EnumField.ATTR_INT_VALUE_MAP)
661+
637662
fun isRequiredField(field: MetaField<*>): Boolean {
638663
if (!field.hasMetaAttr(MetaField.ATTR_REQUIRED, true)) return false
639664
val raw = runCatching { field.getMetaAttr(MetaField.ATTR_REQUIRED, true).value }.getOrNull()

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

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,10 +60,17 @@ open class KotlinSpringConfigGenerator : MultiFileDirectGeneratorBase<MetaObject
6060

6161
val typeBuilder = TypeSpec.classBuilder(className)
6262
.addKdoc(
63-
"GENERATED — wires Exposed's `Database.connect()` from the Spring " +
64-
"[DataSource] bean\nand runs [MetadataStartupValidator.validate] at " +
65-
"app startup.\n\nIf you don't want the validator auto-call, set " +
66-
"`metaobjects.validator.enabled=false`.\n"
63+
if (validatorEnabled) {
64+
"GENERATED — wires Exposed's `Database.connect()` from the Spring " +
65+
"[DataSource] bean\nand runs [MetadataStartupValidator.validate] at " +
66+
"app startup.\n\nIf you don't want the validator auto-call, set " +
67+
"`metaobjects.validator.enabled=false`.\n"
68+
} else {
69+
// Generated with validatorEnabled=false: no validator hook is emitted, so the
70+
// KDoc must not say one runs.
71+
"GENERATED — wires Exposed's `Database.connect()` from the Spring " +
72+
"[DataSource] bean.\n"
73+
}
6774
)
6875
.addAnnotation(CONFIGURATION)
6976
.primaryConstructor(

0 commit comments

Comments
 (0)