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.
[]
= "0.9"
= { = "1", = ["derive"] }
= { = "1", = ["arbitrary_precision"] }
# Add these when your schema has UUID or temporal columns.
= { = "1", = ["serde"] }
= { = "0.4", = ["serde"] }
[]
= "0.9"
Generated bindings only need the schema runtime. Applications with their own HTTP client and
response handling can use rp-supabase-client = { version = "0.9", default-features = false }.
That leaves out authentication and does not enable serde_json/arbitrary_precision.
// build.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 public model::Snapshot format is version 2. It requires explicit primary-key, unique-key, foreign-key, and partition facts for every table. Version 1 snapshots require regeneration, not an empty relationship graph.
Direct database introspection
Enable the database build-dependency feature. Database and TLS dependencies do not enter the application's runtime dependency graph.
[]
= { = "0.9", = ["database"] }
// build.rs
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:
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 andDefault.
Rows implement schema::Relation and schema::Projection<Row>. Each relation module exposes
query(client). The query retains its relation and response type until decoding.
It consumes the client, so cloning remains explicit. Regenerate previously emitted Rust bindings
with codegen 0.9 before using runtime 0.9. The snapshot format remains version 2.
JSON and JSONB column markers, including domains over those types, implement schema::JsonColumn.
use messages;
let rows = query.fetch.await?;
// rows has type Vec<messages::Row>.
Typed projections and filters
Use a query-first selection for local results. Do not repeat Rust field types or a selection string:
use ;
let selected = ;
let rows = selected.query
.eq
.fetch
.await?;
Each generated columns marker records its owning relation, readable field type, non-null
filter type, and exact SQL name. The compiler rejects unknown columns, columns from another
relation, mismatched scalar values, and projections from another relation.
select! generates local records with the schema's types and nullability. Keep projection!
for named public results and reusable children, selecting them with .select(schema::named::<_, Dto>()).
Both use exact SQL keys, reject missing or duplicate selected fields, and ignore extra response keys.
Scalar comparisons support eq, neq, gt, gte, lt, and lte.
Values must implement Display and match the column's filter type through Borrow.
String columns accept &str without a caller allocation. Generated enums display their exact database labels.
Use is_null(column) for nullable fields, not eq(column, &None).
The runtime escapes column identifiers and lets the HTTP client encode scalar values once.
fetch() infers Vec<P> for P: Projection<R>. fetch_one() infers P and uses PostgREST's
single-row response semantics. Both methods return the owned rp_postgrest::Error directly.
Error::postgrest_body() exposes the decoded code, message, details, and hint.
postgrest_error() exposes the canonical structured error. Response metadata retains the observed
status, headers, and effective URL. Malformed error envelopes and successful JSON decoding failures
have separate error variants.
One DTO can implement the projection contract for several relations:
projection!
The selected columns must exist in every relation with identical Rust value types and exact SQL response keys. Unselected columns may differ. Relationship handles still enforce their source, target, and selected child projection.
Typed queries support order(column, Order::Asc), order_with_nulls, borrowed literal in_
values, and json_text_eq(json_column, &["fingerprint"], value)?. JSON path keys are escaped
identifiers. Scalar comparison values remain literal; IN values use list-specific quoting.
limit and inclusive range produce a paged read query that cannot become a typed mutation.
fetch_with_count(Count::Exact) returns rows and the server total; count(Count::Exact) reads
the total without decoding rows. Missing or invalid totals are errors, never an invented zero.
schema::params renders the same unencoded query pairs without a Postgrest instance:
use params;
projection!
let pairs = ;
let request = http.get.query;
It also provides typed comparison, null, IN, order, and JSON text-path helpers. Pass pairs directly to your HTTP client's query serializer. Do not percent-encode them first.
Typed relationships
Relationship markers identify an exact FK constraint. Forward markers use the normalized
constraint name. Reverse markers add the referencing table name, such as orders_orders_customer.
The generator emits markers for nonpartition base tables in the same explicitly selected schema.
Referenced types can add dependency schemas, but do not add endpoints.
use ;
projection!
projection!
projection!
let rows = query
.select
.embedded
.embedded
.exists
.fetch
.await?;
Aliases become response keys and typed filter handles. The compiler checks the source relation,
target relation, and exact selected child projection when composing handles.
An empty(...) selection has a handle but no decoded field.
To-one fields have type Option<Child>. Reverse fields have type Vec<Child>, unless their FK
column set exactly matches a recorded primary key or unique constraint, which produces Option<Child>.
These types remain conservative with inner. RLS or child filters can hide a non-null FK target.
Every visible selected embed key is required, including one whose value is null.
Child filters normally preserve parent rows. inner filters rows at the embed's parent level.
Use exists(handle) or not_exists(handle) for relationship existence or anti-existence.
Scoped filters support these predicates and further .embedded(...) scopes.
Any embedded filter or existence predicate locks the selection. Choose .select(selection) first.
Root filters and one typed mutation remain available after locking.
For UPDATE and DELETE, use root column predicates to constrain affected rows. Child filters shape returned representations. PostgREST 16.2 rejects embed-alias existence predicates as DELETE row conditions. The runtime reports the native execution error; it does not translate an embed into an FK null check.
Nested selections append into one parent buffer using Projection<R>::SELECT_LEN and
write_selection. Custom Projection<R> implementations must supply both for each relation.
Local nested selections infer the child relation from the FK:
use ;
let selected = select!;
let billing = selected.orders.then;
let rows = selected.query
.embedded
.exists
.fetch.await?;
The generator emits finite ColumnByKey and RelationshipByKey implementations directly
on each schema-qualified row. Keys encode the complete normalized Rust identifier as character types.
No global key registry, identifier hashing, or parsing of generated Rust is needed.
The resolved marker retains the original SQL response key, resource, and FK hint.
For renamed dependencies, configure the generator's runtime path and pass runtime = path; to select! and key!.
Typed writes and raw queries
Pass generated payloads to insert(&Insert) or update(&Update).
The builder handles serialization and reports failures when executing the query.
Only base tables implement schema::WritableRelation. Views do not expose typed writes.
A query can choose only one mutation. Projections also control returned write representations.
Write fetch() requests a representation. Write execute() requests minimal return without JSON
decoding, and execute_with_count(Count::Exact) returns the affected-row count.
Call into_raw() for arbitrary expressions, relationships outside the captured FK graph,
bulk writes, or comparisons whose mapped types do not implement Display.
This drops typed guarantees. The owned builder's fetch::<Vec<MessageSummary>>() still checks
HTTP status and decodes the named DTO through the same canonical error path.
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 for schema-only runtime use or decoding outside the client. The default client feature enables it for typed fetches. Do not convert exact numerics through f64. 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 echoed = .fetch.await?;
The generated function marker determines the return type, so no manual decode or result annotation
is needed. RPC construction defers argument serialization errors until execution and does not add
relation selection or single-row semantics. PostgreSQL void emits Returns = (); an actual
HTTP 204 decodes as unit. Empty bodies are not treated as successful row or scalar JSON.
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 = new
.prelude
.type_override
.derive
.attribute
.type_attribute;
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 expressions. Computed, view, self-referential, cross-schema, and partition-child relationships stay raw. Many-to-many queries can use explicit nested junction relationships; no direct shortcut is inferred.
Verification 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.