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")
+ }
+}