Skip to main content

mz_deploy/project/resolve/normalize/
overlay_transformer.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//! Name transformation for `mz-deploy dev` overlay compilation.
11//!
12//! This module provides [`OverlayTransformer`], which implements the
13//! two-step reference resolution rule for schema-level overlays:
14//!
15//! 1. **External references** — if the database is not in
16//!    `in_project_databases`, emit the name verbatim.
17//! 2. **Overlaid objects** — if the fully qualified object is in
18//!    `overlay_objects`, rewrite the database component to
19//!    `<database>__<profile_name>`. Otherwise emit
20//!    `<database>.<schema>.<object>` (production reference). Routing by object,
21//!    not by schema, keeps a non-overlaid object that happens to share a schema
22//!    with an overlaid one pointing at production, where it actually exists.
23//!
24//! Unqualified / partially qualified names are fully qualified using the
25//! transformer's `fqn` context before the rule is applied.
26//!
27//! The project planner has already applied any configured `profile_suffix`
28//! to database and cluster names before `dev` invokes the transformer, so
29//! no suffix handling is required here.
30
31use std::collections::BTreeSet;
32
33use mz_sql_parser::ast::{Ident, UnresolvedItemName};
34
35use crate::project::ir::compiled::FullyQualifiedName;
36use crate::project::ir::object_id::ObjectId;
37use crate::project::resolve::normalize::transformers::{ClusterTransformer, NameTransformer};
38use mz_repr::namespaces::is_system_schema;
39
40/// Transforms references for `mz-deploy dev` overlay compilation.
41///
42/// Applies the two-step reference resolution rule:
43///
44/// 1. If the referenced database is not in `in_project_databases`, leave
45///    the name verbatim (external dependency).
46/// 2. If the fully qualified object is in `overlay_objects`, rewrite the
47///    database component to `<database>__<profile_name>`. Otherwise emit
48///    `<database>.<schema>.<object>` (production reference).
49///
50/// Unqualified / partially qualified names are fully qualified using
51/// `fqn` before the rule is applied.
52pub(crate) struct OverlayTransformer<'a> {
53    pub(crate) fqn: &'a FullyQualifiedName,
54    pub(crate) profile_name: &'a str,
55    pub(crate) in_project_databases: &'a BTreeSet<String>,
56    pub(crate) overlay_objects: &'a BTreeSet<ObjectId>,
57    pub(crate) target_cluster: &'a str,
58}
59
60impl<'a> NameTransformer for OverlayTransformer<'a> {
61    fn transform_name(&self, name: &UnresolvedItemName) -> UnresolvedItemName {
62        // System catalog references are database-less and aren't part of any
63        // project; leave them verbatim so the server resolves them natively.
64        if name.0.len() == 2 && is_system_schema(name.0[0].as_str()) {
65            return name.clone();
66        }
67
68        // Normalize to 3-part name first
69        let (database, schema, object) = match name.0.len() {
70            1 => {
71                // Unqualified: use fqn database + schema
72                let database = Ident::new(self.fqn.database()).expect("valid database identifier");
73                let schema = Ident::new(self.fqn.schema()).expect("valid schema identifier");
74                let object = name.0[0].clone();
75                (database, schema, object)
76            }
77            2 => {
78                // Schema-qualified: prepend fqn database
79                let database = Ident::new(self.fqn.database()).expect("valid database identifier");
80                let schema = name.0[0].clone();
81                let object = name.0[1].clone();
82                (database, schema, object)
83            }
84            3 => {
85                // Already fully qualified
86                let database = name.0[0].clone();
87                let schema = name.0[1].clone();
88                let object = name.0[2].clone();
89                (database, schema, object)
90            }
91            _ => {
92                // Invalid — return as-is (matches FullyQualifyingTransformer behavior)
93                return name.clone();
94            }
95        };
96
97        let db_str = database.to_string();
98
99        // Step 1: external check — leave verbatim if not in project.
100        if !self.in_project_databases.contains(&db_str) {
101            return UnresolvedItemName(vec![database, schema, object]);
102        }
103
104        // Step 2: object check — rewrite to the overlay db only when the
105        // referenced object is itself overlaid. A non-overlaid object that
106        // shares a schema with an overlaid one stays at its production address,
107        // which is where it actually exists.
108        let referenced = ObjectId::new(
109            database.as_str().to_string(),
110            schema.as_str().to_string(),
111            object.as_str().to_string(),
112        );
113        let final_db_str = if self.overlay_objects.contains(&referenced) {
114            format!("{}__{}", db_str, self.profile_name)
115        } else {
116            db_str
117        };
118
119        let final_db = Ident::new(&final_db_str).expect("valid database identifier");
120        UnresolvedItemName(vec![final_db, schema, object])
121    }
122
123    fn database_name(&self) -> &str {
124        self.fqn.database()
125    }
126}
127
128impl<'a> ClusterTransformer for OverlayTransformer<'a> {
129    fn transform_cluster(&self, _: &Ident) -> Ident {
130        Ident::new(self.target_cluster).expect("valid cluster identifier")
131    }
132
133    fn get_original_cluster_name(&self, name: &str) -> String {
134        name.to_string()
135    }
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::project::ir::compiled::FullyQualifiedName;
142    use crate::project::ir::object_id::ObjectId;
143
144    fn make_fqn(database: &str, schema: &str, object: &str) -> FullyQualifiedName {
145        ObjectId::new(database.to_string(), schema.to_string(), object.to_string()).into()
146    }
147
148    fn make_name(parts: &[&str]) -> UnresolvedItemName {
149        UnresolvedItemName(
150            parts
151                .iter()
152                .map(|s| Ident::new(*s).expect("valid identifier"))
153                .collect(),
154        )
155    }
156
157    /// Build an OverlayTransformer for tests that need in-project dbs.
158    fn make_transformer<'a>(
159        fqn: &'a FullyQualifiedName,
160        profile_name: &'a str,
161        in_project_databases: &'a BTreeSet<String>,
162        overlay_objects: &'a BTreeSet<ObjectId>,
163    ) -> OverlayTransformer<'a> {
164        OverlayTransformer {
165            fqn,
166            profile_name,
167            in_project_databases,
168            overlay_objects,
169            target_cluster: "quickstart_dev",
170        }
171    }
172
173    fn obj(database: &str, schema: &str, object: &str) -> ObjectId {
174        ObjectId::new(database.to_string(), schema.to_string(), object.to_string())
175    }
176
177    // External reference: database not in in_project_databases → verbatim.
178    #[mz_ore::test]
179    fn external_reference_unchanged() {
180        let fqn = make_fqn("mydb", "public", "ctx");
181        let in_project = BTreeSet::from(["mydb".to_string()]);
182        let overlay: BTreeSet<ObjectId> = BTreeSet::new();
183        let t = make_transformer(&fqn, "alice", &in_project, &overlay);
184
185        let input = make_name(&["external_db", "analytics", "events"]);
186        let result = t.transform_name(&input);
187
188        assert_eq!(result.0[0].as_str(), "external_db");
189        assert_eq!(result.0[1].as_str(), "analytics");
190        assert_eq!(result.0[2].as_str(), "events");
191    }
192
193    // In-project DB, clean schema → unchanged 3-part name.
194    #[mz_ore::test]
195    fn in_project_clean_schema_routes_to_prod() {
196        let fqn = make_fqn("mydb", "public", "ctx");
197        let in_project = BTreeSet::from(["mydb".to_string()]);
198        let overlay: BTreeSet<ObjectId> = BTreeSet::new();
199        let t = make_transformer(&fqn, "alice", &in_project, &overlay);
200
201        let input = make_name(&["mydb", "public", "orders"]);
202        let result = t.transform_name(&input);
203
204        assert_eq!(result.0[0].as_str(), "mydb");
205        assert_eq!(result.0[1].as_str(), "public");
206        assert_eq!(result.0[2].as_str(), "orders");
207    }
208
209    // In-project DB, object IS overlaid → db becomes db__profile.
210    #[mz_ore::test]
211    fn in_project_overlaid_object_routes_to_overlay() {
212        let fqn = make_fqn("mydb", "public", "ctx");
213        let in_project = BTreeSet::from(["mydb".to_string()]);
214        let overlay = BTreeSet::from([obj("mydb", "public", "orders")]);
215        let t = make_transformer(&fqn, "alice", &in_project, &overlay);
216
217        let input = make_name(&["mydb", "public", "orders"]);
218        let result = t.transform_name(&input);
219
220        assert_eq!(result.0[0].as_str(), "mydb__alice");
221        assert_eq!(result.0[1].as_str(), "public");
222        assert_eq!(result.0[2].as_str(), "orders");
223    }
224
225    // A non-overlaid object that shares a schema with an overlaid one routes
226    // to production, not the overlay (the overlay never creates it).
227    #[mz_ore::test]
228    fn non_overlaid_object_in_overlaid_schema_routes_to_prod() {
229        let fqn = make_fqn("mydb", "reports", "summary");
230        let in_project = BTreeSet::from(["mydb".to_string()]);
231        // `summary` is overlaid; `legacy_table` (same schema) is not.
232        let overlay = BTreeSet::from([obj("mydb", "reports", "summary")]);
233        let t = make_transformer(&fqn, "alice", &in_project, &overlay);
234
235        let input = make_name(&["mydb", "reports", "legacy_table"]);
236        let result = t.transform_name(&input);
237
238        assert_eq!(result.0[0].as_str(), "mydb");
239        assert_eq!(result.0[1].as_str(), "reports");
240        assert_eq!(result.0[2].as_str(), "legacy_table");
241    }
242
243    // Unqualified (1-part) name: fqn database + schema used, then
244    // routed to overlay if the resolved object is overlaid.
245    #[mz_ore::test]
246    fn unqualified_name_resolved_via_fqn_then_routed_to_overlay() {
247        let fqn = make_fqn("mydb", "public", "ctx");
248        let in_project = BTreeSet::from(["mydb".to_string()]);
249        let overlay = BTreeSet::from([obj("mydb", "public", "orders")]);
250        let t = make_transformer(&fqn, "alice", &in_project, &overlay);
251
252        // 1-part: just "orders"
253        let input = make_name(&["orders"]);
254        let result = t.transform_name(&input);
255
256        assert_eq!(result.0[0].as_str(), "mydb__alice");
257        assert_eq!(result.0[1].as_str(), "public");
258        assert_eq!(result.0[2].as_str(), "orders");
259    }
260
261    // Cluster rewrite: any input cluster name → target_cluster.
262    #[mz_ore::test]
263    fn transform_cluster_rewrites_to_target() {
264        let fqn = make_fqn("mydb", "public", "ctx");
265        let in_project = BTreeSet::from(["mydb".to_string()]);
266        let overlay: BTreeSet<ObjectId> = BTreeSet::new();
267        let t = make_transformer(&fqn, "alice", &in_project, &overlay);
268
269        let input = Ident::new("prod").expect("valid identifier");
270        let out = t.transform_cluster(&input);
271        assert_eq!(out.as_str(), "quickstart_dev");
272
273        let input2 = Ident::new("anything_else").expect("valid identifier");
274        let out2 = t.transform_cluster(&input2);
275        assert_eq!(out2.as_str(), "quickstart_dev");
276    }
277
278    // Schema-qualified (2-part) name: fqn database prepended, then
279    // routed to overlay if the resolved object is overlaid.
280    #[mz_ore::test]
281    fn schema_qualified_name_resolved_via_fqn_then_routed_to_overlay() {
282        let fqn = make_fqn("mydb", "public", "ctx");
283        let in_project = BTreeSet::from(["mydb".to_string()]);
284        let overlay = BTreeSet::from([obj("mydb", "analytics", "summary")]);
285        let t = make_transformer(&fqn, "alice", &in_project, &overlay);
286
287        // 2-part: "analytics.summary" — fqn database prepended
288        let input = make_name(&["analytics", "summary"]);
289        let result = t.transform_name(&input);
290
291        assert_eq!(result.0[0].as_str(), "mydb__alice");
292        assert_eq!(result.0[1].as_str(), "analytics");
293        assert_eq!(result.0[2].as_str(), "summary");
294    }
295}