Skip to content

Commit 9a655a3

Browse files
dmealingclaude
andcommitted
feat(codegen-kotlin): emit text() for field.string with dbColumnType=jsonb
The Kotlin Exposed table generator was treating `@dbColumnType=jsonb` on a `field.string` as a no-op — same fall-through as any other unknown override — so the column emitted as `varchar(name, 255)` even when the adopter clearly wanted Postgres JSONB on the DB side. The 255-char default cap then silently truncates any non-trivial JSON payload (rubric weights, feature flags, config blobs) that exceeds it. Add a `DB_COLUMN_TYPE_JSONB` constant and a `when (dbColumnType(field))` branch in the `StringField` arm of `KotlinTypeMapper.exposedColumnSpec` that emits `text("col")` instead. Rationale: * The DB column is `JSONB`. Postgres accepts text I/O against JSONB, so a `text` column on the Exposed side reads + writes without conversion. * `text` is unbounded — no accidental truncation on JSON payloads larger than the varchar default. * The Kotlin data class property stays `String` (raw JSON text). The application is responsible for ensuring well-formed JSON; Postgres validates at write time. This is the **raw-string** JSONB path for `field.string`. The typed-object JSONB path on `field.object` (`@storage=jsonb`) is unchanged and continues to emit `jsonb(name, encoder, decoder)` with kotlinx.serialization. Tests cover the three failure shapes of the old behaviour: 1. plain `dbColumnType=jsonb` now emits `text(...)` not `varchar(...)`. 2. lookup is case-folded (`"JSONB"` and `"jsonb"` both route to text). 3. an explicit `maxLength` doesn't override — JSONB wins, the column is unbounded regardless. Adopters with existing `varchar(255)` JSONB columns in their schema need no migration: the DB column shape doesn't change, only the Exposed-side mapping. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent cdb09af commit 9a655a3

2 files changed

Lines changed: 64 additions & 11 deletions

File tree

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

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,22 @@ object KotlinTypeMapper {
7878
/** `@dbColumnType` value on [StringField] that selects Exposed `uuid("col")`. */
7979
private const val DB_COLUMN_TYPE_UUID = "uuid"
8080

81+
/**
82+
* `@dbColumnType` value on [StringField] that emits a `text("col")` column
83+
* intended for a Postgres `JSONB`-typed column on the DB side. The Kotlin
84+
* property stays `String` (raw JSON text); the application is responsible
85+
* for ensuring well-formed JSON and Postgres validates at write time.
86+
* Using `text` (rather than `varchar(255)` default) removes the
87+
* accidental length cap on serialised payloads — JSON blobs routinely
88+
* exceed 255 chars (e.g. rubric weights, feature flags, configuration).
89+
*
90+
* Note: this is the **raw-string** JSONB path for `field.string`. The
91+
* typed-object JSONB path on `field.object` (`@storage=jsonb`) lives in
92+
* [KotlinExposedTableGenerator]'s object-column emission and uses
93+
* `jsonb("col", encoder, decoder)` with kotlinx.serialization.
94+
*/
95+
private const val DB_COLUMN_TYPE_JSONB = "jsonb"
96+
8197
/** `@dbColumnType` value on [TimestampField] that opts in to Exposed `timestampWithTimeZone("col")`. */
8298
private const val DB_COLUMN_TYPE_TIMESTAMP_WITH_TZ = "timestamp_with_tz"
8399

@@ -186,17 +202,25 @@ object KotlinTypeMapper {
186202
// data class property stays `String` for now (Exposed coerces String ↔ uuid at
187203
// the SQL boundary), so adopters can convert a string-shaped FK column to the
188204
// 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)"
205+
//
206+
// `@dbColumnType=jsonb` opt-in: emit `text("col")` instead of varchar. The DB
207+
// column is `JSONB` (Postgres accepts text I/O against JSONB), and the Exposed
208+
// `text` column has no length cap so JSON payloads larger than 255 chars
209+
// (rubric weights, feature flags, configuration blobs) don't trip the
210+
// varchar-default limit. The Kotlin property stays `String` (raw JSON text).
211+
when (dbColumnType(field)) {
212+
DB_COLUMN_TYPE_UUID -> "uuid(\"$colName\")"
213+
DB_COLUMN_TYPE_JSONB -> "text(\"$colName\")"
214+
else -> {
215+
// Dispatch to Exposed `text(name)` when the field is declared as unbounded text:
216+
// (1) explicit `@kind: "text"` opt-in, OR
217+
// (2) `@maxLength` exceeds the VARCHAR/TEXT cutoff (Postgres TOAST boundary).
218+
// Otherwise emit `varchar(name, N)` with N defaulting to 255.
219+
val kind = stringAttr(field, ATTR_KIND)
220+
val maxLen = stringMaxLength(field)
221+
if (kind == KIND_TEXT || maxLen > VARCHAR_TEXT_THRESHOLD) "text(\"$colName\")"
222+
else "varchar(\"$colName\", $maxLen)"
223+
}
200224
}
201225
}
202226
is IntegerField -> "integer(\"$colName\")"

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

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,35 @@ class KotlinTypeMapperTest {
144144
assertEquals(STRING, KotlinTypeMapper.kotlinTypeName(f))
145145
}
146146

147+
@Test fun `string field with dbColumnType=jsonb emits text column not capped varchar`() {
148+
// `@dbColumnType=jsonb` on a `field.string` opts the column to Postgres JSONB on
149+
// the DB side. The Exposed column is `text(...)` (unbounded) rather than the
150+
// default `varchar(name, 255)` — JSON payloads routinely exceed 255 chars and
151+
// Postgres accepts text I/O against a JSONB column. Kotlin property stays String
152+
// (raw JSON text); the application is responsible for validity.
153+
val f = StringField("rubricWeights")
154+
f.addMetaAttr(StringAttribute.create("dbColumnType", "jsonb"))
155+
assertEquals("text(\"rubric_weights\")", KotlinTypeMapper.exposedColumnSpec(f))
156+
assertEquals(STRING, KotlinTypeMapper.kotlinTypeName(f))
157+
}
158+
159+
@Test fun `string field with dbColumnType=jsonb is case-insensitive`() {
160+
// `@dbColumnType` lookup case-folds (see `KotlinTypeMapper.dbColumnType`); both
161+
// `"JSONB"` and `"jsonb"` route to the JSONB branch.
162+
val f = StringField("featureFlags")
163+
f.addMetaAttr(StringAttribute.create("dbColumnType", "JSONB"))
164+
assertEquals("text(\"feature_flags\")", KotlinTypeMapper.exposedColumnSpec(f))
165+
}
166+
167+
@Test fun `string field with dbColumnType=jsonb ignores maxLength`() {
168+
// A short `@maxLength` would normally select varchar; the JSONB override wins
169+
// regardless so a YAML with both attributes still gets the unbounded text column.
170+
val f = StringField("blob")
171+
f.addMetaAttr(StringAttribute.create("dbColumnType", "jsonb"))
172+
f.addMetaAttr(IntAttribute.create("maxLength", 64))
173+
assertEquals("text(\"blob\")", KotlinTypeMapper.exposedColumnSpec(f))
174+
}
175+
147176
// === Currency / Enum / UUID coverage ===
148177

149178
@Test fun `currency field maps to Long and long exposed column with snake_case name`() {

0 commit comments

Comments
 (0)