1extern 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#[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#[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 #[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 #[must_use]
94 pub fn schema(self, schema: impl Into<String>) -> Self {
95 self.schemas([schema.into()])
96 }
97
98 #[must_use]
100 pub fn prelude(mut self, prelude: impl Into<String>) -> Self {
101 self.config.prelude = prelude.into();
102 self
103 }
104
105 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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#[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 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 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 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}