diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5d6bdc2..43d7cb6 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,9 +6,18 @@
- **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.
+ key detected from PostgREST's `` description marker, and standard queries: `selectAll`,
+ `selectById`, `upsert`, `deleteById`, `countAll`, plus offset `page` and keyset `pageAfter`
+ pagination — for SQLDelight's own plugin to compile into a typed database. The default
+ `--format kotlin` (one `@Serializable` file per table/enum) is unchanged.
+- **Codegen can emit offline-sync adapters** (`--format sqldelight --adapters true`, or
+ `SupabaseSqlDelightAdapterGenerator`). Writes a `SupabaseAdapters.kt` with a
+ `supabaseAdapters(driver)` factory that wires each generated table into an
+ [offline-sync](https://github.com/AndroidPoet/offline-sync-kmp) `LocalStore` as its source of
+ truth — one generic adapter per table configured by a column descriptor (kind + nullability) and
+ primary key, no per-table conversion code. Requires the offline-sync runtime on the classpath.
+- **Read the schema from a local file** with `--spec ` instead of fetching it from a
+ live project — no network, no key. (`--url`/`--key` still work as before.)
## 0.9.4
diff --git a/supabase-codegen/sample/openapi.json b/supabase-codegen/sample/openapi.json
new file mode 100644
index 0000000..0737ff0
--- /dev/null
+++ b/supabase-codegen/sample/openapi.json
@@ -0,0 +1,35 @@
+{
+ "swagger": "2.0",
+ "info": { "title": "sample PostgREST schema", "version": "12.0.1" },
+ "host": "sample.supabase.co",
+ "basePath": "/",
+ "definitions": {
+ "chat_rooms": {
+ "required": ["id", "name", "is_private", "member_count", "created_at"],
+ "properties": {
+ "id": { "format": "uuid", "type": "string", "description": "Note:\nThis is a Primary Key." },
+ "name": { "format": "text", "type": "string" },
+ "topic": { "format": "text", "type": "string" },
+ "is_private": { "format": "boolean", "type": "boolean" },
+ "member_count": { "format": "int32", "type": "integer" },
+ "settings": { "format": "jsonb", "type": "object" },
+ "created_at": { "format": "timestamp with time zone", "type": "string" }
+ }
+ },
+ "chat_messages": {
+ "required": ["id", "room_id", "body", "edited", "score", "created_at"],
+ "properties": {
+ "id": { "format": "uuid", "type": "string", "description": "Note:\nThis is a Primary Key." },
+ "room_id": { "format": "uuid", "type": "string", "description": "Note:\nThis is a Foreign Key to `chat_rooms.id`." },
+ "sender_id": { "format": "uuid", "type": "string" },
+ "body": { "format": "text", "type": "string" },
+ "reactions": { "format": "jsonb", "type": "object" },
+ "edited": { "format": "boolean", "type": "boolean" },
+ "score": { "format": "int64", "type": "integer" },
+ "price": { "format": "numeric", "type": "number" },
+ "tags": { "type": "array", "items": { "type": "string" } },
+ "created_at": { "format": "timestamp with time zone", "type": "string" }
+ }
+ }
+ }
+}
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 f32acad..8347978 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
@@ -4,34 +4,31 @@ import java.nio.file.Files
import java.nio.file.Path
/**
- * CLI entry point. Fetches a Supabase project's schema and writes one Kotlin file of
- * `@Serializable` models. The output uses only multiplatform-common types, so drop it
+ * CLI entry point. Reads a Supabase project's schema (fetched live, or from a local OpenAPI JSON
+ * file) and writes typed code. The output uses only multiplatform-common types, so it drops
* straight into a `commonMain` source set.
*
* ```
* supabase-codegen --url https://[.supabase.co --key \
* --package com.example.db --out src/commonMain/kotlin
* ```
- * `--url`/`--key` fall back to the `SUPABASE_URL` / `SUPABASE_KEY` environment vars.
+ * `--url`/`--key` fall back to the `SUPABASE_URL` / `SUPABASE_KEY` environment vars. Pass
+ * `--spec ` instead to read the OpenAPI document from disk (no network, no key).
*
* `--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.
+ * SQLDelight's plugin to compile; add `--adapters true` to *also* emit `SupabaseAdapters.kt`, the
+ * glue that wires each table into an [offline-sync](https://github.com/AndroidPoet/offline-sync-kmp)
+ * `LocalStore` (needs the offline-sync runtime on the classpath).
*/
public fun main(args: Array) {
val options = parseArgs(args)
- val url = options["--url"] ?: System.getenv("SUPABASE_URL")
- val key = options["--key"] ?: System.getenv("SUPABASE_KEY")
- require(!url.isNullOrBlank() && !key.isNullOrBlank()) {
- "Provide --url and --key (or SUPABASE_URL / SUPABASE_KEY). " +
- "Use a key whose role can read the tables you want generated."
- }
val packageName = options["--package"] ?: "supabase.generated"
val outDir = options["--out"] ?: "build/generated/supabase"
val format = options["--format"]?.lowercase() ?: "kotlin"
+ val emitAdapters = options["--adapters"]?.toBooleanStrictOrNull() ?: false
- println("Fetching schema from ${url.trimEnd('/')}/rest/v1/ …")
- val spec = SchemaFetcher.fetch(url, key)
+ val spec = loadSpec(options)
val packageDir = Path.of(outDir, *packageName.split('.').toTypedArray())
// Generate, clearing prior output first so a dropped table/enum can't leave a stale file.
@@ -50,13 +47,38 @@ public fun main(args: Array) {
else -> error("Unknown --format '$format' (use 'kotlin' or 'sqldelight').")
}
- for (file in files) {
+ val written = files.toMutableList()
+ if (format == "sqldelight" && emitAdapters) {
+ written += SupabaseSqlDelightAdapterGenerator.generate(spec, packageName)
+ }
+
+ for (file in written) {
val target = Path.of(outDir, file.relativePath)
Files.createDirectories(target.parent)
Files.writeString(target, file.contents)
println("✓ Wrote $target")
}
- println("✓ Generated ${files.size} $format file(s)")
+ println("✓ Generated ${written.size} file(s) ($format${if (emitAdapters) " + adapters" else ""})")
+}
+
+/**
+ * Loads the OpenAPI document (raw JSON) from `--spec ` if given, otherwise fetches it live
+ * from `--url`/`--key`. The generators decode the JSON themselves.
+ */
+private fun loadSpec(options: Map): String {
+ val specFile = options["--spec"]
+ if (specFile != null) {
+ println("Reading schema from $specFile …")
+ return Files.readString(Path.of(specFile))
+ }
+ val url = options["--url"] ?: System.getenv("SUPABASE_URL")
+ val key = options["--key"] ?: System.getenv("SUPABASE_KEY")
+ require(!url.isNullOrBlank() && !key.isNullOrBlank()) {
+ "Provide --spec , or --url and --key (or SUPABASE_URL / SUPABASE_KEY). " +
+ "Use a key whose role can read the tables you want generated."
+ }
+ println("Fetching schema from ${url.trimEnd('/')}/rest/v1/ …")
+ return SchemaFetcher.fetch(url, key)
}
private fun parseArgs(args: Array): Map {
diff --git a/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SupabaseSqlDelightAdapterGenerator.kt b/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SupabaseSqlDelightAdapterGenerator.kt
new file mode 100644
index 0000000..1c6e3a8
--- /dev/null
+++ b/supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SupabaseSqlDelightAdapterGenerator.kt
@@ -0,0 +1,95 @@
+package io.github.androidpoet.supabase.codegen
+
+import kotlinx.serialization.json.Json
+
+/**
+ * Generates the glue that wires each SQLDelight table (see [SupabaseSqlDelightGenerator]) into an
+ * [offline-sync](https://github.com/AndroidPoet/offline-sync-kmp) `LocalStore` as its **single
+ * source of truth**: one `SupabaseAdapters.kt` holding a `supabaseAdapters(driver)` factory that
+ * returns a `Map`.
+ *
+ * Each entry is a generic `SqlDelightTableAdapter` configured with a trivial column descriptor
+ * (name + storage kind + nullability) and the primary key — *no* per-table conversion logic is
+ * generated, because that lives once in offline-sync's adapter. The generated file therefore only
+ * needs the offline-sync runtime + SQLDelight runtime on the classpath to compile.
+ */
+public object SupabaseSqlDelightAdapterGenerator {
+ private val json =
+ Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+ }
+
+ /** The offline-sync runtime package the generated factory imports from. */
+ private const val RUNTIME = "io.github.androidpoet.offlinesync"
+
+ /** Parses the OpenAPI [specJson] and returns the single `SupabaseAdapters.kt` [GeneratedFile]. */
+ public fun generate(specJson: String, packageName: String): GeneratedFile =
+ generate(json.decodeFromString(OpenApiSpec.serializer(), specJson), packageName)
+
+ internal fun generate(spec: OpenApiSpec, packageName: String): GeneratedFile {
+ val tables = spec.definitions.filterValues { it.properties.isNotEmpty() }
+ val sb = StringBuilder()
+ sb.append("package $packageName\n\n")
+ sb.append("import app.cash.sqldelight.db.SqlDriver\n")
+ sb.append("import $RUNTIME.TableAdapter\n")
+ sb.append("import $RUNTIME.store.Column\n")
+ sb.append("import $RUNTIME.store.ColumnKind\n")
+ sb.append("import $RUNTIME.store.SqlDelightTableAdapter\n\n")
+ sb.append("// Generated from the Supabase schema. Do not edit by hand.\n")
+ sb.append("// Each table is wired into an offline-sync LocalStore as its source of truth.\n")
+ sb.append("public fun supabaseAdapters(driver: SqlDriver): Map =\n")
+ sb.append(" mapOf(\n")
+ for ((tableName, definition) in tables) {
+ val pk = primaryKeyColumn(definition) ?: definition.properties.keys.first()
+ sb.append(" ${quoteString(tableName)} to SqlDelightTableAdapter(\n")
+ sb.append(" driver = driver,\n")
+ sb.append(" table = ${quoteString(tableName)},\n")
+ sb.append(" columns =\n")
+ sb.append(" listOf(\n")
+ for ((name, column) in definition.properties) {
+ val kind = columnKind(column)
+ val nullable = name !in definition.required
+ sb.append(" Column(${quoteString(name)}, ColumnKind.$kind, nullable = $nullable),\n")
+ }
+ sb.append(" ),\n")
+ sb.append(" pk = ${quoteString(pk)},\n")
+ sb.append(" ),\n")
+ }
+ sb.append(" )\n")
+ return GeneratedFile("${packageName.replace('.', '/')}/SupabaseAdapters.kt", sb.toString())
+ }
+
+ /** Mirrors [SupabaseSqlDelightGenerator]: PostgREST marks the PK with ``, else fall to `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" }
+ }
+
+ /**
+ * Maps a Postgres column to a [ColumnKind] — the SQLite storage class *plus* how the value maps
+ * to/from JSON. Unlike the raw `.sq` storage class, `boolean` and `json`/`jsonb`/array stay
+ * distinct from plain `INTEGER`/`TEXT` so the adapter can round-trip them faithfully.
+ */
+ private fun columnKind(column: ColumnProperty): String {
+ if (column.type == "array") return "JSON" // arrays stored as JSON text
+ return when (column.format?.lowercase()?.trim() ?: column.type?.lowercase()?.trim()) {
+ in BOOL_TYPES -> "BOOL"
+ in INT_TYPES, in LONG_TYPES -> "INTEGER"
+ in REAL_TYPES -> "REAL"
+ in JSON_TYPES -> "JSON"
+ // text, varchar, uuid, timestamp*, date, money, enum, … → TEXT
+ else -> "TEXT"
+ }
+ }
+
+ private fun quoteString(value: String): String = "\"$value\""
+
+ 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")
+ private val JSON_TYPES = setOf("json", "jsonb")
+}
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
index 8e08ebc..a448995 100644
--- 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
@@ -67,9 +67,13 @@ public object SupabaseSqlDelightGenerator {
sb.append("CREATE TABLE IF NOT EXISTS ${quote(tableName)} (\n").append(columns).append("\n);\n")
sb.append("\nselectAll:\nSELECT * FROM ${quote(tableName)};\n")
+ sb.append("\ncountAll:\nSELECT COUNT(*) 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")
+ // Offset + keyset pagination, ordered by the primary key.
+ sb.append("\npage:\nSELECT * FROM ${quote(tableName)} ORDER BY ${quote(pk)} LIMIT ? OFFSET ?;\n")
+ sb.append("\npageAfter:\nSELECT * FROM ${quote(tableName)} WHERE ${quote(pk)} > ? ORDER BY ${quote(pk)} LIMIT ?;\n")
}
val cols = definition.properties.keys
val colList = cols.joinToString(", ") { quote(it) }
diff --git a/supabase-codegen/src/test/kotlin/io/github/androidpoet/supabase/codegen/SupabaseSqlDelightAdapterGeneratorTest.kt b/supabase-codegen/src/test/kotlin/io/github/androidpoet/supabase/codegen/SupabaseSqlDelightAdapterGeneratorTest.kt
new file mode 100644
index 0000000..eec6ef0
--- /dev/null
+++ b/supabase-codegen/src/test/kotlin/io/github/androidpoet/supabase/codegen/SupabaseSqlDelightAdapterGeneratorTest.kt
@@ -0,0 +1,76 @@
+package io.github.androidpoet.supabase.codegen
+
+import kotlin.test.Test
+import kotlin.test.assertContains
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class SupabaseSqlDelightAdapterGeneratorTest {
+ private val fixture =
+ """
+ {
+ "definitions": {
+ "todos": {
+ "required": ["id", "title", "done", "rank", "score"],
+ "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" },
+ "settings": { "format": "jsonb", "type": "object" },
+ "tags": { "type": "array", "items": { "type": "string" } },
+ "created_at": { "format": "timestamp with time zone", "type": "string" }
+ }
+ }
+ }
+ }
+ """.trimIndent()
+
+ private val file = SupabaseSqlDelightAdapterGenerator.generate(fixture, packageName = "com.example.db")
+ private val src = file.contents
+
+ @Test
+ fun emits_one_adapters_file_under_the_package_path() {
+ assertEquals("com/example/db/SupabaseAdapters.kt", file.relativePath)
+ assertContains(src, "package com.example.db")
+ assertContains(src, "public fun supabaseAdapters(driver: SqlDriver): Map")
+ }
+
+ @Test
+ fun imports_the_offline_sync_runtime() {
+ assertContains(src, "import io.github.androidpoet.offlinesync.TableAdapter")
+ assertContains(src, "import io.github.androidpoet.offlinesync.store.Column")
+ assertContains(src, "import io.github.androidpoet.offlinesync.store.ColumnKind")
+ assertContains(src, "import io.github.androidpoet.offlinesync.store.SqlDelightTableAdapter")
+ }
+
+ @Test
+ fun wires_the_table_with_its_primary_key() {
+ assertContains(src, "\"todos\" to SqlDelightTableAdapter(")
+ assertContains(src, "table = \"todos\"")
+ assertContains(src, "pk = \"id\"")
+ }
+
+ @Test
+ fun classifies_every_column_kind() {
+ assertContains(src, "Column(\"title\", ColumnKind.TEXT, nullable = false)") // text + required
+ assertContains(src, "Column(\"done\", ColumnKind.BOOL, nullable = false)") // boolean -> BOOL, not INTEGER
+ assertContains(src, "Column(\"rank\", ColumnKind.INTEGER, nullable = false)") // int32
+ assertContains(src, "Column(\"score\", ColumnKind.INTEGER, nullable = false)") // int64
+ assertContains(src, "Column(\"price\", ColumnKind.REAL, nullable = true)") // numeric + not required -> nullable
+ assertContains(src, "Column(\"settings\", ColumnKind.JSON, nullable = true)") // jsonb -> JSON
+ assertContains(src, "Column(\"tags\", ColumnKind.JSON, nullable = true)") // array -> JSON
+ assertContains(src, "Column(\"created_at\", ColumnKind.TEXT, nullable = true)") // timestamp -> TEXT
+ }
+
+ @Test
+ fun sqldelight_schema_now_emits_paged_queries() {
+ val sq = SupabaseSqlDelightGenerator.generate(fixture, "com.example.db").single().contents
+ assertContains(sq, "countAll:\nSELECT COUNT(*) FROM \"todos\";")
+ assertContains(sq, "page:\nSELECT * FROM \"todos\" ORDER BY \"id\" LIMIT ? OFFSET ?;")
+ assertContains(sq, "pageAfter:\nSELECT * FROM \"todos\" WHERE \"id\" > ? ORDER BY \"id\" LIMIT ?;")
+ assertTrue("countAll" in sq)
+ }
+}
]