diff --git a/CHANGELOG.md b/CHANGELOG.md index fdb9c78d..6799eac4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` 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 diff --git a/samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/SupabaseModels.kt b/samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/ChatMessages.kt similarity index 57% rename from samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/SupabaseModels.kt rename to samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/ChatMessages.kt index d249311f..bd0dee97 100644 --- a/samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/SupabaseModels.kt +++ b/samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/ChatMessages.kt @@ -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, diff --git a/samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/ChatRooms.kt b/samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/ChatRooms.kt new file mode 100644 index 00000000..e425c008 --- /dev/null +++ b/samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/ChatRooms.kt @@ -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, +) diff --git a/samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/E2eMessages.kt b/samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/E2eMessages.kt new file mode 100644 index 00000000..62099171 --- /dev/null +++ b/samples/chat-compose/src/main/java/io/github/androidpoet/supabase/sample/chat/generated/tables/E2eMessages.kt @@ -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, +) diff --git a/supabase-codegen-gradle/src/main/kotlin/io/github/androidpoet/supabase/codegen/gradle/SupabaseCodegenPlugin.kt b/supabase-codegen-gradle/src/main/kotlin/io/github/androidpoet/supabase/codegen/gradle/SupabaseCodegenPlugin.kt index 7568e94a..843e163f 100644 --- a/supabase-codegen-gradle/src/main/kotlin/io/github/androidpoet/supabase/codegen/gradle/SupabaseCodegenPlugin.kt +++ b/supabase-codegen-gradle/src/main/kotlin/io/github/androidpoet/supabase/codegen/gradle/SupabaseCodegenPlugin.kt @@ -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 - /** Package for the generated file. Defaults to `supabase.generated`. */ + /** Package for the generated models. Defaults to `supabase.generated`. */ public abstract val packageName: Property - /** Generated file name (without extension). Defaults to `SupabaseModels`. */ - public abstract val fileName: Property - - /** 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). @@ -67,9 +64,6 @@ public abstract class GenerateSupabaseModelsTask : DefaultTask() { @get:Internal public abstract val packageName: Property - @get:Internal - public abstract val fileName: Property - @get:Internal public abstract val outputDir: DirectoryProperty @@ -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)") } } @@ -103,7 +99,6 @@ public class SupabaseCodegenPlugin : Plugin { 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 -> @@ -112,7 +107,6 @@ public class SupabaseCodegenPlugin : Plugin { 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. diff --git a/supabase-codegen-gradle/src/test/kotlin/io/github/androidpoet/supabase/codegen/gradle/SupabaseCodegenPluginTest.kt b/supabase-codegen-gradle/src/test/kotlin/io/github/androidpoet/supabase/codegen/gradle/SupabaseCodegenPluginTest.kt index b4d2deab..10b56bbd 100644 --- a/supabase-codegen-gradle/src/test/kotlin/io/github/androidpoet/supabase/codegen/gradle/SupabaseCodegenPluginTest.kt +++ b/supabase-codegen-gradle/src/test/kotlin/io/github/androidpoet/supabase/codegen/gradle/SupabaseCodegenPluginTest.kt @@ -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") diff --git a/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/Main.kt b/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/Main.kt index 4b7cc4e4..a6ad4dbe 100644 --- a/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/Main.kt +++ b/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/Main.kt @@ -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) { val options = parseArgs(args) @@ -24,17 +27,18 @@ public fun main(args: Array) { } 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): Map { diff --git a/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SupabaseModelGenerator.kt b/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SupabaseModelGenerator.kt index 41f10427..ac1a5d73 100644 --- a/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SupabaseModelGenerator.kt +++ b/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SupabaseModelGenerator.kt @@ -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 = @@ -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 = + generate(json.decodeFromString(OpenApiSpec.serializer(), specJson), packageName) + + internal fun generate(spec: OpenApiSpec, packageName: String): List { + 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>() - val tables = mutableListOf() - // 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() + // 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() + val claimedTables = mutableMapOf() 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() + 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, @@ -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) @@ -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, @@ -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 ` 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 @@ -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 diff --git a/supabase-codegen/src/test/kotlin/io/github/androidpoet/supabase/codegen/SupabaseModelGeneratorTest.kt b/supabase-codegen/src/test/kotlin/io/github/androidpoet/supabase/codegen/SupabaseModelGeneratorTest.kt index 8e7023d0..8f35a5f3 100644 --- a/supabase-codegen/src/test/kotlin/io/github/androidpoet/supabase/codegen/SupabaseModelGeneratorTest.kt +++ b/supabase-codegen/src/test/kotlin/io/github/androidpoet/supabase/codegen/SupabaseModelGeneratorTest.kt @@ -32,15 +32,26 @@ class SupabaseModelGeneratorTest { } """.trimIndent() - private val generated = SupabaseModelGenerator.generate(fixture, packageName = "com.example.db") + // All generated files concatenated, for the content-level assertions below. + private val generated = source(fixture) + + private fun source(spec: String, packageName: String = "com.example.db"): String = + SupabaseModelGenerator.generate(spec, packageName).joinToString("\n") { it.contents } @Test fun generates_a_serializable_data_class_named_after_the_table() { - assertContains(generated, "package com.example.db") + assertContains(generated, "package com.example.db.tables") assertContains(generated, "@Serializable") assertContains(generated, "data class Todos(") } + @Test + fun emits_one_file_per_table_and_per_enum_in_typed_folders() { + val paths = SupabaseModelGenerator.generate(fixture, "com.example.db").map { it.relativePath } + assertContains(paths, "com/example/db/tables/Todos.kt") + assertContains(paths, "com/example/db/enums/PriorityLevel.kt") + } + @Test fun required_columns_are_non_null_and_nullable_columns_are_optional() { assertContains(generated, "val id: String") // required (uuid → String) @@ -100,7 +111,7 @@ class SupabaseModelGeneratorTest { } """.trimIndent() - val generated = SupabaseModelGenerator.generate(spec, packageName = "com.example.db") + val generated = source(spec) assertContains(generated, "val price: String?") // money → String (would throw as Double) assertContains(generated, "val tax: Double?") // numeric → Double (JSON number) @@ -114,21 +125,21 @@ class SupabaseModelGeneratorTest { } @Test - fun postgres_enums_become_serializable_enum_classes() { + fun postgres_enums_become_serializable_enum_classes_in_their_own_package() { assertContains(generated, "enum class PriorityLevel") assertContains(generated, "@SerialName(\"low\")") assertContains(generated, "LOW") assertContains(generated, "HIGH") assertContains(generated, "val priority: PriorityLevel?") - // The enum lives in the SAME file/package, so it must be referenced directly, - // never imported. An empty-package ClassName made KotlinPoet emit - // `import PriorityLevel` from the default package, which Kotlin forbids — the - // whole generated file then failed to compile. Guard against that regression. + // Enums live in their own `.enums` package; the table file imports them from there. + assertContains(generated, "package com.example.db.enums") + assertContains(generated, "import com.example.db.enums.PriorityLevel") + // Never an empty-package import (`import PriorityLevel`), which Kotlin forbids and + // would make the generated file uncompilable. Guard against that regression. assertFalse( - "import PriorityLevel" in generated, + "import PriorityLevel\n" in generated, "enum must not be imported from the default package (uncompilable)", ) - assertFalse("import com.example.db.PriorityLevel" in generated, "same-package enum must not be self-imported") } @Test @@ -156,7 +167,7 @@ class SupabaseModelGeneratorTest { } } """.trimIndent() - val out = SupabaseModelGenerator.generate(schema, packageName = "p") + val out = source(schema, "p") assertContains(out, "val `class`: String") // hard keyword → backticked assertContains(out, "val `1stPlace`: Int?") // digit-leading identifier → backticked assertContains(out, "@SerialName(\"1st_place\")") @@ -213,10 +224,10 @@ class SupabaseModelGeneratorTest { } @Test - fun fails_fast_when_a_table_and_an_enum_collide_on_one_kotlin_name() { - // Table `order_status` → `class OrderStatus`; a column whose Postgres enum type - // is `order_status` → `enum class OrderStatus`. Both are top-level types in one - // file, so without a cross-check they emit a redeclaration that won't compile. + fun a_table_and_an_enum_with_the_same_name_no_longer_collide() { + // Table `order_status` → `tables/OrderStatus.kt`; a column whose Postgres enum type + // is `order_status` → `enums/OrderStatus.kt`. In the single-file layout these were a + // fatal redeclaration; split into typed sub-packages they coexist cleanly. val schema = """ { @@ -234,11 +245,9 @@ class SupabaseModelGeneratorTest { } } """.trimIndent() - val error = - assertFailsWith { - SupabaseModelGenerator.generate(schema, packageName = "p") - } - assertContains(error.message ?: "", "OrderStatus") + val paths = SupabaseModelGenerator.generate(schema, "p").map { it.relativePath } + assertContains(paths, "p/tables/OrderStatus.kt") + assertContains(paths, "p/enums/OrderStatus.kt") } @Test