Skip to main content

use_pg_schema/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::{fmt, str::FromStr};
5use std::error::Error;
6
7use use_pg_identifier::{PgIdentifier, PgIdentifierError};
8
9/// The common user-facing PostgreSQL schema.
10pub const PUBLIC_SCHEMA: &str = "public";
11/// The PostgreSQL system catalog schema.
12pub const PG_CATALOG_SCHEMA: &str = "pg_catalog";
13/// The SQL information schema.
14pub const INFORMATION_SCHEMA: &str = "information_schema";
15
16/// PostgreSQL schema name primitive.
17#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
18pub struct PgSchemaName(PgIdentifier);
19
20impl PgSchemaName {
21    /// Creates a schema name.
22    ///
23    /// # Errors
24    ///
25    /// Returns [`PgSchemaError`] when identifier validation fails.
26    pub fn new(input: impl AsRef<str>) -> Result<Self, PgSchemaError> {
27        PgIdentifier::new(input)
28            .map(Self)
29            .map_err(PgSchemaError::Identifier)
30    }
31
32    /// Returns the `public` schema name.
33    ///
34    /// # Panics
35    ///
36    /// Panics only if the built-in `public` constant is changed to an invalid identifier.
37    #[must_use]
38    pub fn public() -> Self {
39        Self::new(PUBLIC_SCHEMA).expect("public is a valid PostgreSQL schema name")
40    }
41
42    /// Returns the `pg_catalog` schema name.
43    ///
44    /// # Panics
45    ///
46    /// Panics only if the built-in `pg_catalog` constant is changed to an invalid identifier.
47    #[must_use]
48    pub fn pg_catalog() -> Self {
49        Self::new(PG_CATALOG_SCHEMA).expect("pg_catalog is a valid PostgreSQL schema name")
50    }
51
52    /// Returns the `information_schema` schema name.
53    ///
54    /// # Panics
55    ///
56    /// Panics only if the built-in `information_schema` constant is changed to an invalid identifier.
57    #[must_use]
58    pub fn information_schema() -> Self {
59        Self::new(INFORMATION_SCHEMA).expect("information_schema is a valid PostgreSQL schema name")
60    }
61
62    /// Returns the schema name text.
63    #[must_use]
64    pub fn as_str(&self) -> &str {
65        self.0.as_str()
66    }
67
68    /// Returns the broad schema classification.
69    #[must_use]
70    pub fn class(&self) -> PgSchemaClass {
71        classify_schema(self.as_str())
72    }
73
74    /// Returns `true` for PostgreSQL system-owned schemas.
75    #[must_use]
76    pub fn is_system(&self) -> bool {
77        self.class().is_system()
78    }
79}
80
81impl AsRef<str> for PgSchemaName {
82    fn as_ref(&self) -> &str {
83        self.as_str()
84    }
85}
86
87impl fmt::Display for PgSchemaName {
88    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
89        self.0.fmt(formatter)
90    }
91}
92
93impl FromStr for PgSchemaName {
94    type Err = PgSchemaError;
95
96    fn from_str(input: &str) -> Result<Self, Self::Err> {
97        Self::new(input)
98    }
99}
100
101impl TryFrom<&str> for PgSchemaName {
102    type Error = PgSchemaError;
103
104    fn try_from(value: &str) -> Result<Self, Self::Error> {
105        Self::new(value)
106    }
107}
108
109/// Broad PostgreSQL schema classification.
110#[derive(Clone, Copy, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
111pub enum PgSchemaClass {
112    /// The `public` schema.
113    Public,
114    /// The PostgreSQL system catalog schema.
115    SystemCatalog,
116    /// The SQL information schema.
117    InformationSchema,
118    /// Session temporary schemas such as `pg_temp_3`.
119    Temporary,
120    /// PostgreSQL toast schemas.
121    Toast,
122    /// User-defined schemas.
123    #[default]
124    User,
125}
126
127impl PgSchemaClass {
128    /// Returns a stable lowercase label.
129    #[must_use]
130    pub const fn as_str(self) -> &'static str {
131        match self {
132            Self::Public => "public",
133            Self::SystemCatalog => "system-catalog",
134            Self::InformationSchema => "information-schema",
135            Self::Temporary => "temporary",
136            Self::Toast => "toast",
137            Self::User => "user",
138        }
139    }
140
141    /// Returns `true` when the class is PostgreSQL-managed.
142    #[must_use]
143    pub const fn is_system(self) -> bool {
144        matches!(
145            self,
146            Self::SystemCatalog | Self::InformationSchema | Self::Temporary | Self::Toast
147        )
148    }
149}
150
151impl fmt::Display for PgSchemaClass {
152    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
153        formatter.write_str(self.as_str())
154    }
155}
156
157/// Classifies a schema label without querying a database.
158#[must_use]
159pub fn classify_schema(input: &str) -> PgSchemaClass {
160    let normalized = input.trim().to_ascii_lowercase();
161    if normalized == PUBLIC_SCHEMA {
162        PgSchemaClass::Public
163    } else if normalized == PG_CATALOG_SCHEMA || normalized.starts_with("pg_catalog_") {
164        PgSchemaClass::SystemCatalog
165    } else if normalized == INFORMATION_SCHEMA {
166        PgSchemaClass::InformationSchema
167    } else if normalized == "pg_temp" || normalized.starts_with("pg_temp_") {
168        PgSchemaClass::Temporary
169    } else if normalized == "pg_toast" || normalized.starts_with("pg_toast") {
170        PgSchemaClass::Toast
171    } else {
172        PgSchemaClass::User
173    }
174}
175
176/// PostgreSQL search-path metadata.
177#[derive(Clone, Debug, Default, Eq, Hash, Ord, PartialEq, PartialOrd)]
178pub struct PgSearchPath {
179    schemas: Vec<PgSchemaName>,
180}
181
182impl PgSearchPath {
183    /// Creates a search path from schema names.
184    #[must_use]
185    pub const fn new(schemas: Vec<PgSchemaName>) -> Self {
186        Self { schemas }
187    }
188
189    /// Creates a search path containing only `public`.
190    #[must_use]
191    pub fn public() -> Self {
192        Self::new(vec![PgSchemaName::public()])
193    }
194
195    /// Returns the schema list.
196    #[must_use]
197    pub fn schemas(&self) -> &[PgSchemaName] {
198        &self.schemas
199    }
200
201    /// Returns the first schema in the search path.
202    #[must_use]
203    pub fn first(&self) -> Option<&PgSchemaName> {
204        self.schemas.first()
205    }
206
207    /// Appends a schema to the search path.
208    pub fn push(&mut self, schema: PgSchemaName) {
209        self.schemas.push(schema);
210    }
211
212    /// Returns `true` when the search path contains `schema`.
213    #[must_use]
214    pub fn contains(&self, schema: &PgSchemaName) -> bool {
215        self.schemas.iter().any(|candidate| candidate == schema)
216    }
217}
218
219impl fmt::Display for PgSearchPath {
220    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
221        let mut schemas = self.schemas.iter();
222        if let Some(first) = schemas.next() {
223            write!(formatter, "{first}")?;
224        }
225        for schema in schemas {
226            write!(formatter, ", {schema}")?;
227        }
228        Ok(())
229    }
230}
231
232/// Error returned when PostgreSQL schema metadata is invalid.
233#[derive(Clone, Debug, Eq, PartialEq)]
234pub enum PgSchemaError {
235    Identifier(PgIdentifierError),
236}
237
238impl fmt::Display for PgSchemaError {
239    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
240        match self {
241            Self::Identifier(error) => {
242                write!(formatter, "invalid PostgreSQL schema identifier: {error}")
243            }
244        }
245    }
246}
247
248impl Error for PgSchemaError {}
249
250#[cfg(test)]
251mod tests {
252    use super::{
253        INFORMATION_SCHEMA, PG_CATALOG_SCHEMA, PUBLIC_SCHEMA, PgSchemaClass, PgSchemaError,
254        PgSchemaName, PgSearchPath, classify_schema,
255    };
256
257    #[test]
258    fn creates_common_schema_names() {
259        assert_eq!(PgSchemaName::public().as_str(), PUBLIC_SCHEMA);
260        assert_eq!(PgSchemaName::pg_catalog().as_str(), PG_CATALOG_SCHEMA);
261        assert_eq!(
262            PgSchemaName::information_schema().as_str(),
263            INFORMATION_SCHEMA
264        );
265    }
266
267    #[test]
268    fn classifies_schema_names() {
269        assert_eq!(classify_schema("public"), PgSchemaClass::Public);
270        assert_eq!(classify_schema("pg_catalog"), PgSchemaClass::SystemCatalog);
271        assert_eq!(
272            classify_schema("information_schema"),
273            PgSchemaClass::InformationSchema
274        );
275        assert_eq!(classify_schema("pg_temp_3"), PgSchemaClass::Temporary);
276        assert_eq!(classify_schema("app"), PgSchemaClass::User);
277        assert!(PgSchemaName::pg_catalog().is_system());
278    }
279
280    #[test]
281    fn tracks_search_path_order() -> Result<(), PgSchemaError> {
282        let mut path = PgSearchPath::public();
283        let app = PgSchemaName::new("app")?;
284        path.push(app.clone());
285
286        assert_eq!(path.first(), Some(&PgSchemaName::public()));
287        assert!(path.contains(&app));
288        assert_eq!(path.to_string(), "public, app");
289        Ok(())
290    }
291}