rp-supabase-codegen 0.12.0

Build-script-first Rust bindings for PostgreSQL and Supabase schemas
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
# rp-supabase-codegen

[![crates.io](https://img.shields.io/crates/v/rp-supabase-codegen.svg)](https://crates.io/crates/rp-supabase-codegen) [![docs.rs](https://docs.rs/rp-supabase-codegen/badge.svg)](https://docs.rs/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.

```toml
[dependencies]
rp-supabase-client = "0.12"
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.12"
```

Generated bindings only need the `schema` runtime. Applications with their own HTTP client and
response handling can use `rp-supabase-client = { version = "0.12", default-features = false }`.
That leaves out authentication and does not enable `serde_json/arbitrary_precision`.

```rust,no_run
// 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(())
}
```

```rust
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.

Schema crates can call `.reexport_macros()` and include their generated bindings at the crate root. Consumers then use `renamed_schema::select!(Row => { id })` and `renamed_schema::key!(id)` without naming the runtime dependency. The wrappers use `$crate` and a public hidden runtime reexport, so Cargo dependency aliases do not affect expansion. The same macros work inside the schema crate. `key!(type id)` also works.

See the [complete snapshot and executable example](../supabase-codegen-example). The public `model::Snapshot` format is version 3. Older snapshots require regeneration.

## Direct database introspection

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

```toml
[build-dependencies]
rp-supabase-codegen = { version = "0.12", features = ["database"] }
```

```rust,no_run
// 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

Install the standalone CLI. It does not build the consumer crate or run its `build.rs`.

```sh
cargo install rp-supabase-codegen --features cli
export DATABASE_URL='postgresql://...'
rp-supabase-codegen snapshot write --out schema.json
rp-supabase-codegen snapshot check --out schema.json
```

Both commands default to `public` and `schema.json`. Repeat `--schema` to select more schemas. Use `--database-url-env NAME` for another environment variable. Prefer environment variables over `--database-url`, which can expose credentials in process listings and shell history.

`check` writes nothing. It reports schema drift and exits with status 1 on drift, missing snapshots, or invalid input. Review drift before running `write` with the same schema selection and output path. Snapshot acquisition does not require Rust type overrides.

The library also exports metadata without generating Rust bindings:

```rust,no_run
fn main() -> Result<(), Box<dyn std::error::Error>> {
    rp_supabase_codegen::Generator::new()
        .snapshot_from_database(&std::env::var("DATABASE_URL")?)?
        .write_to("schema.json")?;
    Ok(())
}
```

Do not write committed snapshots from an ordinary build script. `Bindings::snapshot()` also exposes typed metadata for 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` 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 current codegen before using the matching runtime. The snapshot format is version 3.
JSON and JSONB column markers, including domains over those types, implement `schema::JsonColumn`.

```rust,no_run
# use rp_supabase_codegen_example::database;
# async fn run(client: rp_supabase_client::Postgrest) -> Result<(), Box<dyn std::error::Error>> {
use database::public::tables::messages;

let rows = messages::query(client.clone()).fetch().await?;
// rows has type Vec<messages::Row>.
# Ok(())
# }
```

### Typed projections and filters

Use a query-first selection for local results. Do not repeat Rust field types or a selection string:

```rust,no_run
# use rp_supabase_codegen_example::database;
# use database::public::tables::messages;
# async fn run(client: rp_supabase_client::Postgrest, message_id: i64) -> Result<(), Box<dyn std::error::Error>> {
use rp_supabase_client::{select, schema::Selection};
let selected = {
    use database::public::tables::messages::Row as Message;
    select!(Message => { id, body, note })
};
let rows = selected.query(client.clone())
    .eq(messages::columns::id, &message_id)
    .fetch()
    .await?;
# Ok(())
# }
```

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:

```rust,no_run
# use rp_supabase_codegen_example::database;
rp_supabase_client::projection! {
    struct Artifact for [database::public::tables::skills, database::public::tables::adapters] {
        id, name, owner_id
    }
}
```

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:

```rust,no_run
# use rp_supabase_codegen_example::database;
# use database::public::tables::messages;
# fn run(http: &rp_supabase_client::rp_postgrest::reqwest::Client, endpoint: &str, message_id: i64) {
use rp_supabase_client::schema::params;
rp_supabase_client::projection! {
    struct MessageSummary for database::public::tables::messages { id, body, note }
}
let pairs = [
    params::projection::<messages::Row, MessageSummary>(),
    params::eq(messages::columns::id, &message_id),
];
let request = http.get(endpoint).query(&pairs);
# }
```

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.

```rust,no_run
# use rp_supabase_codegen_example::database;
# async fn run(client: rp_supabase_client::Postgrest) -> Result<(), Box<dyn std::error::Error>> {
use database::public::tables::{addresses, customers, orders};

rp_supabase_client::projection! {
    struct AddressSummary for database::public::tables::addresses { id, label }
}
rp_supabase_client::projection! {
    struct OrderSummary for database::public::tables::orders {
        id,
        billing: embed(database::public::tables::orders::relationships::orders_billing, AddressSummary),
        shipping: embed(database::public::tables::orders::relationships::orders_shipping, AddressSummary, inner),
    }
}
rp_supabase_client::projection! {
    struct CustomerSummary for database::public::tables::customers {
        id,
        orders: embed(database::public::tables::customers::relationships::orders_orders_customer, OrderSummary),
        matching_orders: empty(database::public::tables::customers::relationships::orders_orders_customer),
    }
}

let rows = customers::query(client.clone())
    .select(rp_supabase_client::schema::named::<_, CustomerSummary>())
    .embedded(CustomerSummary::orders.then(OrderSummary::billing), |address| {
        address.eq(addresses::columns::label, "Main");
    })
    .embedded(CustomerSummary::matching_orders, |order| {
        order.eq(orders::columns::label, "open");
    })
    .exists(CustomerSummary::matching_orders)
    .fetch()
    .await?;
# Ok(())
# }
```

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:

```rust,no_run
# use rp_supabase_codegen_example::database::public::tables::customers;
# async fn run(client: rp_supabase_client::Postgrest) -> Result<(), Box<dyn std::error::Error>> {
use rp_supabase_client::{key, select, schema::Selection};
let selected = select!(customers::Row => {
    id,
    orders: orders_orders_customer {
        id,
        billing: orders_billing { label },
        shipping: inner(orders_shipping) { label },
    },
    matching_orders: empty(orders_orders_customer),
});
let billing = selected.orders.then(selected.orders.child.billing);
let rows = selected.query(client.clone())
    .embedded(billing, |address| {
        address.eq(billing.column(key!(label)), "Main");
    })
    .exists(selected.matching_orders)
    .fetch().await?;
# Ok(())
# }
```

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.

The generator resolves each relation column's read nullability and write obligation once.
Rows, column marker value types and nullable capabilities, insert/update fields, and automatic
`Default` derives consume that same private contract. `Insert` derives `Default` only when every
writable field is omittable; `Update` always does.

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. Composites honor snapshot field nullability. Live introspection defaults composite fields to nullable unless a type comment declares `@not_null id, name`. Domains preserve their qualified identity for overrides and otherwise resolve to their base type. Unknown types fail generation and require `type_override`.

Validated string membership CHECK constraints, including PostgreSQL's normalized `= ANY (ARRAY[...])` form, generate enums in `public::enums`, named after the table and column. Compound predicates, arbitrary casts and nonliteral values remain their SQL base types. Enums support Serde and `Display` for filters. `.column_type("public.adapters.owner_type", "::domain::OwnerType")` replaces a column's base Rust type across Row, Insert, Update and column markers without changing omission or nullability. The canonical `public.tables.adapters.owner_type` target also works. Unknown columns or invalid Rust types fail generation.

Relationship canonical names remain unchanged. Single-column forward links expose the FK column name without a trailing `_id`, when unambiguous. Reverse links expose the source table name when unambiguous. `.relationship_alias("public.tables.orders.relationships.orders_customer_fkey", "buyer")` adds a checked custom alias. Alias markers retain the canonical marker's cardinality and exact PostgREST constraint hint. Unknown targets and alias name collisions fail generation.

Use `.json_type("public.functions.invite_org_member.Returns", "crate::InviteOutcome")` for typed JSON RPC results. Table columns accept `public.tables.artifacts.manifest` or `public.artifacts.manifest`; composite fields use `public.composites.ResultInfo.data`, RPC inputs use `public.functions.invite.Args.audience`, and OUT fields use `public.functions.invite.Record.data`. Targets must have a JSON/JSONB type, a domain over JSON, or an array of JSON. Custom Serde types bind in the root prelude scope. Generated fields retain SQL `Option`, `Array`, omission and set-returning wrappers, so plain fetches decode the custom type directly.

### 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.

By default, non-default arguments use `Option<T>` because PostgreSQL functions can accept null. `.strict_args()` makes them `T` globally; `.strict_args_for("public.functions.finalize_flow_publish")` does so for one generated function module. A function comment such as `@nullable p_version` opts an argument back into `Option<T>` and persists that contract in the snapshot. Unknown annotation argument names fail introspection. Default arguments always use `Field<Option<T>>`, preserving omission versus explicit null. Custom JSON and SQL type mappings follow the same policy. 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.

```rust,no_run
# use rp_supabase_codegen_example::database;
# use rp_supabase_client::schema;
# async fn run(client: rp_supabase_client::Postgrest) -> Result<(), Box<dyn std::error::Error>> {
let echoed = schema::rpc::<database::public::functions::echo_message::Function>(
    client.clone(),
    &database::public::functions::echo_message::Args {
        message: Some("hello".to_owned()),
    },
).fetch().await?;
# Ok(())
# }
```

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

```rust
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. Ordinary views infer non-null direct base-column projections from a single base table, without joins, CTEs or grouping. Materialized views remain nullable because their stored rows can predate current base constraints. View comments and RPC record function comments can declare `@not_null field, other_field`. Snapshots retain these contracts. `.not_null("public.functions.finalize_flow_publish.Record", ["flow_id", "created"])` sets the same contract in the builder. View targets use `public.tables.view.Row`; composite targets use `public.composites.ResultInfo`. Unknown fields and targets fail generation.

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

```sh
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](../../docs/schema-generation-research.md) records source comparisons and design decisions.