Skip to main content

mz_deploy/project/ir/
object_id.rs

1// Copyright Materialize, Inc. and contributors. All rights reserved.
2//
3// Use of this software is governed by the Business Source License
4// included in the LICENSE file.
5//
6// As of the Change Date specified in that file, in accordance with
7// the Business Source License, use of this software will be governed
8// by the Apache License, Version 2.0.
9
10//! Fully qualified database-object identifier type.
11//!
12//! [`ObjectId`] is the canonical way to refer to a database object throughout
13//! project compilation and graph analysis. It is used as a map key,
14//! dependency graph node, and display type in error messages.
15//!
16//! ## Invariant
17//!
18//! User objects are fully qualified `database.schema.object`. Objects in
19//! Materialize's system schemas (`pg_catalog`, `mz_catalog`, `mz_internal`,
20//! `mz_introspection`, `information_schema`, `mz_unsafe`,
21//! `mz_catalog_unstable`) live at the cluster level and have no database;
22//! their `ObjectId` carries `database = None` and renders as
23//! `schema.object`. Partially qualified references are resolved into
24//! `ObjectId`s by [`ObjectId::from_item_name`] and
25//! [`ObjectId::from_raw_item_name`], which fill in missing components from
26//! the current file's database/schema context.
27//!
28//! ## Resolution Examples
29//!
30//! ```text
31//! from_item_name("sales", default_db="materialize", default_schema="public")
32//!   → ObjectId::new("materialize", "public", "sales" )
33//!
34//! from_item_name("analytics.summary", default_db="materialize", default_schema="public")
35//!   → ObjectId::new("materialize", "analytics", "summary" )
36//!
37//! from_item_name("mz_catalog.mz_objects", ...)
38//!   → ObjectId { database: None, schema: "mz_catalog", object: "mz_objects" }
39//!
40//! from_item_name("other_db.staging.events", ...)
41//!   → ObjectId::new("other_db", "staging", "events" )
42//! ```
43
44use mz_repr::namespaces::is_system_schema;
45use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
46use std::path::Path;
47use std::str::FromStr;
48use tower_lsp::lsp_types::Url;
49
50use mz_sql_parser::ast::{Ident, RawItemName, UnresolvedItemName};
51
52/// A canonical object identifier.
53///
54/// User objects are fully qualified (`database.schema.object`). System-schema
55/// objects (e.g. `mz_catalog.mz_objects`) carry `database = None` because
56/// system catalogs are outside any database.
57#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
58pub struct ObjectId {
59    database: Option<Ident>,
60    schema: Ident,
61    object: Ident,
62}
63
64impl ObjectId {
65    /// Create a user-object ObjectId with the given database, schema, and object names.
66    ///
67    /// The arguments are raw (unquoted) identifier values — e.g. file stems or
68    /// catalog names. They are stored as [`Ident`]s, which compare on the raw
69    /// value and only quote when rendered (see the [`std::fmt::Display`] impl).
70    pub fn new(database: String, schema: String, object: String) -> Self {
71        Self {
72            database: Some(Ident::new_unchecked(database)),
73            schema: Ident::new_unchecked(schema),
74            object: Ident::new_unchecked(object),
75        }
76    }
77
78    /// Create a system-schema ObjectId (no database). The caller is
79    /// responsible for ensuring `schema` is a system schema.
80    pub fn new_system(schema: String, object: String) -> Self {
81        Self {
82            database: None,
83            schema: Ident::new_unchecked(schema),
84            object: Ident::new_unchecked(object),
85        }
86    }
87
88    /// Get the database name, or `None` for system-schema objects.
89    #[inline]
90    pub fn database(&self) -> Option<&str> {
91        self.database.as_ref().map(Ident::as_str)
92    }
93
94    /// Get the database name, panicking if this is a system-schema oid.
95    ///
96    /// Use only in code paths that exclusively handle user objects (apply,
97    /// promote, stage, etc.). System-schema oids never reach those paths
98    /// because they are not project objects.
99    #[inline]
100    pub fn expect_database(&self) -> &str {
101        self.database
102            .as_ref()
103            .map(Ident::as_str)
104            .unwrap_or_else(|| {
105                panic!(
106                    "system-schema ObjectId '{}' used in user-object context",
107                    self
108                )
109            })
110    }
111
112    /// Get the schema name.
113    #[inline]
114    pub fn schema(&self) -> &str {
115        self.schema.as_str()
116    }
117
118    /// Get the object name.
119    #[inline]
120    pub fn object(&self) -> &str {
121        self.object.as_str()
122    }
123
124    /// Resolve an [`UnresolvedItemName`] into an [`ObjectId`].
125    ///
126    /// Name parts are resolved based on how many components are present:
127    /// - 1-part (`object`) — uses both `default_database` and `default_schema`.
128    /// - 2-part (`schema.object`) — `database = None` if `schema` is a system
129    ///   schema; otherwise `default_database`.
130    /// - 3-part (`database.schema.object`) — used as-is.
131    pub fn from_item_name(
132        name: &UnresolvedItemName,
133        default_database: &str,
134        default_schema: &str,
135    ) -> Self {
136        match name.0.as_slice() {
137            [object] => Self {
138                database: Some(Ident::new_unchecked(default_database)),
139                schema: Ident::new_unchecked(default_schema),
140                object: object.clone(),
141            },
142            [schema, object] => {
143                let database = if is_system_schema(schema.as_str()) {
144                    None
145                } else {
146                    Some(Ident::new_unchecked(default_database))
147                };
148                Self {
149                    database,
150                    schema: schema.clone(),
151                    object: object.clone(),
152                }
153            }
154            [database, schema, object] => Self {
155                database: Some(database.clone()),
156                schema: schema.clone(),
157                object: object.clone(),
158            },
159            _ => Self {
160                database: Some(Ident::new_unchecked(default_database)),
161                schema: Ident::new_unchecked(default_schema),
162                object: Ident::new_unchecked("unknown"),
163            },
164        }
165    }
166
167    /// Resolve a [`RawItemName`] into a fully qualified [`ObjectId`].
168    ///
169    /// Unwraps the inner [`UnresolvedItemName`] and delegates to
170    /// [`from_item_name`](Self::from_item_name).
171    pub fn from_raw_item_name(
172        name: &RawItemName,
173        default_database: &str,
174        default_schema: &str,
175    ) -> Self {
176        // RawItemName wraps UnresolvedItemName
177        Self::from_item_name(name.name(), default_database, default_schema)
178    }
179
180    /// Convert to an [`UnresolvedItemName`] (the reverse of [`from_item_name`](Self::from_item_name)).
181    ///
182    /// Produces a 3-part name for user objects and a 2-part name for
183    /// system-schema objects (`database = None`).
184    pub fn to_unresolved_item_name(&self) -> UnresolvedItemName {
185        let mut parts = Vec::with_capacity(3);
186        if let Some(db) = &self.database {
187            parts.push(db.clone());
188        }
189        parts.push(self.schema.clone());
190        parts.push(self.object.clone());
191        UnresolvedItemName(parts)
192    }
193
194    /// Parse an ObjectId from a fully qualified name string.
195    ///
196    /// # Arguments
197    /// * `fqn` - Fully qualified name in the format "database.schema.object"
198    ///
199    /// # Returns
200    /// ObjectId if the FQN is valid (has exactly 3 dot-separated parts)
201    ///
202    /// # Errors
203    /// Returns error if the FQN format is invalid
204    /// Derive the default database and schema from a file's URI.
205    ///
206    /// Expects the file to be under `<root>/models/<database>/<schema>/`.
207    /// Returns `None` if the path doesn't match the expected layout.
208    pub fn default_db_schema_from_uri(file_uri: &Url, root: &Path) -> Option<(String, String)> {
209        let file_path = file_uri.to_file_path().ok()?;
210        let models_dir = root.join("models");
211        let relative = file_path.strip_prefix(&models_dir).ok()?;
212
213        let components: Vec<_> = relative
214            .components()
215            .map(|c| c.as_os_str().to_string_lossy().to_string())
216            .collect();
217
218        // Expected: [database, schema, file.sql] or deeper
219        if components.len() >= 3 {
220            Some((components[0].clone(), components[1].clone()))
221        } else {
222            None
223        }
224    }
225}
226
227impl FromStr for ObjectId {
228    type Err = String;
229
230    fn from_str(s: &str) -> Result<Self, Self::Err> {
231        let invalid = || {
232            format!(
233                "invalid object id '{}': expected format \
234                 'database.schema.object' (or 'schema.object' for system catalogs)",
235                s
236            )
237        };
238        let name = mz_sql_parser::parser::parse_item_name(s).map_err(|_| invalid())?;
239        match name.0.as_slice() {
240            [database, schema, object] => Ok(ObjectId {
241                database: Some(database.clone()),
242                schema: schema.clone(),
243                object: object.clone(),
244            }),
245            [schema, object] if is_system_schema(schema.as_str()) => Ok(ObjectId {
246                database: None,
247                schema: schema.clone(),
248                object: object.clone(),
249            }),
250            _ => Err(invalid()),
251        }
252    }
253}
254
255impl std::fmt::Display for ObjectId {
256    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
257        match &self.database {
258            Some(db) => write!(f, "{}.{}.{}", db, self.schema, self.object),
259            None => write!(f, "{}.{}", self.schema, self.object),
260        }
261    }
262}
263
264impl Serialize for ObjectId {
265    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
266    where
267        S: Serializer,
268    {
269        serializer.serialize_str(&self.to_string())
270    }
271}
272
273impl<'de> Deserialize<'de> for ObjectId {
274    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
275    where
276        D: Deserializer<'de>,
277    {
278        struct ObjectIdVisitor;
279
280        impl<'de> de::Visitor<'de> for ObjectIdVisitor {
281            type Value = ObjectId;
282            fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
283                formatter.write_str("an object ID")
284            }
285
286            fn visit_str<E>(self, value: &str) -> Result<ObjectId, E>
287            where
288                E: de::Error,
289            {
290                ObjectId::from_str(value).map_err(de::Error::custom)
291            }
292        }
293
294        deserializer.deserialize_str(ObjectIdVisitor)
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[mz_ore::test]
303    fn parse_user_object_three_parts() {
304        let id: ObjectId = "materialize.public.foo".parse().unwrap();
305        assert_eq!(id.database(), Some("materialize"));
306        assert_eq!(id.schema(), "public");
307        assert_eq!(id.object(), "foo");
308    }
309
310    #[mz_ore::test]
311    fn parse_system_schema_two_parts() {
312        for input in [
313            "mz_catalog.mz_objects",
314            "pg_catalog.pg_class",
315            "mz_internal.mz_comments",
316            "mz_introspection.mz_compute_dependencies",
317            "information_schema.tables",
318        ] {
319            let id: ObjectId = input.parse().unwrap_or_else(|e| panic!("{}: {}", input, e));
320            assert_eq!(id.database(), None, "{}", input);
321        }
322    }
323
324    #[mz_ore::test]
325    fn parse_user_two_parts_rejected() {
326        let err = "public.foo".parse::<ObjectId>().unwrap_err();
327        assert!(err.contains("invalid object id"), "got: {}", err);
328    }
329
330    #[mz_ore::test]
331    fn parse_one_part_rejected() {
332        assert!("foo".parse::<ObjectId>().is_err());
333    }
334
335    #[mz_ore::test]
336    fn display_round_trip() {
337        for input in [
338            "materialize.public.foo",
339            "mz_catalog.mz_objects",
340            "pg_catalog.pg_class",
341        ] {
342            let id: ObjectId = input.parse().unwrap();
343            assert_eq!(id.to_string(), input);
344        }
345    }
346
347    #[mz_ore::test]
348    fn from_item_name_two_part_system_strips_default_db() {
349        let name = UnresolvedItemName(vec![
350            Ident::new("mz_catalog").unwrap(),
351            Ident::new("mz_objects").unwrap(),
352        ]);
353        let id = ObjectId::from_item_name(&name, "materialize", "public");
354        assert_eq!(id.database(), None);
355        assert_eq!(id.schema(), "mz_catalog");
356        assert_eq!(id.object(), "mz_objects");
357    }
358
359    #[mz_ore::test]
360    fn from_item_name_two_part_user_uses_default_db() {
361        let name = UnresolvedItemName(vec![
362            Ident::new("public").unwrap(),
363            Ident::new("foo").unwrap(),
364        ]);
365        let id = ObjectId::from_item_name(&name, "materialize", "default");
366        assert_eq!(id.database(), Some("materialize"));
367        assert_eq!(id.schema(), "public");
368        assert_eq!(id.object(), "foo");
369    }
370
371    #[mz_ore::test]
372    fn from_item_name_three_part_used_as_is() {
373        let name = UnresolvedItemName(vec![
374            Ident::new("other_db").unwrap(),
375            Ident::new("staging").unwrap(),
376            Ident::new("events").unwrap(),
377        ]);
378        let id = ObjectId::from_item_name(&name, "materialize", "public");
379        assert_eq!(id.database(), Some("other_db"));
380        assert_eq!(id.schema(), "staging");
381        assert_eq!(id.object(), "events");
382    }
383
384    /// A declared reserved-word dependency parses to its unquoted value.
385    #[mz_ore::test]
386    fn parse_quoted_reserved_word_three_parts() {
387        let id: ObjectId = "materialize.public.\"table\"".parse().unwrap();
388        assert_eq!(id.database(), Some("materialize"));
389        assert_eq!(id.schema(), "public");
390        assert_eq!(id.object(), "table");
391    }
392
393    /// Display re-quotes a keyword so a rendered id (e.g. in types.lock) re-parses.
394    #[mz_ore::test]
395    fn reserved_word_display_round_trips() {
396        let id: ObjectId = "materialize.public.\"table\"".parse().unwrap();
397        assert_eq!(id.to_string(), "materialize.public.\"table\"");
398        let reparsed: ObjectId = id.to_string().parse().unwrap();
399        assert_eq!(reparsed, id);
400    }
401
402    /// `from_item_name` and `ObjectId::new` produce equal ids for a keyword name —
403    /// the equality dependency resolution relies on.
404    #[mz_ore::test]
405    fn reserved_word_identity_matches_across_constructors() {
406        let name = UnresolvedItemName(vec![
407            Ident::new("materialize").unwrap(),
408            Ident::new("public").unwrap(),
409            Ident::new("table").unwrap(),
410        ]);
411        let from_ref = ObjectId::from_item_name(&name, "materialize", "public");
412        let from_stem = ObjectId::new(
413            "materialize".to_string(),
414            "public".to_string(),
415            "table".to_string(),
416        );
417        assert_eq!(from_ref, from_stem);
418        assert_eq!(from_ref.object(), "table");
419    }
420}