From ca1734644bd3b5a51d630ec1a4b356c671d43391 Mon Sep 17 00:00:00 2001 From: Ranbir Singh Date: Mon, 22 Jun 2026 15:05:27 +0530 Subject: [PATCH] Codegen: emit offline-sync adapters + paged queries, read spec from file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the SQLDelight codegen so the whole Supabase -> typed local DB chain generates from one OpenAPI document: - .sq tables gain countAll + offset page + keyset pageAfter queries. - New SupabaseSqlDelightAdapterGenerator emits SupabaseAdapters.kt: a supabaseAdapters(driver) factory wiring each table into an offline-sync LocalStore as its source of truth. Each entry is a generic SqlDelightTableAdapter configured by a column descriptor (kind + nullability) and primary key — no per-table conversion code is generated. Enabled with --format sqldelight --adapters true. - --spec reads the schema from a local file (no network, no key); --url/--key still work. Verified live against supabase-codegen/sample/openapi.json: generates the .sq files + SupabaseAdapters.kt, and the generated adapters compile against the offline-sync runtime. Logged under Unreleased. --- CHANGELOG.md | 15 ++- supabase-codegen/sample/openapi.json | 35 +++++++ .../androidpoet/supabase/codegen/Main.kt | 50 +++++++--- .../SupabaseSqlDelightAdapterGenerator.kt | 95 +++++++++++++++++++ .../codegen/SupabaseSqlDelightGenerator.kt | 4 + .../SupabaseSqlDelightAdapterGeneratorTest.kt | 76 +++++++++++++++ 6 files changed, 258 insertions(+), 17 deletions(-) create mode 100644 supabase-codegen/sample/openapi.json create mode 100644 supabase-codegen/src/main/kotlin/io/github/androidpoet/supabase/codegen/SupabaseSqlDelightAdapterGenerator.kt create mode 100644 supabase-codegen/src/test/kotlin/io/github/androidpoet/supabase/codegen/SupabaseSqlDelightAdapterGeneratorTest.kt 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) + } +}