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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@

## Unreleased

### Added

- **Codegen auto-sync** — `supabaseCodegen { autoSync = true }` wires model generation into every
Kotlin compile so the generated models always track the live schema (the SQLDelight/RevenueCat
"generated code is build output, never hand-edited" model). Output goes to `build/generated/supabase`
(gitignore it) and the generator owns it. When `SUPABASE_URL`/`SUPABASE_KEY` are unset, auto-sync
is skipped (with a warning) rather than failing, so offline/CI builds without credentials still
build; a configured-but-unreachable fetch still fails the build. The default stays the on-demand
`generateSupabaseModels` task (commit the output, like `supabase gen types`).

### Changed

- **Codegen now emits one file per model in typed sub-packages** instead of a single
Expand Down
66 changes: 66 additions & 0 deletions samples/chat-compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,72 @@ android {
}
}

// --- Supabase model auto-sync -------------------------------------------------------------
// Regenerate @Serializable models from the LIVE schema into build/generated/supabase before
// every build, so they always track the database. Output lives under build/ (gitignored) and is
// never hand-edited — the SQLDelight "generated code is build output" model. Skips gracefully when
// SUPABASE_URL/SUPABASE_KEY are unset, so the sample still configures offline / without an instance.
//
// (A standalone consumer would instead apply the published plugin with `autoSync = true`; this
// sample drives the codegen CLI directly because a project can't apply a sibling module's plugin
// within the same Gradle build.)
// Resolve url/key via providers at configuration time (tracked as config-cache inputs) so we
// avoid storing script-capturing lambdas on the task, which the configuration cache rejects.
val supabaseGeneratedDir =
layout.buildDirectory
.dir("generated/supabase")
.get()
.asFile
val supabaseUrlForGen =
providers
.gradleProperty("SUPABASE_URL")
.orElse(providers.environmentVariable("SUPABASE_URL"))
.orElse("")
.get()
val supabaseKeyForGen =
providers
.gradleProperty("SUPABASE_KEY")
.orElse(providers.environmentVariable("SUPABASE_KEY"))
.orElse("")
.get()
val supabaseCodegenConfigured = supabaseUrlForGen.isNotBlank() && supabaseKeyForGen.isNotBlank()

val codegenClasspath: Configuration by configurations.creating
dependencies { codegenClasspath(project(":supabase-codegen")) }

val generateSupabaseModels by tasks.registering(JavaExec::class) {
group = "supabase"
description = "Auto-sync @Serializable models from the live Supabase schema"
classpath = codegenClasspath
mainClass.set("io.github.androidpoet.supabase.codegen.MainKt")
args(
"--url",
supabaseUrlForGen,
"--key",
supabaseKeyForGen,
"--package",
"io.github.androidpoet.supabase.sample.chat.generated",
"--out",
supabaseGeneratedDir.path,
)
// No declared outputs → JavaExec always runs (the remote schema can change with no local
// change). Disabled (not failed) when unconfigured so the sample still builds offline.
enabled = supabaseCodegenConfigured
}

if (!supabaseCodegenConfigured) {
logger.lifecycle("supabase codegen: SUPABASE_URL/SUPABASE_KEY not set — model auto-sync disabled for this build.")
}

// Compile the generated models, and regenerate them first on every Kotlin compile.
android.sourceSets
.getByName("main")
.java
.srcDir(supabaseGeneratedDir)
tasks
.matching { it.name.startsWith("compile") && it.name.contains("Kotlin") }
.configureEach { dependsOn(generateSupabaseModels) }

dependencies {
implementation(project(":supabase-client"))
implementation(project(":supabase-auth"))
Expand Down

This file was deleted.

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@ import org.gradle.work.DisableCachingByDefault
* url.set(providers.environmentVariable("SUPABASE_URL"))
* key.set(providers.environmentVariable("SUPABASE_KEY"))
* packageName.set("com.example.db")
* outputDir.set(layout.projectDirectory.dir("src/commonMain/kotlin"))
* autoSync.set(true) // regenerate from the live schema on every build
* }
* ```
*
* `url`/`key` fall back to the `SUPABASE_URL` / `SUPABASE_KEY` environment variables.
*
* With [autoSync] on, the models are regenerated into [outputDir] (defaults to
* `build/generated/supabase`, which you should gitignore) before every Kotlin compile, so they
* always track the database — the SQLDelight/RevenueCat "generated code is build output, never
* edited" model. Add the output as a source directory, e.g.
* `kotlin.sourceSets.commonMain { kotlin.srcDir(layout.buildDirectory.dir("generated/supabase")) }`.
*/
public abstract class SupabaseCodegenExtension {
/** Project URL, e.g. `https://<ref>.supabase.co`. Falls back to `SUPABASE_URL`. */
Expand All @@ -39,13 +45,22 @@ public abstract class SupabaseCodegenExtension {

/** Where to write the generated files. Defaults to `build/generated/supabase`. */
public abstract val outputDir: DirectoryProperty

/**
* When `true`, regenerate the models from the live schema before every Kotlin compile so they
* stay in sync with the database (output goes to [outputDir]; gitignore it and never hand-edit).
* Requires network + a key on every build; if neither `url`/`key` nor the env vars are set, the
* build skips codegen (with a warning) rather than failing, so it stays buildable offline/in CI
* without credentials. A reachable-but-failing fetch still fails the build. Defaults to `false`
* (the on-demand `generateSupabaseModels` task, committed like `supabase gen types`).
*/
public abstract val autoSync: Property<Boolean>
}

/**
* Fetches the Supabase schema and writes one Kotlin file per table/enum of `@Serializable` models. This is
* an on-demand task — it is deliberately NOT wired into `compileKotlin`, because the schema
* lives behind the network and needs a key, and you don't want either on every build. Run it
* when your schema changes (and commit the result, the way `supabase gen types` is used).
* Fetches the Supabase schema and writes one Kotlin file per table/enum of `@Serializable` models.
* By default this is an on-demand task; with `supabaseCodegen.autoSync = true` it is wired to run
* before every Kotlin compile so the models always track the live schema.
*
* Everything is `@Internal`: this is a side-effecting command whose real input (the remote
* schema) Gradle can't observe, so tracking inputs/outputs would only add footguns — notably
Expand All @@ -67,21 +82,37 @@ public abstract class GenerateSupabaseModelsTask : DefaultTask() {
@get:Internal
public abstract val outputDir: DirectoryProperty

/** Set by the plugin from `supabaseCodegen.autoSync`; controls the missing-credentials behaviour. */
@get:Internal
public abstract val autoSync: Property<Boolean>

@TaskAction
public fun generate() {
val projectUrl =
url.orNull?.takeIf { it.isNotBlank() }
?: throw GradleException("supabaseCodegen.url is not set (or export SUPABASE_URL).")
val apiKey =
key.orNull?.takeIf { it.isNotBlank() }
?: throw GradleException(
"supabaseCodegen.key is not set (or export SUPABASE_KEY). Use a key that can read your tables.",
val auto = autoSync.getOrElse(false)
val projectUrl = url.orNull?.takeIf { it.isNotBlank() }
val apiKey = key.orNull?.takeIf { it.isNotBlank() }

// In auto-sync mode an unconfigured build (no url/key) must not fail — that would break
// offline/CI builds and contributors who don't have a Supabase instance. Skip instead.
// A configured-but-unreachable fetch below still throws and fails the build.
if (projectUrl == null || apiKey == null) {
if (auto) {
logger.warn(
"supabaseCodegen: SUPABASE_URL/SUPABASE_KEY not set — skipping auto-sync codegen for this build.",
)
val pkg = packageName.get()
return
}
throw GradleException(
when (projectUrl) {
null -> "supabaseCodegen.url is not set (or export SUPABASE_URL)."
else -> "supabaseCodegen.key is not set (or export SUPABASE_KEY). Use a key that can read your tables."
},
)
}

logger.lifecycle("Fetching Supabase schema from ${projectUrl.trimEnd('/')}/rest/v1/ …")
val spec = SchemaFetcher.fetch(projectUrl, apiKey)
val files = SupabaseModelGenerator.generate(spec, pkg)
val files = SupabaseModelGenerator.generate(spec, packageName.get())

val root = outputDir.get().asFile
for (file in files) {
Expand All @@ -100,17 +131,31 @@ public class SupabaseCodegenPlugin : Plugin<Project> {
val extension = project.extensions.create("supabaseCodegen", SupabaseCodegenExtension::class.java)
extension.packageName.convention("supabase.generated")
extension.outputDir.convention(project.layout.buildDirectory.dir("generated/supabase"))
extension.autoSync.convention(false)

val generateTask =
project.tasks.register("generateSupabaseModels", GenerateSupabaseModelsTask::class.java) { task ->
task.group = "supabase"
task.description = "Generates Kotlin @Serializable models from your Supabase schema"
task.url.set(extension.url.orElse(project.providers.environmentVariable("SUPABASE_URL")))
task.key.set(extension.key.orElse(project.providers.environmentVariable("SUPABASE_KEY")))
task.packageName.set(extension.packageName)
task.outputDir.set(extension.outputDir)
task.autoSync.set(extension.autoSync)
// The remote schema can change without any local input changing, so never report
// up-to-date — when this runs, it means it.
task.outputs.upToDateWhen { false }
}

project.tasks.register("generateSupabaseModels", GenerateSupabaseModelsTask::class.java) { task ->
task.group = "supabase"
task.description = "Generates Kotlin @Serializable models from your Supabase schema"
task.url.set(extension.url.orElse(project.providers.environmentVariable("SUPABASE_URL")))
task.key.set(extension.key.orElse(project.providers.environmentVariable("SUPABASE_KEY")))
task.packageName.set(extension.packageName)
task.outputDir.set(extension.outputDir)
// The remote schema can change without any local input changing, so never report
// up-to-date — when you run the task, you mean it.
task.outputs.upToDateWhen { false }
// Auto-sync: regenerate before every Kotlin compile so the models track the live schema.
// Done in afterEvaluate so the Kotlin/Android compile tasks (and the user's autoSync choice)
// are registered. The generated dir still has to be added as a source set by the consumer.
project.afterEvaluate {
if (extension.autoSync.getOrElse(false)) {
project.tasks
.matching { it.name.startsWith("compile") && it.name.contains("Kotlin") }
.configureEach { it.dependsOn(generateTask) }
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -49,4 +49,37 @@ class SupabaseCodegenFunctionalTest {
"task must be configuration-cache compatible, got:\n${result.output}",
)
}

@Test
fun auto_sync_skips_instead_of_failing_when_credentials_are_missing() {
// With autoSync on, an unconfigured build (no url/key) must SUCCEED with a skip warning,
// so offline/CI builds and credential-less contributors aren't broken.
val dir = createTempDirectory("codegen-ft-auto").toFile()
File(dir, "settings.gradle.kts").writeText("""rootProject.name = "ft-auto"""")
File(dir, "build.gradle.kts").writeText(
"""
plugins { id("io.github.androidpoet.supabase.codegen") }

supabaseCodegen {
url.set("")
key.set("")
packageName.set("com.example.db")
autoSync.set(true)
}
""".trimIndent(),
)

val result =
GradleRunner
.create()
.withProjectDir(dir)
.withPluginClasspath()
.withArguments("generateSupabaseModels")
.build()

assertTrue(
"skipping auto-sync codegen" in result.output,
"expected a skip warning, got:\n${result.output}",
)
}
}
Loading