Skip to content

Commit da7ee92

Browse files
dmealingclaude
andcommitted
fix(codegen-kotlin): snake_case column names + uuid/timestamp type overrides
KotlinExposedTableGenerator was emitting Exposed column declarations with the YAML field name verbatim as the column-name string argument, producing varchar("displayName", ...) when Postgres convention is snake_case (display_name). Downstream adopters whose schema follows that convention hit "column does not exist" at query time. Also: field.timestamp defaulted to timestampWithTimeZone which is the rarer Postgres variant; many schemas use plain timestamp. And there was no way to declare a varchar field as a Postgres uuid column. Fixes: - camelToSnake helper converts column-name argument (Kotlin property name stays camelCase; only the SQL column string is snake_case'd) - field.string + @dbColumnType=uuid -> Exposed uuid(...) column type (Kotlin property stays String; Exposed coerces at the SQL boundary) - field.timestamp default -> plain timestamp(...) (not timestampWithTimeZone) - field.timestamp + @dbColumnType=timestamp_with_tz -> opts in to TZ-aware Snake-case conversion is applied uniformly across all column-emission paths: regular field columns, enumerationByName columns, flattened object sub-columns, jsonb columns, declared FK columns (from relationship.composition), inferred FK columns (from inverse many-side), and identity.reference-decorated columns. Regression tests cover each: camelToSnake helper unit tests (8 cases incl. acronyms, digits, idempotency), per-column-emission path tests, and an opt-in timestampWithTimeZone test. Snapshot fixtures updated where they exercised the affected default mappings (4 snapshots: bidirectional-fk PostTable, fk PostTable, view SalesReportTable, primitives AuthorTable). Tests: 104 pass (up from 98; added 6 new tests). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 695d1f3 commit da7ee92

10 files changed

Lines changed: 375 additions & 52 deletions

File tree

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

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -198,10 +198,12 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
198198
// field.enum → typed Exposed enumerationByName column referencing the
199199
// generated enum class. Length matches the historical VARCHAR fallback
200200
// (KotlinTypeMapper.ENUM_VARCHAR_LEN). Same-package class reference, so
201-
// no import is required.
201+
// no import is required. Column name is snake_case-d for Postgres
202+
// convention (matches the StringField/varchar path).
202203
val enumName = KotlinTypeMapper.enumTypeName(field, entity)?.simpleName
203204
?: error("enumTypeName returned null for EnumField '${field.name}' on ${entity.name}")
204-
"enumerationByName(\"${field.name}\", ${KotlinTypeMapper.ENUM_VARCHAR_LEN}, $enumName::class)"
205+
val colName = KotlinGenUtil.camelToSnake(field.name)
206+
"enumerationByName(\"$colName\", ${KotlinTypeMapper.ENUM_VARCHAR_LEN}, $enumName::class)"
205207
} else {
206208
KotlinTypeMapper.exposedColumnSpec(field)
207209
}
@@ -277,7 +279,10 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
277279
val target = KotlinGenUtil.resolveObjectByShortOrFqn(loader, ref) ?: continue
278280
for (subField in target.metaFields) {
279281
val propertyName = parentName + subField.name.replaceFirstChar { it.uppercase() }
280-
val colName = parentName + "_" + subField.name
282+
// Physical column name: snake-join parent + sub-field, both snake_case-d.
283+
// E.g. parent "homeAddress" + sub "streetLine1" → "home_address_street_line1".
284+
val colName = KotlinGenUtil.camelToSnake(parentName) + "_" +
285+
KotlinGenUtil.camelToSnake(subField.name)
281286
val baseSpec = KotlinTypeMapper.exposedColumnSpec(subField, colName)
282287
// Sub-column is nullable iff the parent is nullable OR the sub-field itself is.
283288
val nullable = parentNullable || !KotlinGenUtil.isRequiredField(subField)
@@ -286,7 +291,9 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
286291
}
287292
} else {
288293
// jsonb (explicit) OR absent (default per CLAUDE.md back-compat rule).
289-
val expr = "jsonb(\"$parentName\", { Json.encodeToString(it) }, { Json.decodeFromString(it) })"
294+
// Physical column name snake_case-d to match the rest of the column emission.
295+
val colName = KotlinGenUtil.camelToSnake(parentName)
296+
val expr = "jsonb(\"$colName\", { Json.encodeToString(it) }, { Json.decodeFromString(it) })"
290297
val full = if (parentNullable) "$expr.nullable()" else expr
291298
result.add(ObjectColumnSpec(parentName, full, ObjectColumnKind.JSONB))
292299
}
@@ -441,10 +448,11 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
441448
val targetTable = PackageMapping.splitFqn(target.name).second + "Table"
442449
val relShortName = rel.shortName ?: rel.name
443450
val propertyName = readColumnAttr(rel) ?: (relShortName + "Id")
451+
val colName = KotlinGenUtil.camelToSnake(propertyName)
444452
val refSuffix = referentialActionSuffix(rel.onDeleteRaw, rel.onUpdateRaw)
445453
return FkColumnSpec(
446454
propertyName = propertyName,
447-
columnExpr = "long(\"$propertyName\").references($targetTable.id$refSuffix)",
455+
columnExpr = "long(\"$colName\").references($targetTable.id$refSuffix)",
448456
refSuffix = refSuffix,
449457
declared = true,
450458
targetTable = targetTable,
@@ -467,10 +475,11 @@ class KotlinExposedTableGenerator : MultiFileDirectGeneratorBase<MetaObject>() {
467475
val ownerShort = PackageMapping.splitFqn(owner.name).second
468476
val ownerTable = ownerShort + "Table"
469477
val propertyName = ownerShort.replaceFirstChar { it.lowercaseChar() } + "Id"
478+
val colName = KotlinGenUtil.camelToSnake(propertyName)
470479
val refSuffix = referentialActionSuffix(rel.onDeleteRaw, rel.onUpdateRaw)
471480
return FkColumnSpec(
472481
propertyName = propertyName,
473-
columnExpr = "long(\"$propertyName\").references($ownerTable.id$refSuffix)",
482+
columnExpr = "long(\"$colName\").references($ownerTable.id$refSuffix)",
474483
refSuffix = refSuffix,
475484
declared = false,
476485
targetTable = ownerTable,

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,4 +46,43 @@ internal object KotlinGenUtil {
4646
else -> false
4747
}
4848
}
49+
50+
/**
51+
* Convert a camelCase identifier to snake_case for use as a physical SQL column name.
52+
*
53+
* Used by [KotlinExposedTableGenerator] so the column-name string argument matches the
54+
* snake_case convention nearly every Postgres schema uses, while the Kotlin property name
55+
* stays camelCase (Kotlin convention). Examples:
56+
* ```
57+
* camelToSnake("displayName") == "display_name"
58+
* camelToSnake("htmlContent") == "html_content"
59+
* camelToSnake("id") == "id"
60+
* camelToSnake("userId") == "user_id"
61+
* camelToSnake("URLPath") == "url_path" // leading run of caps treated as one word
62+
* ```
63+
*
64+
* The algorithm inserts `_` before any uppercase letter that is preceded by either a
65+
* lowercase letter OR by another uppercase letter immediately followed by a lowercase
66+
* letter (the second rule splits "URLPath" into "url_path" rather than "u_r_l_path").
67+
* The whole result is then lowercased. Non-ASCII letters are passed through unchanged.
68+
*/
69+
fun camelToSnake(name: String): String {
70+
if (name.isEmpty()) return name
71+
val sb = StringBuilder(name.length + 4)
72+
for (i in name.indices) {
73+
val c = name[i]
74+
if (i > 0 && c.isUpperCase()) {
75+
val prev = name[i - 1]
76+
val next = if (i + 1 < name.length) name[i + 1] else null
77+
// Insert underscore between [lower|digit][Upper] (standard camelCase boundary)
78+
// OR between [Upper][Upper][lower] (acronym → word boundary, e.g. URLPath → URL_Path)
79+
if (prev.isLowerCase() || prev.isDigit() ||
80+
(prev.isUpperCase() && next != null && next.isLowerCase())) {
81+
sb.append('_')
82+
}
83+
}
84+
sb.append(c.lowercaseChar())
85+
}
86+
return sb.toString()
87+
}
4988
}

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

Lines changed: 78 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,28 @@ object KotlinTypeMapper {
5959
/** Attribute name read off a [StringField] to dispatch to `text(name)` (kind=text). */
6060
private const val ATTR_KIND = "kind"
6161

62+
/**
63+
* Attribute name read off any field to override the default Exposed column type.
64+
* Recognised values (case-insensitive):
65+
* - `uuid` (on [StringField]) — emit Exposed `uuid("col")` instead of `varchar("col", N)`.
66+
* Postgres maps this to the native `uuid` column type. Kotlin data class property type
67+
* stays `String` for minimum-change today (Exposed coerces String ↔ uuid at the SQL
68+
* boundary). A future enhancement can promote to a typed `UUID` Kotlin property.
69+
* - `timestamp_with_tz` (on [TimestampField]) — emit Exposed `timestampWithTimeZone("col")`
70+
* (Postgres `timestamp with time zone`). Opt-in: the default for `field.timestamp` is
71+
* plain `timestamp("col")` (Postgres `timestamp without time zone`) because that is the
72+
* more common shape in real schemas; TZ-aware is the rarer specialisation.
73+
*
74+
* Unknown values fall through to the default mapping for the field type.
75+
*/
76+
private const val ATTR_DB_COLUMN_TYPE = "dbColumnType"
77+
78+
/** `@dbColumnType` value on [StringField] that selects Exposed `uuid("col")`. */
79+
private const val DB_COLUMN_TYPE_UUID = "uuid"
80+
81+
/** `@dbColumnType` value on [TimestampField] that opts in to Exposed `timestampWithTimeZone("col")`. */
82+
private const val DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ = "timestamp_with_tz"
83+
6284
/**
6385
* Compute the generated Kotlin enum-class name for an [EnumField] hung off [entity].
6486
*
@@ -110,8 +132,16 @@ object KotlinTypeMapper {
110132
)
111133
}
112134

113-
/** Map a MetaField to the Exposed `Table` column statement (e.g., `varchar("name", 100)`). */
114-
fun exposedColumnSpec(field: MetaField<*>): String = exposedColumnSpec(field, field.name)
135+
/**
136+
* Map a MetaField to the Exposed `Table` column statement (e.g., `varchar("name", 100)`).
137+
*
138+
* The default physical column name is [field.name] snake_case-d (Postgres convention:
139+
* `displayName` → `display_name`). Callers needing a verbatim column name (or a custom
140+
* one — e.g., the flattened `@storage` path that prefix-joins parent + sub field) use
141+
* the two-arg overload [exposedColumnSpec] and pass the column name explicitly.
142+
*/
143+
fun exposedColumnSpec(field: MetaField<*>): String =
144+
exposedColumnSpec(field, KotlinGenUtil.camelToSnake(field.name))
115145

116146
/**
117147
* Return the fully-qualified import required for the Exposed column function this
@@ -130,7 +160,15 @@ object KotlinTypeMapper {
130160
*/
131161
fun exposedColumnImport(field: MetaField<*>): String? = when (field) {
132162
is DateField -> "org.jetbrains.exposed.sql.javatime.date"
133-
is TimestampField -> "org.jetbrains.exposed.sql.javatime.timestampWithTimeZone"
163+
// Default for field.timestamp is plain `timestamp(...)` (Postgres `timestamp
164+
// without time zone` — the more common shape). Opt-in `@dbColumnType=timestamp_with_tz`
165+
// switches to `timestampWithTimeZone(...)` (Postgres `timestamp with time zone`).
166+
is TimestampField -> {
167+
if (timestampWithTzOptIn(field))
168+
"org.jetbrains.exposed.sql.javatime.timestampWithTimeZone"
169+
else
170+
"org.jetbrains.exposed.sql.javatime.timestamp"
171+
}
134172
// StringField, IntegerField, LongField, DoubleField, BooleanField, CurrencyField,
135173
// EnumField, and UUID-subtype fields all map to member functions on Table.
136174
// No additional import required.
@@ -144,21 +182,35 @@ object KotlinTypeMapper {
144182
*/
145183
fun exposedColumnSpec(field: MetaField<*>, colName: String): String = when (field) {
146184
is StringField -> {
147-
// Dispatch to Exposed `text(name)` when the field is declared as unbounded text:
148-
// (1) explicit `@kind: "text"` opt-in, OR
149-
// (2) `@maxLength` exceeds the VARCHAR/TEXT cutoff (Postgres TOAST boundary).
150-
// Otherwise emit `varchar(name, N)` with N defaulting to 255.
151-
val kind = stringAttr(field, ATTR_KIND)
152-
val maxLen = stringMaxLength(field)
153-
if (kind == KIND_TEXT || maxLen > VARCHAR_TEXT_THRESHOLD) "text(\"$colName\")"
154-
else "varchar(\"$colName\", $maxLen)"
185+
// `@dbColumnType=uuid` opt-in: emit `uuid("col")` instead of varchar. The Kotlin
186+
// data class property stays `String` for now (Exposed coerces String ↔ uuid at
187+
// the SQL boundary), so adopters can convert a string-shaped FK column to the
188+
// native Postgres uuid type without changing their data class shape.
189+
if (dbColumnType(field) == DB_COLUMN_TYPE_UUID) {
190+
"uuid(\"$colName\")"
191+
} else {
192+
// Dispatch to Exposed `text(name)` when the field is declared as unbounded text:
193+
// (1) explicit `@kind: "text"` opt-in, OR
194+
// (2) `@maxLength` exceeds the VARCHAR/TEXT cutoff (Postgres TOAST boundary).
195+
// Otherwise emit `varchar(name, N)` with N defaulting to 255.
196+
val kind = stringAttr(field, ATTR_KIND)
197+
val maxLen = stringMaxLength(field)
198+
if (kind == KIND_TEXT || maxLen > VARCHAR_TEXT_THRESHOLD) "text(\"$colName\")"
199+
else "varchar(\"$colName\", $maxLen)"
200+
}
155201
}
156202
is IntegerField -> "integer(\"$colName\")"
157203
is LongField -> "long(\"$colName\")"
158204
is DoubleField -> "double(\"$colName\")"
159205
is BooleanField -> "bool(\"$colName\")"
160206
is DateField -> "date(\"$colName\")"
161-
is TimestampField -> "timestampWithTimeZone(\"$colName\")"
207+
// Default for field.timestamp is plain `timestamp(...)` — Postgres `timestamp
208+
// without time zone` is the more common shape. Opt in to TZ-aware via
209+
// `@dbColumnType=timestamp_with_tz`.
210+
is TimestampField -> {
211+
if (timestampWithTzOptIn(field)) "timestampWithTimeZone(\"$colName\")"
212+
else "timestamp(\"$colName\")"
213+
}
162214
// Currency stored as BIGINT minor units — same as Long. Separate arm for
163215
// semantic clarity (a future migration generator can branch on it).
164216
is CurrencyField -> "long(\"$colName\")"
@@ -174,6 +226,20 @@ object KotlinTypeMapper {
174226
)
175227
}
176228

229+
/**
230+
* Read the `@dbColumnType` attribute (own-only, case-folded) for column-type overrides.
231+
* Returns null when absent. See [ATTR_DB_COLUMN_TYPE] for recognised values.
232+
*/
233+
private fun dbColumnType(field: MetaField<*>): String? =
234+
stringAttr(field, ATTR_DB_COLUMN_TYPE)?.lowercase()
235+
236+
/**
237+
* True iff [field] carries `@dbColumnType=timestamp_with_tz` (case-insensitive).
238+
* Centralised so the type spec and the import set stay in lockstep — both call this.
239+
*/
240+
private fun timestampWithTzOptIn(field: MetaField<*>): Boolean =
241+
dbColumnType(field) == DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ
242+
177243
/**
178244
* Best-effort read of a named string attribute (own-only) on [field]. Returns null when
179245
* the attribute is absent, throws during lookup, or isn't a [com.metaobjects.attr.MetaAttribute].

0 commit comments

Comments
 (0)