Skip to main content

mz_deploy/cli/commands/
dev.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//! `mz-deploy dev` — developer inner-loop overlay command.
11//!
12//! Creates per-developer overlay databases (`<base_db>__<profile>`) from
13//! the dirty subset of the project's views, materialized views, and indexes.
14//! Every overlay materialized view and index is rewritten to run on a single
15//! user-supplied target cluster. The overlay is drop-and-rebuilt on every
16//! invocation.
17//!
18//! Requires the `materialize_developer` role plus `CREATEDB` at run time.
19
20use std::collections::BTreeSet;
21
22use crate::cli::commands::ObjectRef;
23use crate::cli::error::CliError;
24use crate::client::{Client, quote_identifier};
25use crate::config::Settings;
26use crate::project::SchemaQualifier;
27use crate::project::analysis::changeset::ChangeSet;
28use crate::project::analysis::deployment_snapshot;
29use crate::project::ast::Statement;
30use crate::project::ir::compiled::FullyQualifiedName;
31use crate::project::ir::object_id::ObjectId;
32use crate::project::resolve::normalize::NormalizingVisitor;
33use crate::{info, verbose};
34
35/// Overlay database name convention: `<base_db>__<profile>`.
36fn overlay_db_name(base_db: &str, profile: &str) -> String {
37    format!("{}__{}", base_db, profile)
38}
39
40/// Refuse to proceed if the user-supplied target cluster hosts a promoted
41/// deployment.
42async fn refuse_if_targets_production_cluster(
43    client: &Client,
44    cluster: &str,
45) -> Result<(), CliError> {
46    let production = client.deployments().list_production_clusters().await?;
47    if let Some(rec) = production.into_iter().find(|r| r.cluster_name == cluster) {
48        return Err(CliError::DevTargetsProductionCluster { cluster: rec });
49    }
50    Ok(())
51}
52
53/// Top-level entry point for `mz-deploy dev`.
54///
55/// Orchestrates role/privilege validation, dirty-set computation, plan
56/// printing, and the drop+create DDL phases.
57///
58/// * `cluster` — target cluster for every overlay MV and index. Required
59///   unless `down` is set (clap enforces this).
60/// * `down` — when `true`, only run the drop phase and exit immediately.
61/// * `dry_run` — when `true`, print the plan but issue no DDL.
62pub async fn run(
63    settings: &Settings,
64    cluster: Option<String>,
65    down: bool,
66    dry_run: bool,
67) -> Result<(), CliError> {
68    let profile = settings.connection();
69    // `dev` always loads with `needs_connection: true`, so a profile must be set.
70    let profile_name = settings
71        .profile_name
72        .clone()
73        .expect("dev requires an active profile");
74    let project_name = settings
75        .directory
76        .file_name()
77        .and_then(|s| s.to_str())
78        .filter(|s| !s.is_empty())
79        .ok_or_else(|| CliError::InvalidProjectDirectory {
80            path: settings.directory.display().to_string(),
81        })?
82        .to_string();
83
84    let planned_project = super::compile::run(settings, true).await?;
85
86    let in_project_databases: BTreeSet<String> = planned_project
87        .databases
88        .iter()
89        .map(|db| db.name.clone())
90        .collect();
91
92    let client = Client::connect_with_profile(profile.clone())
93        .await
94        .map_err(CliError::Connection)?;
95
96    crate::cli::commands::setup::verify(&client, settings.emulator()).await?;
97    let role =
98        crate::cli::commands::setup::validate_connection(&client, settings.emulator()).await?;
99    crate::cli::commands::setup::require_developer(role)?;
100
101    if in_project_databases.is_empty() {
102        info!("Project has no databases — nothing to overlay.");
103        return Ok(());
104    }
105
106    let sample_overlay_db = overlay_db_name(
107        in_project_databases.iter().next().expect("non-empty"),
108        &profile_name,
109    );
110    crate::cli::commands::setup::require_createdb(&client, &profile.username, &sample_overlay_db)
111        .await?;
112
113    if down {
114        drop_phase(&client, &profile_name, &project_name, &in_project_databases).await?;
115        info!("Overlay removed.");
116        return Ok(());
117    }
118
119    // clap guarantees `cluster` is `Some` whenever `down` is false.
120    let target_cluster = cluster.expect("cluster required unless --down");
121    refuse_if_targets_production_cluster(&client, &target_cluster).await?;
122
123    let new_snapshot = deployment_snapshot::build_snapshot_from_planned(&planned_project)?;
124    let production_snapshot = deployment_snapshot::load_from_database(&client, None).await?;
125
126    // Empty production → full overlay (first-run semantics matching stage).
127    let change_set = if production_snapshot.objects.is_empty() {
128        verbose!("Full deployment: no production deployment found");
129        None
130    } else {
131        Some(ChangeSet::from_deployment_snapshot_comparison(
132            &production_snapshot,
133            &new_snapshot,
134            &planned_project,
135            &BTreeSet::new(),
136        ))
137    };
138
139    let all_objects = match change_set.as_ref() {
140        Some(cs) if cs.is_empty() => Vec::new(),
141        Some(cs) => {
142            verbose!("{}", cs);
143            planned_project.get_sorted_objects_filtered(&cs.objects_to_deploy)?
144        }
145        None => planned_project.get_sorted_objects()?,
146    };
147
148    let mut skipped = 0usize;
149    let overlay_objects: Vec<ObjectRef<'_>> = all_objects
150        .into_iter()
151        .filter(|(_, typed_obj)| match &typed_obj.stmt {
152            Statement::CreateView(_) | Statement::CreateMaterializedView(_) => true,
153            _ => {
154                skipped += 1;
155                false
156            }
157        })
158        .collect();
159    if skipped > 0 {
160        verbose!(
161            "skipped {} object(s) of unsupported type (tables/sources/sinks)",
162            skipped
163        );
164    }
165
166    let dirty_schemas: BTreeSet<SchemaQualifier> = overlay_objects
167        .iter()
168        .map(|(id, _)| {
169            SchemaQualifier::new(id.expect_database().to_string(), id.schema().to_string())
170        })
171        .collect();
172
173    print_plan(&dirty_schemas, &profile_name);
174
175    if dry_run {
176        return Ok(());
177    }
178
179    drop_phase(&client, &profile_name, &project_name, &in_project_databases).await?;
180
181    if dirty_schemas.is_empty() {
182        info!("Dev overlay ready (nothing to overlay).");
183        return Ok(());
184    }
185
186    create_phase(
187        &client,
188        &profile_name,
189        &project_name,
190        &in_project_databases,
191        &dirty_schemas,
192        &overlay_objects,
193        &target_cluster,
194    )
195    .await?;
196
197    info!("Dev overlay ready.");
198    Ok(())
199}
200
201fn print_plan(dirty_schemas: &BTreeSet<SchemaQualifier>, profile_name: &str) {
202    if dirty_schemas.is_empty() {
203        info!("Dirty set is empty — nothing to overlay.");
204        return;
205    }
206    info!("→ Dirty schemas:");
207    for qual in dirty_schemas {
208        info!("    {}.{}", qual.database, qual.schema);
209    }
210
211    let overlay_dbs: BTreeSet<String> = dirty_schemas
212        .iter()
213        .map(|q| overlay_db_name(&q.database, profile_name))
214        .collect();
215    info!("→ Overlay databases:");
216    for db in &overlay_dbs {
217        info!("    {}", db);
218    }
219}
220
221/// Phase 1 of the dev rebuild: drop every overlay database recorded for
222/// this `(profile, project)` pair, then purge the manifest rows. Finally
223/// sweep any in-project `<base_db>__<profile>` names not in the manifest
224/// (catalog restore, interrupted prior run).
225pub(crate) async fn drop_phase(
226    client: &Client,
227    profile_name: &str,
228    project_name: &str,
229    in_project_databases: &BTreeSet<String>,
230) -> Result<(), CliError> {
231    let overlays = client.dev_overlays();
232
233    let existing: BTreeSet<String> = overlays
234        .list_overlays(profile_name, project_name)
235        .await?
236        .into_iter()
237        .collect();
238    for db in &existing {
239        drop_database(client, db).await?;
240    }
241    overlays.delete_overlays(profile_name, project_name).await?;
242
243    for base_db in in_project_databases {
244        let overlay_db = overlay_db_name(base_db, profile_name);
245        if !existing.contains(&overlay_db) {
246            drop_database(client, &overlay_db).await?;
247        }
248    }
249
250    Ok(())
251}
252
253async fn drop_database(client: &Client, database: &str) -> Result<(), CliError> {
254    let sql = format!(
255        "DROP DATABASE IF EXISTS {} CASCADE",
256        quote_identifier(database),
257    );
258    client.execute(&sql, &[]).await?;
259    Ok(())
260}
261
262/// Phase 2 of the dev rebuild: create overlay databases, schemas, and objects.
263///
264/// Per dirty schema we issue `CREATE DATABASE IF NOT EXISTS <overlay_db>`,
265/// insert a manifest row (so `drop_phase` can always reach it even if we crash
266/// mid-run), then `CREATE SCHEMA IF NOT EXISTS`. Objects are emitted in
267/// dependency order with references rewritten through `OverlayTransformer`
268/// and every `IN CLUSTER` clause rewritten to `target_cluster`.
269pub(crate) async fn create_phase(
270    client: &Client,
271    profile_name: &str,
272    project_name: &str,
273    in_project_databases: &BTreeSet<String>,
274    dirty_schemas: &BTreeSet<SchemaQualifier>,
275    overlay_objects: &[ObjectRef<'_>],
276    target_cluster: &str,
277) -> Result<(), CliError> {
278    let provisioning = client.provisioning();
279    let overlays = client.dev_overlays();
280
281    let mut created_overlay_dbs: BTreeSet<String> = BTreeSet::new();
282    for qualifier in dirty_schemas {
283        let overlay_db = overlay_db_name(&qualifier.database, profile_name);
284        if created_overlay_dbs.insert(overlay_db.clone()) {
285            provisioning.create_database(&overlay_db).await?;
286            overlays
287                .insert_overlay(profile_name, project_name, &overlay_db)
288                .await?;
289        }
290    }
291
292    for qualifier in dirty_schemas {
293        let overlay_db = overlay_db_name(&qualifier.database, profile_name);
294        provisioning
295            .create_schema(&overlay_db, &qualifier.schema)
296            .await?;
297    }
298
299    let overlay_object_ids: BTreeSet<ObjectId> = overlay_objects
300        .iter()
301        .map(|(id, _)| (*id).clone())
302        .collect();
303
304    for (object_id, typed_object) in overlay_objects {
305        let original_fqn: FullyQualifiedName = object_id.clone().into();
306        let mut visitor = NormalizingVisitor::overlay(
307            &original_fqn,
308            profile_name,
309            in_project_databases,
310            &overlay_object_ids,
311            target_cluster,
312        );
313
314        let stmt = typed_object
315            .stmt
316            .clone()
317            .normalize_name_with(&visitor, &original_fqn.to_item_name())
318            .normalize_dependencies_with(&mut visitor)
319            .normalize_cluster_with(&visitor);
320
321        client.execute(&stmt.to_string(), &[]).await?;
322
323        let mut indexes = typed_object.indexes.clone();
324        visitor.normalize_index_references(&mut indexes);
325        visitor.normalize_index_clusters(&mut indexes);
326        for index in &indexes {
327            client.execute(&index.to_string(), &[]).await?;
328        }
329    }
330
331    Ok(())
332}