From fd90136f4cc1a3c37a68eeb5e47cc382adf275e7 Mon Sep 17 00:00:00 2001 From: Ranbir Singh Date: Mon, 22 Jun 2026 14:03:17 +0530 Subject: [PATCH] Codegen: add --format sqldelight to emit .sq schema files Adds SupabaseSqlDelightGenerator, which turns a PostgREST OpenAPI document into one SQLDelight .sq file per table: a CREATE TABLE with Postgres->SQLite storage classes, NOT NULL from required columns, the primary key detected from PostgREST's description marker (falling back to an id column), plus standard selectAll/selectById/upsert/ deleteById queries. SQLDelight's own plugin then compiles these into a type-safe Kotlin database. The CLI gains --format kotlin|sqldelight (default kotlin, unchanged), with format-specific stale-file cleanup before writing. --- CHANGELOG.md | 8 ++ .../androidpoet/supabase/codegen/Main.kt | 31 +++-- .../codegen/SupabaseSqlDelightGenerator.kt | 112 ++++++++++++++++++ .../SupabaseSqlDelightGeneratorTest.kt | 86 ++++++++++++++ 4 files changed, 227 insertions(+), 10 deletions(-) create mode 100644 supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SupabaseSqlDelightGenerator.kt create mode 100644 supabase-codegen/src/test/kotlin/io/github/androidpoet/supabase/codegen/SupabaseSqlDelightGeneratorTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 31c884c..5d6bdc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ ## Unreleased +### Added + +- **Codegen can emit SQLDelight `.sq` schema** (`--format sqldelight`, or `SupabaseSqlDelightGenerator`). + Generates one `.sq` file per table — `CREATE TABLE` with Postgres→SQLite storage classes, the primary + key detected from PostgREST's `` description marker, and standard `selectAll`/`selectById`/ + `upsert`/`deleteById` queries — for SQLDelight's own plugin to compile into a typed database. The + default `--format kotlin` (one `@Serializable` file per table/enum) is unchanged. + ## 0.9.4 > Codegen-only release: `supabase-codegen` + `supabase-codegen-gradle` published at `0.9.4`. 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 bef6324..f32acad 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 @@ -14,8 +14,9 @@ import java.nio.file.Path * ``` * `--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`). + * `--format kotlin` (default) emits one Kotlin file per table (under `{package}.tables`) and per + * enum (under `{package}.enums`). `--format sqldelight` emits one `.sq` file per table for + * SQLDelight's plugin to compile. */ public fun main(args: Array) { val options = parseArgs(args) @@ -27,17 +28,27 @@ public fun main(args: Array) { } val packageName = options["--package"] ?: "supabase.generated" val outDir = options["--out"] ?: "build/generated/supabase" + val format = options["--format"]?.lowercase() ?: "kotlin" println("Fetching schema from ${url.trimEnd('/')}/rest/v1/ …") val spec = SchemaFetcher.fetch(url, key) - val files = SupabaseModelGenerator.generate(spec, packageName) - - // Clear the generator-owned subpackages first so a table/enum dropped from the schema - // doesn't leave a stale file. Scoped to those dirs, so hand-written code elsewhere is safe. val packageDir = Path.of(outDir, *packageName.split('.').toTypedArray()) - for (sub in SupabaseModelGenerator.generatedSubpackages) { - packageDir.resolve(sub).toFile().deleteRecursively() - } + + // Generate, clearing prior output first so a dropped table/enum can't leave a stale file. + val files = + when (format) { + "kotlin" -> { + for (sub in SupabaseModelGenerator.generatedSubpackages) { + packageDir.resolve(sub).toFile().deleteRecursively() + } + SupabaseModelGenerator.generate(spec, packageName) + } + "sqldelight" -> { + packageDir.toFile().listFiles { f -> f.extension == "sq" }?.forEach { it.delete() } + SupabaseSqlDelightGenerator.generate(spec, packageName) + } + else -> error("Unknown --format '$format' (use 'kotlin' or 'sqldelight').") + } for (file in files) { val target = Path.of(outDir, file.relativePath) @@ -45,7 +56,7 @@ public fun main(args: Array) { Files.writeString(target, file.contents) println("✓ Wrote $target") } - println("✓ Generated ${files.size} file(s)") + println("✓ Generated ${files.size} $format file(s)") } private fun parseArgs(args: Array): Map { diff --git a/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SupabaseSqlDelightGenerator.kt b/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SupabaseSqlDelightGenerator.kt new file mode 100644 index 0000000..8e08ebc --- /dev/null +++ b/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SupabaseSqlDelightGenerator.kt @@ -0,0 +1,112 @@ +package io.github.androidpoet.supabase.codegen + +import kotlinx.serialization.json.Json + +/** + * Generates SQLDelight `.sq` files (one per table) from a PostgREST OpenAPI document, so a local + * SQLDelight database can mirror the Supabase schema. Each file holds a `CREATE TABLE` plus a small + * set of standard queries; SQLDelight's own Gradle plugin then turns these into the type-safe + * Kotlin database. This is the schema half of the "Supabase → SQLDelight" pipeline — it only ever + * *reads* the schema and emits `.sq` source. + * + * Like the Kotlin model generator, the output is fully owned by the generator and overwritten on + * every run — don't hand-edit it. + */ +public object SupabaseSqlDelightGenerator { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + private val header = + """ + |-- Generated from the Supabase schema. Do not edit by hand. + |-- SQLDelight's plugin turns this into a type-safe Kotlin database. + """.trimMargin() + + /** + * Parses the OpenAPI [specJson] and returns one [GeneratedFile] per table, each a `.sq` file + * placed under [packageName] (SQLDelight derives the package from the directory path). + */ + public fun generate(specJson: String, packageName: String): List = + generate(json.decodeFromString(OpenApiSpec.serializer(), specJson), packageName) + + internal fun generate(spec: OpenApiSpec, packageName: String): List { + val packagePath = packageName.replace('.', '/') + val files = mutableListOf() + // Two tables can normalise to the same `.sq` file name (e.g. `user_profile` / `userProfile`), + // which would emit two files with the same SQLDelight queries class — fail fast instead. + val claimedFiles = mutableMapOf() + + for ((tableName, definition) in spec.definitions) { + if (definition.properties.isEmpty()) continue + val fileBase = Naming.pascal(tableName) + val clash = claimedFiles.put(fileBase, tableName) + check(clash == null) { + "Tables `$clash` and `$tableName` both map to the SQLDelight file `$fileBase.sq`. " + + "Rename one of the tables (or its exposed name) so the generated names don't collide." + } + files += GeneratedFile("$packagePath/$fileBase.sq", buildSqFile(tableName, definition)) + } + return files + } + + private fun buildSqFile(tableName: String, definition: TableDefinition): String { + val pk = primaryKeyColumn(definition) + val columns = + definition.properties.entries.joinToString(",\n") { (name, column) -> + val type = sqliteType(column) + val notNull = if (name in definition.required) " NOT NULL" else "" + val pkClause = if (name == pk) " PRIMARY KEY" else "" + " ${quote(name)} $type$notNull$pkClause" + } + + val sb = StringBuilder() + sb.append(header).append("\n\n") + sb.append("CREATE TABLE IF NOT EXISTS ${quote(tableName)} (\n").append(columns).append("\n);\n") + + sb.append("\nselectAll:\nSELECT * FROM ${quote(tableName)};\n") + if (pk != null) { + sb.append("\nselectById:\nSELECT * FROM ${quote(tableName)} WHERE ${quote(pk)} = ?;\n") + sb.append("\ndeleteById:\nDELETE FROM ${quote(tableName)} WHERE ${quote(pk)} = ?;\n") + } + val cols = definition.properties.keys + val colList = cols.joinToString(", ") { quote(it) } + val placeholders = cols.joinToString(", ") { "?" } + sb.append("\nupsert:\nINSERT OR REPLACE INTO ${quote(tableName)}($colList)\nVALUES($placeholders);\n") + return sb.toString() + } + + /** + * PostgREST flags a primary-key column by appending `` to its `description` + * (e.g. `"Note:\nThis is a Primary Key."`). Fall back to a column literally named `id`. + */ + private fun primaryKeyColumn(definition: TableDefinition): String? { + definition.properties.entries + .firstOrNull { (_, c) -> c.description?.contains("") == true } + ?.let { return it.key } + return definition.properties.keys.firstOrNull { it == "id" } + } + + /** Wraps an identifier in double quotes so SQL keywords / mixed-case names stay valid. */ + private fun quote(identifier: String): String = "\"$identifier\"" + + /** Maps a Postgres column to a SQLite storage class. */ + private fun sqliteType(column: ColumnProperty): String { + if (column.type == "array") return "TEXT" // arrays are stored as JSON text + return when (column.format?.lowercase()?.trim() ?: column.type?.lowercase()?.trim()) { + in INT_TYPES, in LONG_TYPES, in BOOL_TYPES -> "INTEGER" + in REAL_TYPES -> "REAL" + // text, varchar, uuid, timestamp*, date, json/jsonb, money, enum, … → TEXT + else -> "TEXT" + } + } + + // Mirrors the Kotlin generator's mapping, collapsed to SQLite's storage classes. `money` is + // TEXT (PostgREST emits it as a locale-formatted string); `boolean` is INTEGER (0/1). + private val INT_TYPES = setOf("int32", "integer", "int4", "smallint", "int2", "serial", "serial4", "smallserial") + private val LONG_TYPES = setOf("int64", "bigint", "int8", "bigserial", "serial8") + private val REAL_TYPES = setOf("number", "double precision", "float8", "real", "float4", "numeric", "decimal") + private val BOOL_TYPES = setOf("boolean", "bool") +} diff --git a/supabase-codegen/src/test/kotlin/io/github/androidpoet/supabase/codegen/SupabaseSqlDelightGeneratorTest.kt b/supabase-codegen/src/test/kotlin/io/github/androidpoet/supabase/codegen/SupabaseSqlDelightGeneratorTest.kt new file mode 100644 index 0000000..929cc32 --- /dev/null +++ b/supabase-codegen/src/test/kotlin/io/github/androidpoet/supabase/codegen/SupabaseSqlDelightGeneratorTest.kt @@ -0,0 +1,86 @@ +package io.github.androidpoet.supabase.codegen + +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class SupabaseSqlDelightGeneratorTest { + // PostgREST flags the primary key by appending `` to the column description. + private val fixture = + """ + { + "definitions": { + "todos": { + "required": ["id", "title", "done", "rank"], + "properties": { + "id": { "format": "uuid", "type": "string", "description": "Note:\nThis is a Primary Key." }, + "title": { "format": "text", "type": "string" }, + "done": { "format": "boolean", "type": "boolean" }, + "rank": { "format": "int32", "type": "integer" }, + "score": { "format": "int64", "type": "integer" }, + "price": { "format": "numeric", "type": "number" }, + "created_at": { "format": "timestamp with time zone", "type": "string" }, + "tags": { "type": "array", "format": "text[]", "items": { "type": "string" } } + } + } + } + } + """.trimIndent() + + private val files = SupabaseSqlDelightGenerator.generate(fixture, packageName = "com.example.db") + private val todos = files.single { it.relativePath.endsWith("Todos.sq") }.contents + + @Test + fun emits_one_sq_file_per_table_under_the_package_path() { + assertEquals(1, files.size) + assertEquals("com/example/db/Todos.sq", files.single().relativePath) + } + + @Test + fun creates_the_table_with_sqlite_storage_classes() { + assertContains(todos, "CREATE TABLE IF NOT EXISTS \"todos\"") + assertContains(todos, "\"id\" TEXT") // uuid → TEXT + assertContains(todos, "\"rank\" INTEGER") // int32 → INTEGER + assertContains(todos, "\"score\" INTEGER") // int64 → INTEGER + assertContains(todos, "\"done\" INTEGER") // boolean → INTEGER (0/1) + assertContains(todos, "\"price\" REAL") // numeric → REAL + assertContains(todos, "\"created_at\" TEXT") // timestamp → TEXT + assertContains(todos, "\"tags\" TEXT") // array → TEXT (JSON) + } + + @Test + fun marks_not_null_and_the_primary_key() { + assertContains(todos, "\"id\" TEXT NOT NULL PRIMARY KEY") // required + + assertContains(todos, "\"title\" TEXT NOT NULL") // required + assertTrue("\"created_at\" TEXT NOT NULL" !in todos, "created_at is not required → nullable") + } + + @Test + fun emits_standard_queries() { + assertContains(todos, "selectAll:\nSELECT * FROM \"todos\";") + assertContains(todos, "selectById:\nSELECT * FROM \"todos\" WHERE \"id\" = ?;") + assertContains(todos, "deleteById:\nDELETE FROM \"todos\" WHERE \"id\" = ?;") + assertContains(todos, "upsert:\nINSERT OR REPLACE INTO \"todos\"") + } + + @Test + fun falls_back_to_an_id_column_when_no_pk_marker_is_present() { + val schema = + """ + { + "definitions": { + "events": { + "required": ["id"], + "properties": { + "id": { "format": "uuid", "type": "string" }, + "name": { "format": "text", "type": "string" } + } + } + } + } + """.trimIndent() + val sq = SupabaseSqlDelightGenerator.generate(schema, "p").single().contents + assertContains(sq, "\"id\" TEXT NOT NULL PRIMARY KEY") + } +}