1extern 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#[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#[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 #[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 #[must_use]
108 pub fn schema(self, schema: impl Into<String>) -> Self {
109 self.schemas([schema.into()])
110 }
111
112 #[must_use]
114 pub fn prelude(mut self, prelude: impl Into<String>) -> Self {
115 self.config.prelude = prelude.into();
116 self
117 }
118
119 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
208 pub const fn reexport_macros(mut self) -> Self {
209 self.config.reexport_macros = true;
210 self
211 }
212
213 #[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 #[must_use]
229 pub const fn strict_args(mut self) -> Self {
230 self.config.strict_args = true;
231 self
232 }
233
234 #[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 #[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 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 #[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 #[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 #[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#[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 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 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 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}