Skip to main content

rp_supabase_codegen/
lib.rs

1//! Generate Rust bindings in `build.rs`, using a versioned snapshot or a `PostgreSQL` connection.
2//!
3//! Prefer committed snapshots for reproducible builds. Live introspection is explicit and
4//! never falls back to stale metadata after an error. Database credentials stay on the host.
5
6extern crate alloc;
7
8#[cfg(feature = "database")]
9mod database;
10mod emitter;
11pub mod model;
12
13use alloc::collections::{BTreeMap, BTreeSet};
14use std::path::{Path, PathBuf};
15
16use model::{SNAPSHOT_VERSION, Snapshot};
17
18/// Generation or schema acquisition failed.
19#[expect(
20    clippy::error_impl_error,
21    reason = "The module-qualified codegen::Error is unambiguous for callers."
22)]
23#[derive(Debug, thiserror::Error)]
24pub enum Error {
25    #[error("invalid schema generation input: {0}")]
26    Invalid(String),
27    #[error("schema generation I/O failed: {0}")]
28    Io(#[from] std::io::Error),
29    #[error("invalid schema snapshot: {0}")]
30    Json(#[from] serde_json::Error),
31    #[error("PostgreSQL schema introspection failed: {0}")]
32    Database(String),
33}
34
35#[derive(Debug, Clone)]
36struct Config {
37    pub schemas: Vec<String>,
38    pub prelude: String,
39    pub derives: Vec<String>,
40    pub attributes: Vec<String>,
41    pub type_overrides: BTreeMap<String, String>,
42    pub type_attributes: BTreeMap<String, Vec<String>>,
43    pub runtime_path: String,
44    pub not_null: BTreeMap<String, Vec<String>>,
45    pub column_types: BTreeMap<String, String>,
46    pub json_types: BTreeMap<String, String>,
47    pub reexport_macros: bool,
48    pub relationship_aliases: BTreeMap<String, String>,
49    pub strict_args: bool,
50    pub strict_functions: BTreeSet<String>,
51}
52
53/// Configure code generation, then choose one explicit schema source.
54///
55/// Custom Rust syntax is checked before writing output. Generated structs preserve SQL names
56/// with Serde attributes. Additional derives and attributes apply to data types, not markers.
57#[derive(Debug, Clone)]
58pub struct Generator {
59    config: Config,
60}
61
62impl Default for Generator {
63    fn default() -> Self {
64        Self::new()
65    }
66}
67
68#[expect(
69    clippy::impl_trait_in_params,
70    reason = "Consuming configuration methods accept owned strings and borrowed literals without named generic parameters."
71)]
72#[expect(
73    clippy::wrong_self_convention,
74    reason = "Each from_* method consumes configured generation options and selects an input adapter."
75)]
76impl Generator {
77    #[must_use]
78    pub fn new() -> Self {
79        Self {
80            config: Config {
81                schemas: vec!["public".to_owned()],
82                prelude: String::new(),
83                derives: Vec::new(),
84                attributes: Vec::new(),
85                type_overrides: BTreeMap::new(),
86                type_attributes: BTreeMap::new(),
87                runtime_path: "::rp_supabase_client::schema".to_owned(),
88                not_null: BTreeMap::new(),
89                column_types: BTreeMap::new(),
90                json_types: BTreeMap::new(),
91                reexport_macros: false,
92                relationship_aliases: BTreeMap::new(),
93                strict_args: false,
94                strict_functions: BTreeSet::new(),
95            },
96        }
97    }
98
99    /// Replace the default `public` selection with these schemas.
100    #[must_use]
101    pub fn schemas(mut self, schemas: impl IntoIterator<Item = impl Into<String>>) -> Self {
102        self.config.schemas = schemas.into_iter().map(Into::into).collect();
103        self
104    }
105
106    /// Select a single schema instead of the default `public`.
107    #[must_use]
108    pub fn schema(self, schema: impl Into<String>) -> Self {
109        self.schemas([schema.into()])
110    }
111
112    /// Add Rust items or imports before the generated schema modules.
113    #[must_use]
114    pub fn prelude(mut self, prelude: impl Into<String>) -> Self {
115        self.config.prelude = prelude.into();
116        self
117    }
118
119    /// Add a derive path, such as `::typed_builder::TypedBuilder`.
120    #[must_use]
121    pub fn derive(mut self, derive: impl Into<String>) -> Self {
122        self.config.derives.push(derive.into());
123        self
124    }
125
126    /// Add an outer attribute, including its `#[...]` delimiters.
127    #[must_use]
128    pub fn attribute(mut self, attribute: impl Into<String>) -> Self {
129        self.config.attributes.push(attribute.into());
130        self
131    }
132
133    /// Add an attribute to one generated data type.
134    ///
135    /// Paths use generated Rust names, for example `public.tables.messages.Insert`.
136    /// Unknown paths fail generation instead of silently ignoring customization.
137    #[must_use]
138    pub fn type_attribute(
139        mut self,
140        generated_type: impl Into<String>,
141        attribute: impl Into<String>,
142    ) -> Self {
143        self.config
144            .type_attributes
145            .entry(generated_type.into())
146            .or_default()
147            .push(attribute.into());
148        self
149    }
150
151    /// Map a qualified SQL type to a Rust type that matches the `PostgREST` JSON representation.
152    #[must_use]
153    pub fn type_override(
154        mut self,
155        postgres_type: impl Into<String>,
156        rust_type: impl Into<String>,
157    ) -> Self {
158        self.config
159            .type_overrides
160            .insert(postgres_type.into(), rust_type.into());
161        self
162    }
163
164    /// Set the runtime module path when the client dependency has a Cargo alias.
165    #[must_use]
166    pub fn runtime_path(mut self, path: impl Into<String>) -> Self {
167        self.config.runtime_path = path.into();
168        self
169    }
170
171    /// Assert non-null fields on a view Row, composite, or RPC Record.
172    /// Unknown targets and SQL field names fail generation.
173    #[must_use]
174    pub fn not_null(
175        mut self,
176        target: impl Into<String>,
177        fields: impl IntoIterator<Item = impl Into<String>>,
178    ) -> Self {
179        self.config
180            .not_null
181            .insert(target.into(), fields.into_iter().map(Into::into).collect());
182        self
183    }
184
185    /// Override one SQL column's base type, preserving nullability and omission.
186    /// Accepts `public.table.column` or `public.tables.table.column`.
187    #[must_use]
188    pub fn column_type(mut self, target: impl Into<String>, rust_type: impl Into<String>) -> Self {
189        self.config
190            .column_types
191            .insert(target.into(), rust_type.into());
192        self
193    }
194
195    /// Decode a JSON field, RPC argument or return using a custom Serde type.
196    /// SQL arrays, nullability, and set-returning wrappers remain intact.
197    #[must_use]
198    pub fn json_type(mut self, target: impl Into<String>, rust_type: impl Into<String>) -> Self {
199        self.config
200            .json_types
201            .insert(target.into(), rust_type.into());
202        self
203    }
204
205    /// Export crate-local `select!` and `key!` wrappers with the runtime path filled.
206    /// Include the generated bindings at the schema crate's root.
207    #[must_use]
208    pub const fn reexport_macros(mut self) -> Self {
209        self.config.reexport_macros = true;
210        self
211    }
212
213    /// Add an alias for one canonical qualified relationship marker.
214    /// For example `public.tables.orders.relationships.orders_customer_fkey`.
215    #[must_use]
216    pub fn relationship_alias(
217        mut self,
218        target: impl Into<String>,
219        alias: impl Into<String>,
220    ) -> Self {
221        self.config
222            .relationship_aliases
223            .insert(target.into(), alias.into());
224        self
225    }
226
227    /// Require non-default RPC inputs unless explicitly marked nullable.
228    #[must_use]
229    pub const fn strict_args(mut self) -> Self {
230        self.config.strict_args = true;
231        self
232    }
233
234    /// Apply strict RPC input contracts to one generated function module.
235    #[must_use]
236    pub fn strict_args_for(mut self, target: impl Into<String>) -> Self {
237        self.config.strict_functions.insert(target.into());
238        self
239    }
240
241    /// Load a portable snapshot and register Cargo's file change detection.
242    ///
243    /// # Errors
244    /// Fails for unreadable, invalid, or unsupported-version snapshots.
245    #[expect(
246        clippy::print_stdout,
247        reason = "Cargo build scripts receive change instructions on stdout."
248    )]
249    pub fn from_snapshot(self, path: impl AsRef<Path>) -> Result<Bindings, Error> {
250        let path = path.as_ref();
251        cargo_path(path)?;
252        println!("cargo::rerun-if-changed={}", path.display());
253        let snapshot = parse_snapshot(&std::fs::read(path)?)?;
254        self.from_metadata(snapshot)
255    }
256
257    /// Generate from in-memory metadata. Does not emit Cargo change instructions.
258    ///
259    /// # Errors
260    /// Fails for unsupported versions, invalid metadata, or invalid custom Rust syntax.
261    pub fn from_metadata(self, mut snapshot: Snapshot) -> Result<Bindings, Error> {
262        validate_snapshot_version(snapshot.version)?;
263        if self.config.schemas.is_empty() || self.config.schemas.iter().any(String::is_empty) {
264            return Err(Error::Invalid(
265                "select at least one nonempty schema".to_owned(),
266            ));
267        }
268        for schema in &self.config.schemas {
269            if !snapshot.schemas.iter().any(|item| &item.name == schema) {
270                return Err(Error::Invalid(format!(
271                    "selected schema {schema:?} is absent from the snapshot"
272                )));
273            }
274        }
275        model::apply_not_null(&mut snapshot, &self.config.not_null)?;
276        let source = emitter::generate(&snapshot, &self.config)?;
277        Ok(Bindings { snapshot, source })
278    }
279
280    /// Acquire portable metadata without rendering Rust bindings.
281    ///
282    /// # Errors
283    /// Fails for empty schema selection, connection errors, or invalid catalog metadata.
284    #[cfg(feature = "database")]
285    pub fn snapshot_from_database(&self, url: &str) -> Result<Snapshot, Error> {
286        if self.config.schemas.is_empty() || self.config.schemas.iter().any(String::is_empty) {
287            return Err(Error::Invalid(
288                "select at least one nonempty schema".to_owned(),
289            ));
290        }
291        database::introspect(url, &self.config.schemas)
292    }
293
294    /// Introspect `PostgreSQL` directly. No CLI or Supabase service-role key is needed.
295    ///
296    /// Register migration paths in your build script, because Cargo cannot detect database DDL.
297    /// Remote connections should use `sslmode=require`; certificates and hostnames are verified.
298    ///
299    /// # Errors
300    /// Fails on connection, catalog, unsupported SQL type, or generation errors.
301    #[cfg(feature = "database")]
302    pub fn from_database(self, url: &str) -> Result<Bindings, Error> {
303        let snapshot = self.snapshot_from_database(url)?;
304        self.from_metadata(snapshot)
305    }
306
307    /// Read a database connection string from a host environment variable.
308    ///
309    /// Emits `rerun-if-env-changed` without printing the variable's secret value.
310    ///
311    /// # Errors
312    /// Fails if the variable is absent, invalid, or database introspection fails.
313    #[cfg(feature = "database")]
314    #[expect(
315        clippy::print_stdout,
316        reason = "Cargo build scripts receive change instructions on stdout."
317    )]
318    pub fn from_database_env(self, variable: &str) -> Result<Bindings, Error> {
319        if variable.is_empty() || variable.contains(['\n', '\r', '=']) {
320            return Err(Error::Invalid(
321                "invalid database environment variable name".to_owned(),
322            ));
323        }
324        println!("cargo::rerun-if-env-changed={variable}");
325        let url = std::env::var(variable).map_err(|_error| {
326            Error::Invalid(format!("set {variable} to a PostgreSQL connection string"))
327        })?;
328        self.from_database(&url)
329    }
330}
331
332/// Validated generated Rust source and the metadata used to produce it.
333#[derive(Debug, Clone)]
334pub struct Bindings {
335    snapshot: Snapshot,
336    source: String,
337}
338
339#[expect(
340    clippy::impl_trait_in_params,
341    reason = "Path arguments accept standard owned and borrowed path types."
342)]
343impl Bindings {
344    #[must_use]
345    pub fn source(&self) -> &str {
346        &self.source
347    }
348
349    #[must_use]
350    pub const fn snapshot(&self) -> &Snapshot {
351        &self.snapshot
352    }
353
354    /// Write source without changing the file when its content is identical.
355    ///
356    /// # Errors
357    /// Fails when reading or writing the output file fails.
358    pub fn write_to(&self, path: impl AsRef<Path>) -> Result<(), Error> {
359        write_if_changed(path.as_ref(), self.source.as_bytes())
360    }
361
362    /// Write source inside Cargo's `OUT_DIR`. The filename must be a single path component.
363    ///
364    /// # Errors
365    /// Fails outside a build script or if the filename or output path is invalid.
366    pub fn write_to_out_dir(&self, filename: &str) -> Result<PathBuf, Error> {
367        let path = Path::new(filename);
368        if path.components().count() != 1
369            || !matches!(
370                path.components().next(),
371                Some(std::path::Component::Normal(_))
372            )
373        {
374            return Err(Error::Invalid(
375                "OUT_DIR filename must be one normal path component".to_owned(),
376            ));
377        }
378        let output = std::env::var_os("OUT_DIR").ok_or_else(|| {
379            Error::Invalid("OUT_DIR is absent; call this method from build.rs".to_owned())
380        })?;
381        let output = PathBuf::from(output).join(filename);
382        self.write_to(&output)?;
383        Ok(output)
384    }
385
386    /// Export metadata for later offline builds. Call outside build.rs to update a committed snapshot.
387    ///
388    /// # Errors
389    /// Fails when encoding or writing the snapshot fails.
390    pub fn write_snapshot(&self, path: impl AsRef<Path>) -> Result<(), Error> {
391        self.snapshot.write_to(path)
392    }
393}
394
395fn validate_snapshot_version(version: u32) -> Result<(), Error> {
396    if version != SNAPSHOT_VERSION {
397        return Err(Error::Invalid(format!(
398            "snapshot version {version} is unsupported; expected {SNAPSHOT_VERSION}; \
399             regenerate the snapshot from PostgreSQL with the current supabase-codegen"
400        )));
401    }
402    Ok(())
403}
404
405fn parse_snapshot(bytes: &[u8]) -> Result<Snapshot, Error> {
406    #[derive(serde::Deserialize)]
407    struct VersionHeader {
408        version: u32,
409    }
410
411    let header: VersionHeader = serde_json::from_slice(bytes)?;
412    validate_snapshot_version(header.version)?;
413    Ok(serde_json::from_slice(bytes)?)
414}
415
416fn cargo_path(path: &Path) -> Result<(), Error> {
417    let Some(path) = path.to_str() else {
418        return Err(Error::Invalid("Cargo input paths must be UTF-8".to_owned()));
419    };
420    if path.contains(['\n', '\r']) {
421        return Err(Error::Invalid(
422            "Cargo input paths cannot contain line breaks".to_owned(),
423        ));
424    }
425    Ok(())
426}
427
428fn write_if_changed(path: &Path, contents: &[u8]) -> Result<(), Error> {
429    match std::fs::read(path) {
430        Ok(existing) if existing == contents => return Ok(()),
431        Ok(_) => {}
432        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
433        Err(error) => return Err(error.into()),
434    }
435    std::fs::write(path, contents)?;
436    Ok(())
437}
438
439#[cfg(test)]
440#[expect(
441    clippy::expect_used,
442    reason = "Unexpected fixture decoding errors must print their diagnostic."
443)]
444mod metadata_tests {
445    use super::{Error, Generator, parse_snapshot};
446    use crate::model::{Snapshot, Table};
447
448    #[test]
449    fn legacy_snapshot_rejected_before_missing_relationship_metadata() {
450        assert!(matches!(
451            parse_snapshot(
452                br#"{"version":1,"schemas":[{"name":"public","enums":[],"composites":[],"functions":[],"tables":[{"name":"orders","kind":"table","columns":[]}]}]}"#,
453            ),
454            Err(Error::Invalid(_))
455        ));
456    }
457
458    #[test]
459    fn in_memory_metadata_rejects_legacy_version() {
460        assert!(matches!(
461            Generator::default().from_metadata(Snapshot {
462                version: 1,
463                schemas: Vec::new(),
464            }),
465            Err(Error::Invalid(_))
466        ));
467    }
468
469    #[test]
470    fn table_relationship_metadata_is_required_even_for_primary_key() {
471        let table = serde_json::json!({
472            "name": "orders",
473            "kind": "table",
474            "columns": [],
475            "primary_key": null,
476            "unique_keys": [],
477            "foreign_keys": [],
478            "is_partition": false
479        });
480        for field in ["primary_key", "unique_keys", "foreign_keys", "is_partition"] {
481            let mut incomplete = table.clone();
482            incomplete.as_object_mut().expect("object").remove(field);
483            assert!(matches!(
484                serde_json::from_value::<Table>(incomplete),
485                Err(error) if error.classify() == serde_json::error::Category::Data
486            ));
487        }
488        let decoded: Table = serde_json::from_value(table).expect("explicit empty facts are valid");
489        assert_eq!(decoded.primary_key, None);
490    }
491}