Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<pk/>` 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 `<pk/>` 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 <openapi.json>` instead of fetching it from a
live project — no network, no key. (`--url`/`--key` still work as before.)

## 0.9.4

Expand Down
35 changes: 35 additions & 0 deletions supabase-codegen/sample/openapi.json
Original file line number Diff line number Diff line change
@@ -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.<pk/>" },
"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.<pk/>" },
"room_id": { "format": "uuid", "type": "string", "description": "Note:\nThis is a Foreign Key to `chat_rooms.id`.<fk table='chat_rooms' column='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" }
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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://<ref>.supabase.co --key <api-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 <file.json>` 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<String>) {
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.
Expand All @@ -50,13 +47,38 @@ public fun main(args: Array<String>) {
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 <file>` if given, otherwise fetches it live
* from `--url`/`--key`. The generators decode the JSON themselves.
*/
private fun loadSpec(options: Map<String, String>): 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 <file>, 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<String>): Map<String, String> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, TableAdapter>`.
*
* 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<String, TableAdapter> =\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 `<pk/>`, else fall to `id`. */
private fun primaryKeyColumn(definition: TableDefinition): String? {
definition.properties.entries
.firstOrNull { (_, c) -> c.description?.contains("<pk/>") == 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")
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down
Original file line number Diff line number Diff line change
@@ -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.<pk/>" },
"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<String, TableAdapter>")
}

@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)
}
}
Loading