Skip to content

feat(typegen): add Kotlin type generation#1086

Open
AndroidPoet wants to merge 2 commits into
supabase:masterfrom
AndroidPoet:feat/kotlin-type-generation
Open

feat(typegen): add Kotlin type generation#1086
AndroidPoet wants to merge 2 commits into
supabase:masterfrom
AndroidPoet:feat/kotlin-type-generation

Conversation

@AndroidPoet

Copy link
Copy Markdown

What

Adds a Kotlin target to gen types, alongside the existing TypeScript, Go, Swift, and Python generators. It follows the same structure as swift.ts (per-schema namespace, Select/Insert/Update variants, enums, composite types) but emits idiomatic kotlinx.serialization code.

PG_META_GENERATE_TYPES=kotlin npm run gen:types:kotlin
# or via the API:
GET /generators/kotlin?included_schemas=public&visibility=internal

Why

The CLI already proxies supabase gen types --lang {typescript,go,swift,python} straight through to this service via PG_META_GENERATE_TYPES. Kotlin is the one major Supabase client ecosystem with no first-party type generation, despite an active KMP/Android user base. This closes that gap with no new runtime dependencies.

Example output

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement

object PublicSchema {
  @Serializable
  enum class UserStatus {
    ACTIVE,
    @SerialName("not-valid id") not_valid_id,
  }
  @Serializable
  class EmptyTableSelect
  @Serializable
  data class ProfilesSelect(
    @SerialName("class") val `class`: String,
    @SerialName("created_at") val createdAt: String,
    val id: Long,
    val metadata: JsonElement? = null,
    val status: UserStatus? = null,
    val tags: List<String>? = null,
  )
  // ...Insert / Update variants
}

Design notes

Modeled on swift.ts, with a few Kotlin-idiomatic refinements:

  • @SerialName only when needed — emitted only where the camelCased property name differs from the raw column name, instead of an always-present CodingKeys block.
  • = null defaults on nullable properties — so Insert/Update payloads can omit them, matching how Supabase Kotlin clients build partial rows.
  • Column-less relations → plain @Serializable class — a data class with an empty primary constructor is a Kotlin compile error, so empty tables/views are special-cased.
  • Keyword safety — Kotlin hard keywords (class, object, in, …) are backtick-escaped; enum values that aren't valid identifiers are sanitized and preserved via @SerialName.
  • Scoped imports — the import block only includes the symbols actually referenced (SerialName, JsonElement, JsonObject).
  • Type mappingint2/4/8 → Short/Int/Long, float4/8 → Float/Double, numeric → Double (no dependency-free KMP-wide decimal; mirrors the Go template), json/jsonb → JsonElement, record → JsonObject, arrays → List<T>, enums/composites → generated types.

Visibility is controlled by PG_META_GENERATE_TYPES_KOTLIN_VISIBILITY (public | internal, default public) and the visibility query param — analogous to Swift's access_control. public is emitted implicitly (Kotlin's default) to keep output lint-clean.

Tests

Added typegen: kotlin and typegen: kotlin w/ internal visibility to test/server/typegen.ts, mirroring the Swift cases. They use empty inline snapshots that populate on first run.

Note: the snapshot suite requires Docker (per CLAUDE.md), which wasn't available in my environment, so the inline snapshots are intentionally left empty for npm run test:update to fill against the test DB. npm run check (tsc) and prettier --check both pass. I verified the generator's actual output against synthetic metadata covering enums, arrays, composite types, nullability, keyword columns, and empty tables (see example above).

Adds a Kotlin generator to gen types, alongside the existing TypeScript,
Go, Swift, and Python targets.

- New template at src/server/templates/kotlin.ts emitting @serializable
  data classes (kotlinx.serialization), one per table/view per operation
  (Select/Insert/Update), plus enum classes and composite types, namespaced
  under a per-schema object.
- Idiomatic touches: @SerialName only when the camelCased property name
  differs from the column name, '= null' defaults on nullable properties,
  column-less relations emitted as plain @serializable class (a data class
  with no params is illegal in Kotlin), backtick-escaped hard keywords, and
  an import block scoped to the symbols actually used.
- Wires the /generators/kotlin route, server type-gen dispatch, the
  PG_META_GENERATE_TYPES_KOTLIN_VISIBILITY env var (public|internal,
  defaults to public), and a gen:types:kotlin npm script.
- Adds typegen tests for default and internal visibility.
@AndroidPoet AndroidPoet requested review from a team, avallete and soedirgo as code owners June 22, 2026 13:19
A type entry's `format` is the spelled-out SQL name (e.g. `integer`)
while its `name` is the catalog name (e.g. `int4`) that the type map and
column metadata are keyed on. Composite attributes were resolved via
`format`, so scalar attributes like int4/int8 fell through to the
JsonElement fallback. Resolve via `name` instead, and flag the
JsonElement import on the unresolved-type fallback path.
@AndroidPoet

Copy link
Copy Markdown
Author

End-to-end verification against a live Postgres 15

Ran the actual PG_META_GENERATE_TYPES=kotlin path (not just the template in isolation) against a real database — schema with an enum, a composite type, an identity column, a jsonb column, an array, a numeric, a reserved-word column ("class"), an empty table, and a view:

object PublicSchema {
  @Serializable
  enum class UserStatus {
    ACTIVE,
    @SerialName("not-valid id") not_valid_id,
  }
  @Serializable
  class EmptyTableSelect
  @Serializable
  data class ProfilesSelect(
    @SerialName("class") val `class`: String,
    @SerialName("created_at") val createdAt: String,
    @SerialName("home_address") val homeAddress: Address? = null,
    val id: Long,
    val metadata: JsonElement? = null,
    val score: Double,
    val status: UserStatus? = null,
    val tags: List<String>? = null,
  )
  // ProfilesInsert / ProfilesUpdate / ActiveProfilesSelect ...
  @Serializable
  data class Address(
    @SerialName("street_name") val streetName: String,
    val zip: Int,
  )
}

The live run caught a bug my isolated test missed: composite attribute types must be resolved by their catalog name (int4), not format (integer) — otherwise scalars fall through to the JsonElement fallback. Fixed in 0320121.

Heads-up for maintainers: the same latent issue exists in go.ts and swift.ts (both pass attribute.type.format); their fixtures just don't include a scalar composite attribute to surface it. Happy to file a separate fix for those if useful.

The two typegen snapshot tests still need npm run test:update against the Docker test DB to populate their inline snapshots, since the CI fixture differs from my ad-hoc schema.

@AndroidPoet

Copy link
Copy Markdown
Author

Proof the output is real, compilable Kotlin (not just well-formed text)

Compiled the generated file with the actual Kotlin toolchain — kotlinc + the bundled kotlinx-serialization compiler plugin (runtime 1.11.0, JDK 17):

$ kotlinc Generated.kt -Xplugin=kotlinx-serialization-compiler-plugin.jar \
    -cp kotlinx-serialization-core-jvm.jar:kotlinx-serialization-json-jvm.jar -d out.jar
EXIT CODE: 0

The @Serializable plugin emitted real serializers for every type — confirming the annotations are processed, not decorative:

generated/PublicSchema$ProfilesSelect.class
generated/PublicSchema$ProfilesSelect$$serializer.class
generated/PublicSchema$Address$$serializer.class
generated/PublicSchema$UserStatus$Companion.class
... (one $$serializer per data class)

Then a full JSON round-trip through ProfilesSelect, decoding an actual Postgres-shaped row and re-encoding it:

val row = Json.decodeFromString(PublicSchema.ProfilesSelect.serializer(), json)
decoded:    id=7 class=`premium` status=ACTIVE tags=[a, b] zip=90210 street=Main St
re-encoded: {"class":"premium","created_at":"2026-06-22T00:00:00Z",
             "home_address":{"street_name":"Main St","zip":90210},
             "id":7,"metadata":{"k":1},"score":9.5,"status":"ACTIVE","tags":["a","b"]}

That one test exercises every non-trivial path, and all pass:

  • @SerialName mappingcreated_at/home_addresscreatedAt/homeAddress
  • Reserved-word column — backtick-escaped `class` decodes and re-encodes
  • Enumstatus: UserStatus decodes "ACTIVE"
  • Nested compositehome_addressAddress(streetName: String, zip: Int)
  • Array / jsonb / numeric / int4List<String> / JsonElement / Double / Int all correct

Full chain verified: live Postgres → pg-meta introspection → apply() → valid Kotlin → compiles → serializes round-trip.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant