rp-supabase-codegen 0.5.0

Build-script-first Rust bindings for PostgreSQL and Supabase schemas
Documentation

rp-supabase-codegen

Generate Rust bindings for Supabase and PostgreSQL schemas in build.rs. No generator CLI is required.

The generator reads a versioned JSON snapshot or introspects PostgreSQL directly. It emits schema modules with table rows, insert and update payloads, enums, composites, and named-argument RPC bindings.

Offline builds

Keep a schema snapshot in source control. Ordinary builds need no database or credentials.

[dependencies]
rp-supabase-client = "0.5"
serde = { version = "1", features = ["derive"] }
serde_json = { version = "1", features = ["arbitrary_precision"] }
# Add these when your schema has UUID or temporal columns.
uuid = { version = "1", features = ["serde"] }
chrono = { version = "0.4", features = ["serde"] }

[build-dependencies]
rp-supabase-codegen = "0.5"
// build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
    rp_supabase_codegen::Generator::new()
        .from_snapshot("schema.json")?
        .write_to_out_dir("database.rs")?;
    Ok(())
}
pub mod database {
    rp_supabase_client::include_schema!("database.rs");
}

The generator registers the snapshot with Cargo's change detection. It writes only the requested file and leaves identical output unchanged. Rust formatting uses prettyplease, not an external formatter.

See the complete snapshot and executable example. The snapshot format is the public model::Snapshot type. Version 1 rejects unknown fields and unsupported versions.

Direct database introspection

Enable the database build-dependency feature. Database and TLS dependencies do not enter the application's runtime dependency graph.

[build-dependencies]
rp-supabase-codegen = { version = "0.5", features = ["database"] }
// build.rs
fn main() -> Result<(), Box<dyn std::error::Error>> {
    println!("cargo::rerun-if-changed=supabase/migrations");
    rp_supabase_codegen::Generator::new()
        .schemas(["public", "api"])
        .from_database_env("SUPABASE_CODEGEN_DATABASE_URL")?
        .write_to_out_dir("database.rs")?;
    Ok(())
}

Use a PostgreSQL connection string, not an anon key or service-role API key. Prefer a direct or session-pooler connection. Use a schema-owning role for complete metadata. Introspection reads catalogs in a read-only repeatable-read transaction. It does not read application rows or function bodies.

TLS verifies certificates and hostnames. The driver upgrades sslmode=prefer to require, preventing plaintext fallback. Use sslmode=disable only for a trusted local database. Connection errors do not print the connection string or raw server diagnostics.

Cargo cannot detect remote DDL. Track your migration directory, or add a refresh environment variable in your build script and change it after remote migrations. Do not assume every cargo build introspects again.

Live failures never fall back to cached metadata. Choose offline or live input explicitly.

Creating a snapshot

Call the same library outside build.rs to refresh the committed snapshot:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    rp_supabase_codegen::Generator::new()
        .from_database_env("SUPABASE_CODEGEN_DATABASE_URL")?
        .write_snapshot("schema.json")?;
    Ok(())
}

Run this Rust code in a small host-side example or maintenance task. Do not write committed snapshots from an ordinary build script. Bindings::snapshot() also exposes typed metadata for your own tooling.

Generated contracts

For public.messages, the generator emits:

  • public::tables::messages::Row, with every readable field.
  • public::tables::messages::Insert, with required fields and omittable default or nullable fields.
  • public::tables::messages::Update, with omittable writable fields and Default.

Rows implement schema::Relation. The schema::from::<Row>(client) helper selects the correct schema and relation. It consumes the client, so cloning remains explicit. It returns the existing rp_postgrest::Builder.

use rp_supabase_client::{PostgerstResponse, schema};
use database::public::tables::messages::Row;

let response = schema::from::<Row>(client.clone())
    .select("*")
    .execute()
    .await?;
let rows = PostgerstResponse::<Vec<Row>>::new(response).json().await??;

Serialize an Insert or Update with serde_json::to_string before passing it to .insert or .update. The client retains its existing nested transport and PostgREST error results.

Omission and null

schema::Field<T> has Omit and Value(T). Nullable writes use Field<Option<T>>:

Rust value Request
Field::Omit No key.
Field::Value(None) Explicit JSON null.
Field::Value(Some(value)) Explicit value.

Non-null writes use Field<T>, so they cannot send null. Generated serializers omit Omit fields. Serializing Omit without the containing-field skip attribute returns an error.

Generated expression columns and ALWAYS identity columns appear only in rows. BY DEFAULT identities remain optional on insert and writable on update. Domain defaults and not-null constraints participate in insert requirements.

Bulk inserts with different omitted keys require care. PostgREST's Prefer: missing=default controls missing-key defaults in bulk requests. Generated omission alone does not change server preferences.

Types

Integer widths match PostgreSQL. UUID and temporal columns use uuid and chrono. Timestamps with time zones use DateTime<FixedOffset>. JSON columns use serde_json::Value.

Numeric columns use serde_json::Number. Enable arbitrary_precision when decoding outside the client. PostgerstResponse::json enables this support through the client's dependency and preserves numeric precision. Non-finite numeric values need a custom mapping because they are not ordinary JSON numbers.

Bytea, network, interval, range, geometric, and text-search columns use their JSON string representation. SQL bytea does not map to a JSON byte array.

schema::Array<T> preserves null elements and variable rank with Elements(Vec<Option<T>>) and Nested(Vec<Array<T>>). Use another outer Option for a nullable column. PostgreSQL does not enforce declared array dimensions. For JSON-valued array elements, JSON itself does not distinguish nested SQL arrays from JSON arrays stored as elements.

Enums preserve exact database labels with Serde renames. Standalone and relation-row composites have nullable members. They remain distinct from a full table row's constraints. Domains preserve their qualified identity for overrides and otherwise resolve to their base type. Unknown types fail generation and require type_override.

Functions

Named-object RPCs emit public::functions::<name>::Args, Returns, and Function. Overloads use deterministic numbered modules, such as lookup_0 and lookup_1, while retaining the original RPC name.

Required arguments use Option<T> because PostgreSQL functions can accept null. Default arguments use Field<Option<T>>, distinguishing omission from null. Scalar results are nullable. Set results use vectors. Non-set composite and OUT results use a single struct. OUT and TABLE fields have a generated Record type, including singleton OUT and INOUT results.

let request = schema::rpc::<database::public::functions::echo_message::Function>(
    client.clone(),
    &database::public::functions::echo_message::Args {
        message: Some("hello".to_owned()),
    },
)?;

PostgREST cannot distinguish every SQL overload by its JSON argument names. Generated overload types do not change that server limitation.

Custom derives, attributes, and preludes

let generator = rp_supabase_codegen::Generator::new()
    .prelude("use ::std::string::String as DatabaseText;")
    .type_override("pg_catalog.text", "DatabaseText")
    .derive("PartialEq")
    .attribute("#[allow(dead_code)]")
    .type_attribute(
        "public.tables.messages.Insert",
        "#[derive(::typed_builder::TypedBuilder)]",
    );

Global derives and attributes apply to generated data structs and enums. Per-type attributes apply only to their named target. Use per-type attributes for macros that cannot operate on enums. The executable example compiles and uses TypedBuilder on its insert payload.

Target paths use generated Rust names. Keyword modules use raw spelling, such as public.tables.r#type.Insert. Record-result targets end in .Record. Unknown targets and invalid Rust syntax fail before output is written.

Prelude items appear at the generated root. Nested modules import their parent, so custom types remain available. schema::prelude exports runtime helpers and include_schema!. Use the generated schema modules explicitly to avoid cross-schema name collisions.

runtime_path("::renamed_client::schema") supports a renamed client dependency. Generated Serde derives and helper attributes require the normal serde dependency name.

Scope and safety

View and materialized-view bindings are read-only, even when PostgreSQL permits writes to a view. Their row fields are conservatively nullable.

The named-object RPC generator excludes unnamed input arguments, trigger functions, polymorphic pseudotypes, and dynamic records without named output fields. Those need different request bodies or explicit application-specific contracts.

Bindings do not validate CHECK constraints, enforce RLS, grant permissions, or type-check arbitrary PostgREST projections and joins. Decode custom projections into your own structs. Additional schema modules can contain referenced types even when their schema is not exposed through PostgREST.

Verification example

cargo run -p rp-supabase-codegen-example

This compiles a real build.rs consumer and exercises generated serialization and decoding offline. For live verification, apply its smoke.sql to a disposable database, expose public through PostgREST, and set SUPABASE_CODEGEN_DATABASE_URL and SUPABASE_CODEGEN_API_URL.

The research note records source comparisons and design decisions.