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;
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}
45
46/// Configure code generation, then choose one explicit schema source.
47///
48/// Custom Rust syntax is checked before writing output. Generated structs preserve SQL names
49/// with Serde attributes. Additional derives and attributes apply to data types, not markers.
50#[derive(Debug, Clone)]
51pub struct Generator {
52    config: Config,
53}
54
55impl Default for Generator {
56    fn default() -> Self {
57        Self::new()
58    }
59}
60
61#[expect(
62    clippy::impl_trait_in_params,
63    reason = "Consuming configuration methods accept owned strings and borrowed literals without named generic parameters."
64)]
65#[expect(
66    clippy::wrong_self_convention,
67    reason = "Each from_* method consumes configured generation options and selects an input adapter."
68)]
69impl Generator {
70    #[must_use]
71    pub fn new() -> Self {
72        Self {
73            config: Config {
74                schemas: vec!["public".to_owned()],
75                prelude: String::new(),
76                derives: Vec::new(),
77                attributes: Vec::new(),
78                type_overrides: BTreeMap::new(),
79                type_attributes: BTreeMap::new(),
80                runtime_path: "::rp_supabase_client::schema".to_owned(),
81            },
82        }
83    }
84
85    /// Replace the default `public` selection with these schemas.
86    #[must_use]
87    pub fn schemas(mut self, schemas: impl IntoIterator<Item = impl Into<String>>) -> Self {
88        self.config.schemas = schemas.into_iter().map(Into::into).collect();
89        self
90    }
91
92    /// Select a single schema instead of the default `public`.
93    #[must_use]
94    pub fn schema(self, schema: impl Into<String>) -> Self {
95        self.schemas([schema.into()])
96    }
97
98    /// Add Rust items or imports before the generated schema modules.
99    #[must_use]
100    pub fn prelude(mut self, prelude: impl Into<String>) -> Self {
101        self.config.prelude = prelude.into();
102        self
103    }
104
105    /// Add a derive path, such as `::typed_builder::TypedBuilder`.
106    #[must_use]
107    pub fn derive(mut self, derive: impl Into<String>) -> Self {
108        self.config.derives.push(derive.into());
109        self
110    }
111
112    /// Add an outer attribute, including its `#[...]` delimiters.
113    #[must_use]
114    pub fn attribute(mut self, attribute: impl Into<String>) -> Self {
115        self.config.attributes.push(attribute.into());
116        self
117    }
118
119    /// Add an attribute to one generated data type.
120    ///
121    /// Paths use generated Rust names, for example `public.tables.messages.Insert`.
122    /// Unknown paths fail generation instead of silently ignoring customization.
123    #[must_use]
124    pub fn type_attribute(
125        mut self,
126        generated_type: impl Into<String>,
127        attribute: impl Into<String>,
128    ) -> Self {
129        self.config
130            .type_attributes
131            .entry(generated_type.into())
132            .or_default()
133            .push(attribute.into());
134        self
135    }
136
137    /// Map a qualified SQL type to a Rust type that matches the `PostgREST` JSON representation.
138    #[must_use]
139    pub fn type_override(
140        mut self,
141        postgres_type: impl Into<String>,
142        rust_type: impl Into<String>,
143    ) -> Self {
144        self.config
145            .type_overrides
146            .insert(postgres_type.into(), rust_type.into());
147        self
148    }
149
150    /// Set the runtime module path when the client dependency has a Cargo alias.
151    #[must_use]
152    pub fn runtime_path(mut self, path: impl Into<String>) -> Self {
153        self.config.runtime_path = path.into();
154        self
155    }
156
157    /// Load a portable snapshot and register Cargo's file change detection.
158    ///
159    /// # Errors
160    /// Fails for unreadable, invalid, or unsupported-version snapshots.
161    #[expect(
162        clippy::print_stdout,
163        reason = "Cargo build scripts receive change instructions on stdout."
164    )]
165    pub fn from_snapshot(self, path: impl AsRef<Path>) -> Result<Bindings, Error> {
166        let path = path.as_ref();
167        cargo_path(path)?;
168        println!("cargo::rerun-if-changed={}", path.display());
169        let snapshot = parse_snapshot(&std::fs::read(path)?)?;
170        self.from_metadata(snapshot)
171    }
172
173    /// Generate from in-memory metadata. Does not emit Cargo change instructions.
174    ///
175    /// # Errors
176    /// Fails for unsupported versions, invalid metadata, or invalid custom Rust syntax.
177    pub fn from_metadata(self, snapshot: Snapshot) -> Result<Bindings, Error> {
178        validate_snapshot_version(snapshot.version)?;
179        if self.config.schemas.is_empty() || self.config.schemas.iter().any(String::is_empty) {
180            return Err(Error::Invalid(
181                "select at least one nonempty schema".to_owned(),
182            ));
183        }
184        for schema in &self.config.schemas {
185            if !snapshot.schemas.iter().any(|item| &item.name == schema) {
186                return Err(Error::Invalid(format!(
187                    "selected schema {schema:?} is absent from the snapshot"
188                )));
189            }
190        }
191        let source = emitter::generate(&snapshot, &self.config)?;
192        Ok(Bindings { snapshot, source })
193    }
194
195    /// Introspect `PostgreSQL` directly. No CLI or Supabase service-role key is needed.
196    ///
197    /// Register migration paths in your build script, because Cargo cannot detect database DDL.
198    /// Remote connections should use `sslmode=require`; certificates and hostnames are verified.
199    ///
200    /// # Errors
201    /// Fails on connection, catalog, unsupported SQL type, or generation errors.
202    #[cfg(feature = "database")]
203    pub fn from_database(self, url: &str) -> Result<Bindings, Error> {
204        let snapshot = database::introspect(url, &self.config.schemas)?;
205        self.from_metadata(snapshot)
206    }
207
208    /// Read a database connection string from a host environment variable.
209    ///
210    /// Emits `rerun-if-env-changed` without printing the variable's secret value.
211    ///
212    /// # Errors
213    /// Fails if the variable is absent, invalid, or database introspection fails.
214    #[cfg(feature = "database")]
215    #[expect(
216        clippy::print_stdout,
217        reason = "Cargo build scripts receive change instructions on stdout."
218    )]
219    pub fn from_database_env(self, variable: &str) -> Result<Bindings, Error> {
220        if variable.is_empty() || variable.contains(['\n', '\r', '=']) {
221            return Err(Error::Invalid(
222                "invalid database environment variable name".to_owned(),
223            ));
224        }
225        println!("cargo::rerun-if-env-changed={variable}");
226        let url = std::env::var(variable).map_err(|_error| {
227            Error::Invalid(format!("set {variable} to a PostgreSQL connection string"))
228        })?;
229        self.from_database(&url)
230    }
231}
232
233/// Validated generated Rust source and the metadata used to produce it.
234#[derive(Debug, Clone)]
235pub struct Bindings {
236    snapshot: Snapshot,
237    source: String,
238}
239
240#[expect(
241    clippy::impl_trait_in_params,
242    reason = "Path arguments accept standard owned and borrowed path types."
243)]
244impl Bindings {
245    #[must_use]
246    pub fn source(&self) -> &str {
247        &self.source
248    }
249
250    #[must_use]
251    pub const fn snapshot(&self) -> &Snapshot {
252        &self.snapshot
253    }
254
255    /// Write source without changing the file when its content is identical.
256    ///
257    /// # Errors
258    /// Fails when reading or writing the output file fails.
259    pub fn write_to(&self, path: impl AsRef<Path>) -> Result<(), Error> {
260        write_if_changed(path.as_ref(), self.source.as_bytes())
261    }
262
263    /// Write source inside Cargo's `OUT_DIR`. The filename must be a single path component.
264    ///
265    /// # Errors
266    /// Fails outside a build script or if the filename or output path is invalid.
267    pub fn write_to_out_dir(&self, filename: &str) -> Result<PathBuf, Error> {
268        let path = Path::new(filename);
269        if path.components().count() != 1
270            || !matches!(
271                path.components().next(),
272                Some(std::path::Component::Normal(_))
273            )
274        {
275            return Err(Error::Invalid(
276                "OUT_DIR filename must be one normal path component".to_owned(),
277            ));
278        }
279        let output = std::env::var_os("OUT_DIR").ok_or_else(|| {
280            Error::Invalid("OUT_DIR is absent; call this method from build.rs".to_owned())
281        })?;
282        let output = PathBuf::from(output).join(filename);
283        self.write_to(&output)?;
284        Ok(output)
285    }
286
287    /// Export metadata for later offline builds. Call outside build.rs to update a committed snapshot.
288    ///
289    /// # Errors
290    /// Fails when encoding or writing the snapshot fails.
291    pub fn write_snapshot(&self, path: impl AsRef<Path>) -> Result<(), Error> {
292        let mut bytes = serde_json::to_vec_pretty(&self.snapshot)?;
293        bytes.push(b'\n');
294        write_if_changed(path.as_ref(), &bytes)
295    }
296}
297
298fn validate_snapshot_version(version: u32) -> Result<(), Error> {
299    if version != SNAPSHOT_VERSION {
300        return Err(Error::Invalid(format!(
301            "snapshot version {version} is unsupported; expected {SNAPSHOT_VERSION}; \
302             regenerate the snapshot from PostgreSQL with the current supabase-codegen"
303        )));
304    }
305    Ok(())
306}
307
308fn parse_snapshot(bytes: &[u8]) -> Result<Snapshot, Error> {
309    #[derive(serde::Deserialize)]
310    struct VersionHeader {
311        version: u32,
312    }
313
314    let header: VersionHeader = serde_json::from_slice(bytes)?;
315    validate_snapshot_version(header.version)?;
316    Ok(serde_json::from_slice(bytes)?)
317}
318
319fn cargo_path(path: &Path) -> Result<(), Error> {
320    let Some(path) = path.to_str() else {
321        return Err(Error::Invalid("Cargo input paths must be UTF-8".to_owned()));
322    };
323    if path.contains(['\n', '\r']) {
324        return Err(Error::Invalid(
325            "Cargo input paths cannot contain line breaks".to_owned(),
326        ));
327    }
328    Ok(())
329}
330
331fn write_if_changed(path: &Path, contents: &[u8]) -> Result<(), Error> {
332    match std::fs::read(path) {
333        Ok(existing) if existing == contents => return Ok(()),
334        Ok(_) => {}
335        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
336        Err(error) => return Err(error.into()),
337    }
338    std::fs::write(path, contents)?;
339    Ok(())
340}
341
342#[cfg(test)]
343#[expect(
344    clippy::expect_used,
345    reason = "Unexpected fixture decoding errors must print their diagnostic."
346)]
347mod metadata_tests {
348    use super::{Error, Generator, parse_snapshot};
349    use crate::model::{Snapshot, Table};
350
351    #[test]
352    fn legacy_snapshot_rejected_before_missing_relationship_metadata() {
353        assert!(matches!(
354            parse_snapshot(
355                br#"{"version":1,"schemas":[{"name":"public","enums":[],"composites":[],"functions":[],"tables":[{"name":"orders","kind":"table","columns":[]}]}]}"#,
356            ),
357            Err(Error::Invalid(_))
358        ));
359    }
360
361    #[test]
362    fn in_memory_metadata_rejects_legacy_version() {
363        assert!(matches!(
364            Generator::default().from_metadata(Snapshot {
365                version: 1,
366                schemas: Vec::new(),
367            }),
368            Err(Error::Invalid(_))
369        ));
370    }
371
372    #[test]
373    fn table_relationship_metadata_is_required_even_for_primary_key() {
374        let table = serde_json::json!({
375            "name": "orders",
376            "kind": "table",
377            "columns": [],
378            "primary_key": null,
379            "unique_keys": [],
380            "foreign_keys": [],
381            "is_partition": false
382        });
383        for field in ["primary_key", "unique_keys", "foreign_keys", "is_partition"] {
384            let mut incomplete = table.clone();
385            incomplete.as_object_mut().expect("object").remove(field);
386            assert!(matches!(
387                serde_json::from_value::<Table>(incomplete),
388                Err(error) if error.classify() == serde_json::error::Category::Data
389            ));
390        }
391        let decoded: Table = serde_json::from_value(table).expect("explicit empty facts are valid");
392        assert_eq!(decoded.primary_key, None);
393    }
394}