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