Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## Unreleased

### Changed

- **Codegen now emits one file per model in typed sub-packages** instead of a single
`SupabaseModels.kt`. Tables go to `{package}.tables` (one file each) and Postgres enums to
`{package}.enums`, mirroring jOOQ's layout — large schemas stay navigable and a column change
produces a one-file diff. A table and an enum that share a Kotlin name (e.g. `order_status`) no
longer collide, since they land in different packages. **API:** `SupabaseModelGenerator.generate`
now returns `List<GeneratedFile>` and no longer takes a `fileName`; the CLI's `--file` flag and the
Gradle extension's `fileName` property were removed. The generated header now points users to
extension functions/properties for customisation (don't hand-edit generated files).

## 0.9.2

### Added
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,12 @@
// Generated from the Supabase schema. Do not edit by hand.
package io.github.androidpoet.supabase.sample.chat.generated
// To customise a model, add extension functions/properties in your OWN file —
// this file is regenerated (overwritten) on every run.
package io.github.androidpoet.supabase.sample.chat.generated.tables

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.String

@Serializable
public data class E2eMessages(
public val id: String,
public val body: String,
@SerialName("created_at")
public val createdAt: String,
)

@Serializable
public data class ChatRooms(
public val id: String,
public val name: String,
@SerialName("created_at")
public val createdAt: String,
)

@Serializable
public data class ChatMessages(
public val id: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Generated from the Supabase schema. Do not edit by hand.
// To customise a model, add extension functions/properties in your OWN file —
// this file is regenerated (overwritten) on every run.
package io.github.androidpoet.supabase.sample.chat.generated.tables

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.String

@Serializable
public data class ChatRooms(
public val id: String,
public val name: String,
@SerialName("created_at")
public val createdAt: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Generated from the Supabase schema. Do not edit by hand.
// To customise a model, add extension functions/properties in your OWN file —
// this file is regenerated (overwritten) on every run.
package io.github.androidpoet.supabase.sample.chat.generated.tables

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.String

@Serializable
public data class E2eMessages(
public val id: String,
public val body: String,
@SerialName("created_at")
public val createdAt: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -34,18 +34,15 @@ public abstract class SupabaseCodegenExtension {
/** API key whose role can read the target tables. Falls back to `SUPABASE_KEY`. */
public abstract val key: Property<String>

/** Package for the generated file. Defaults to `supabase.generated`. */
/** Package for the generated models. Defaults to `supabase.generated`. */
public abstract val packageName: Property<String>

/** Generated file name (without extension). Defaults to `SupabaseModels`. */
public abstract val fileName: Property<String>

/** Where to write the generated file. Defaults to `build/generated/supabase`. */
/** Where to write the generated files. Defaults to `build/generated/supabase`. */
public abstract val outputDir: DirectoryProperty
}

/**
* Fetches the Supabase schema and writes one Kotlin file of `@Serializable` models. This is
* Fetches the Supabase schema and writes one Kotlin file per table/enum of `@Serializable` models. This is
* an on-demand task — it is deliberately NOT wired into `compileKotlin`, because the schema
* lives behind the network and needs a key, and you don't want either on every build. Run it
* when your schema changes (and commit the result, the way `supabase gen types` is used).
Expand All @@ -67,9 +64,6 @@ public abstract class GenerateSupabaseModelsTask : DefaultTask() {
@get:Internal
public abstract val packageName: Property<String>

@get:Internal
public abstract val fileName: Property<String>

@get:Internal
public abstract val outputDir: DirectoryProperty

Expand All @@ -84,17 +78,19 @@ public abstract class GenerateSupabaseModelsTask : DefaultTask() {
"supabaseCodegen.key is not set (or export SUPABASE_KEY). Use a key that can read your tables.",
)
val pkg = packageName.get()
val file = fileName.get()

logger.lifecycle("Fetching Supabase schema from ${projectUrl.trimEnd('/')}/rest/v1/ …")
val spec = SchemaFetcher.fetch(projectUrl, apiKey)
val code = SupabaseModelGenerator.generate(spec, pkg, file)

val packageDir = outputDir.get().asFile.resolve(pkg.replace('.', '/'))
packageDir.mkdirs()
val target = packageDir.resolve("$file.kt")
target.writeText(code)
logger.lifecycle("✓ Wrote $target")
val files = SupabaseModelGenerator.generate(spec, pkg)

val root = outputDir.get().asFile
for (file in files) {
val target = root.resolve(file.relativePath)
target.parentFile.mkdirs()
target.writeText(file.contents)
logger.lifecycle("✓ Wrote $target")
}
logger.lifecycle("✓ Generated ${files.size} file(s)")
}
}

Expand All @@ -103,7 +99,6 @@ public class SupabaseCodegenPlugin : Plugin<Project> {
override fun apply(project: Project) {
val extension = project.extensions.create("supabaseCodegen", SupabaseCodegenExtension::class.java)
extension.packageName.convention("supabase.generated")
extension.fileName.convention("SupabaseModels")
extension.outputDir.convention(project.layout.buildDirectory.dir("generated/supabase"))

project.tasks.register("generateSupabaseModels", GenerateSupabaseModelsTask::class.java) { task ->
Expand All @@ -112,7 +107,6 @@ public class SupabaseCodegenPlugin : Plugin<Project> {
task.url.set(extension.url.orElse(project.providers.environmentVariable("SUPABASE_URL")))
task.key.set(extension.key.orElse(project.providers.environmentVariable("SUPABASE_KEY")))
task.packageName.set(extension.packageName)
task.fileName.set(extension.fileName)
task.outputDir.set(extension.outputDir)
// The remote schema can change without any local input changing, so never report
// up-to-date — when you run the task, you mean it.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ class SupabaseCodegenPluginTest {
val extension = project.extensions.findByType(SupabaseCodegenExtension::class.java)
assertNotNull(extension, "supabaseCodegen extension should be registered")
assertEquals("supabase.generated", extension.packageName.get())
assertEquals("SupabaseModels", extension.fileName.get())

val task = project.tasks.findByName("generateSupabaseModels")
assertNotNull(task, "generateSupabaseModels task should be registered")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import java.nio.file.Path
* --package com.example.db --out src/commonMain/kotlin
* ```
* `--url`/`--key` fall back to the `SUPABASE_URL` / `SUPABASE_KEY` environment vars.
*
* Output is one file per table (under `{package}.tables`) and per enum (under
* `{package}.enums`).
*/
public fun main(args: Array<String>) {
val options = parseArgs(args)
Expand All @@ -24,17 +27,18 @@ public fun main(args: Array<String>) {
}
val packageName = options["--package"] ?: "supabase.generated"
val outDir = options["--out"] ?: "build/generated/supabase"
val fileName = options["--file"] ?: "SupabaseModels"

println("Fetching schema from ${url.trimEnd('/')}/rest/v1/ …")
val spec = SchemaFetcher.fetch(url, key)
val code = SupabaseModelGenerator.generate(spec, packageName, fileName)
val files = SupabaseModelGenerator.generate(spec, packageName)

val targetDir = Path.of(outDir, *packageName.split('.').toTypedArray())
Files.createDirectories(targetDir)
val target = targetDir.resolve("$fileName.kt")
Files.writeString(target, code)
println("✓ Wrote $target")
for (file in files) {
val target = Path.of(outDir, file.relativePath)
Files.createDirectories(target.parent)
Files.writeString(target, file.contents)
println("✓ Wrote $target")
}
println("✓ Generated ${files.size} file(s)")
}

private fun parseArgs(args: Array<String>): Map<String, String> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,22 @@ import com.squareup.kotlinpoet.TypeName
import com.squareup.kotlinpoet.TypeSpec
import kotlinx.serialization.json.Json

/** One generated Kotlin source file: its path relative to the output root, and its contents. */
public data class GeneratedFile(
public val relativePath: String,
public val contents: String,
)

/**
* Generates Kotlin `@Serializable` data classes (one per table) and `enum class`es
* (one per Postgres enum) from a PostgREST OpenAPI document. The database is the
* source of truth — this only ever *reads* the schema and emits Kotlin source; it
* never writes anything back to the database.
* Generates Kotlin `@Serializable` data classes (one file per table, under a `tables`
* sub-package) and `enum class`es (one file per Postgres enum, under an `enums`
* sub-package) from a PostgREST OpenAPI document. The database is the source of truth —
* this only ever *reads* the schema and emits Kotlin source; it never writes anything
* back to the database.
*
* The generated files are fully owned by the generator and overwritten on every run, so
* never hand-edit them. To add behaviour, write extension functions/properties on the
* generated types in your OWN file — that survives regeneration.
*/
public object SupabaseModelGenerator {
private val json =
Expand All @@ -35,63 +46,71 @@ public object SupabaseModelGenerator {
private val serialName = ClassName("kotlinx.serialization", "SerialName")
private val jsonElement = ClassName("kotlinx.serialization.json", "JsonElement")

// Sub-packages each kind of model is emitted into, so a large schema stays navigable and
// a table and an enum that share a Kotlin name (e.g. `order_status`) land in different
// packages instead of colliding. Mirrors jOOQ's `tables`/`enums` layout.
private const val TABLES_SUBPACKAGE = "tables"
private const val ENUMS_SUBPACKAGE = "enums"

private val header =
listOf(
"Generated from the Supabase schema. Do not edit by hand.",
"To customise a model, add extension functions/properties in your OWN file —",
"this file is regenerated (overwritten) on every run.",
)

/**
* Parses the OpenAPI [specJson] served at `{projectUrl}/rest/v1/` and returns a
* single Kotlin source file (named [fileName]) under [packageName] containing a
* data class for every table and an enum class for every Postgres enum.
* Parses the OpenAPI [specJson] served at `{projectUrl}/rest/v1/` and returns one
* [GeneratedFile] per table (under `{packageName}.tables`) and per Postgres enum
* (under `{packageName}.enums`).
*/
public fun generate(
specJson: String,
packageName: String,
fileName: String = "SupabaseModels",
): String = generate(json.decodeFromString(OpenApiSpec.serializer(), specJson), packageName, fileName)
public fun generate(specJson: String, packageName: String): List<GeneratedFile> =
generate(json.decodeFromString(OpenApiSpec.serializer(), specJson), packageName)

internal fun generate(spec: OpenApiSpec, packageName: String): List<GeneratedFile> {
val tablesPackage = "$packageName.$TABLES_SUBPACKAGE"
val enumsPackage = "$packageName.$ENUMS_SUBPACKAGE"

internal fun generate(spec: OpenApiSpec, packageName: String, fileName: String): String {
// Collected across all tables and de-duplicated by enum type name, since the
// same Postgres enum can back columns on many tables.
val enums = linkedMapOf<String, List<String>>()
val tables = mutableListOf<TypeSpec>()
// Two distinct tables can normalise to the same Kotlin class name (e.g. `user_profile`
// and `userProfile`). That would emit duplicate declarations that don't compile, so we
// fail fast with an actionable message instead.
val claimedNames = mutableMapOf<String, String>()
// Keyed by Kotlin class name so two tables that normalise to the same name (e.g.
// `user_profile` and `userProfile`) are caught: they would emit two files with the
// same class, so we fail fast with an actionable message instead.
val tableTypes = linkedMapOf<String, TypeSpec>()
val claimedTables = mutableMapOf<String, String>()

for ((tableName, definition) in spec.definitions) {
if (definition.properties.isEmpty()) continue // a data class needs ≥1 property
val className = Naming.pascal(tableName)
val clash = claimedNames.put(className, tableName)
val clash = claimedTables.put(className, tableName)
check(clash == null) {
"Tables `$clash` and `$tableName` both map to the Kotlin class `$className`. " +
"Rename one of the tables (or its exposed name) so the generated names don't collide."
}
tables += buildDataClass(packageName, className, tableName, definition, enums)
tableTypes[className] = buildDataClass(enumsPackage, className, tableName, definition, enums)
}

// Enums and table data classes are all emitted as top-level types in one file,
// so a table and an enum that normalise to the same Kotlin name (e.g. table
// `order_status` and Postgres enum `order_status`) would emit two declarations
// with the same name that don't compile. Fail fast, mirroring the table/column/
// enum-name collision checks.
for (enumName in enums.keys) {
val tableName = claimedNames[enumName]
check(tableName == null) {
"Postgres enum `$enumName` and table `$tableName` both map to the Kotlin type " +
"`$enumName`. Rename one of them (or its exposed name) so the generated " +
"names don't collide."
}
}
// Tables and enums live in separate sub-packages, so a table and an enum that share a
// Kotlin name no longer collide — no cross-check needed (unlike the single-file layout).

val files = mutableListOf<GeneratedFile>()
enums.forEach { (name, values) -> files += fileOf(enumsPackage, name, buildEnum(name, values)) }
tableTypes.forEach { (name, type) -> files += fileOf(tablesPackage, name, type) }
return files
}

val file =
FileSpec
.builder(packageName, fileName)
.addFileComment("Generated from the Supabase schema. Do not edit by hand.")
enums.forEach { (name, values) -> file.addType(buildEnum(name, values)) }
tables.forEach(file::addType)
return file.build().toString()
/** Wraps a single top-level [type] in its own file under [packageName]. */
private fun fileOf(packageName: String, fileName: String, type: TypeSpec): GeneratedFile {
val builder = FileSpec.builder(packageName, fileName)
header.forEach(builder::addFileComment)
val contents = builder.addType(type).build().toString()
val relativePath = "${packageName.replace('.', '/')}/$fileName.kt"
return GeneratedFile(relativePath, contents)
}

private fun buildDataClass(
packageName: String,
enumsPackage: String,
className: String,
tableName: String,
definition: TableDefinition,
Expand All @@ -111,7 +130,7 @@ public object SupabaseModelGenerator {

for ((columnName, column) in definition.properties) {
val nullable = columnName !in definition.required
val type = kotlinType(packageName, tableName, columnName, column, enums).copy(nullable = nullable)
val type = kotlinType(enumsPackage, tableName, columnName, column, enums).copy(nullable = nullable)
val propName = Naming.camel(columnName)

val clash = claimedProps.put(propName, columnName)
Expand Down Expand Up @@ -164,7 +183,7 @@ public object SupabaseModelGenerator {

/** Maps one column to its Kotlin type, registering any enum it introduces. */
private fun kotlinType(
packageName: String,
enumsPackage: String,
tableName: String,
columnName: String,
column: ColumnProperty,
Expand All @@ -173,10 +192,9 @@ public object SupabaseModelGenerator {
if (column.enum.isNotEmpty()) {
val enumName = Naming.enumName(column.format, tableName, columnName)
registerEnum(enums, enumName, column.enum)
// Enums are emitted into THIS same file/package, so reference them with the
// file's package — an empty-package ClassName makes KotlinPoet emit an
// `import <Enum>` from the default package, which Kotlin forbids (uncompilable).
return ClassName(packageName, enumName)
// Enums live in their own `.enums` package; reference them by their full
// package so KotlinPoet emits a correct `import {package}.enums.{Enum}`.
return ClassName(enumsPackage, enumName)
}
if (column.type == "array") {
val items = column.items
Expand All @@ -186,7 +204,7 @@ public object SupabaseModelGenerator {
items.enum.isNotEmpty() -> {
val enumName = Naming.enumName(items.format, tableName, columnName)
registerEnum(enums, enumName, items.enum)
ClassName(packageName, enumName)
ClassName(enumsPackage, enumName)
}
// A nested array (e.g. int[][]) reports its element `type` as "array" with
// no scalar format. Keep it as raw JSON so deserialization can't fail trying
Expand Down
Loading
Loading