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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<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.

## 0.9.4

> Codegen-only release: `supabase-codegen` + `supabase-codegen-gradle` published at `0.9.4`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) {
val options = parseArgs(args)
Expand All @@ -27,25 +28,35 @@ public fun main(args: Array<String>) {
}
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)
Files.createDirectories(target.parent)
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<String>): Map<String, String> {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<GeneratedFile> =
generate(json.decodeFromString(OpenApiSpec.serializer(), specJson), packageName)

internal fun generate(spec: OpenApiSpec, packageName: String): List<GeneratedFile> {
val packagePath = packageName.replace('.', '/')
val files = mutableListOf<GeneratedFile>()
// 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<String, String>()

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 `<pk/>` to its `description`
* (e.g. `"Note:\nThis is a Primary Key.<pk/>"`). Fall back to a column literally named `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" }
}

/** 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")
}
Original file line number Diff line number Diff line change
@@ -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 `<pk/>` 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.<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" },
"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 + <pk/>
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")
}
}
Loading