Skip to main content

mz_deploy/cli/commands/
delete.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//! Delete command — drop an object from Materialize and remove its project file.
11
12use crate::cli::CliError;
13use crate::cli::progress;
14use crate::client::{Client, ConnectionError, quote_identifier};
15use crate::config::Settings;
16use crate::log;
17use crate::project::ir::object_id::ObjectId;
18use crate::{info, info_nonl};
19use std::fmt;
20use std::io::{self, Write};
21use std::path::{Path, PathBuf};
22
23/// The kind of object to delete.
24#[derive(Debug, Clone, Copy)]
25pub enum ObjectKind {
26    Cluster,
27    Connection,
28    NetworkPolicy,
29    Role,
30    Secret,
31    Source,
32    Table,
33}
34
35impl ObjectKind {
36    fn label(&self) -> &'static str {
37        match self {
38            ObjectKind::Cluster => "cluster",
39            ObjectKind::Connection => "connection",
40            ObjectKind::NetworkPolicy => "network policy",
41            ObjectKind::Role => "role",
42            ObjectKind::Secret => "secret",
43            ObjectKind::Source => "source",
44            ObjectKind::Table => "table",
45        }
46    }
47
48    fn sql_keyword(&self) -> &'static str {
49        match self {
50            ObjectKind::Cluster => "CLUSTER",
51            ObjectKind::Connection => "CONNECTION",
52            ObjectKind::NetworkPolicy => "NETWORK POLICY",
53            ObjectKind::Role => "ROLE",
54            ObjectKind::Secret => "SECRET",
55            ObjectKind::Source => "SOURCE",
56            ObjectKind::Table => "TABLE",
57        }
58    }
59}
60
61#[derive(serde::Serialize)]
62struct DeleteResult {
63    kind: String,
64    name: String,
65    file_removed: String,
66}
67
68impl fmt::Display for DeleteResult {
69    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
70        write!(
71            f,
72            "  \u{2713} Dropped {} '{}' and removed {}",
73            self.kind, self.name, self.file_removed
74        )
75    }
76}
77
78/// A resolved delete target: the parsed name, file path, and DROP SQL.
79struct DeleteTarget {
80    file_path: PathBuf,
81    drop_sql: String,
82}
83
84impl DeleteTarget {
85    fn resolve(directory: &Path, kind: ObjectKind, name: &str) -> Result<Self, CliError> {
86        let keyword = kind.sql_keyword();
87        let (file_path, drop_sql) = match kind {
88            ObjectKind::Cluster => (
89                directory.join("clusters").join(format!("{}.sql", name)),
90                format!("DROP {} {}", keyword, quote_identifier(name)),
91            ),
92            ObjectKind::NetworkPolicy => (
93                directory
94                    .join("network-policies")
95                    .join(format!("{}.sql", name)),
96                format!("DROP {} {}", keyword, quote_identifier(name)),
97            ),
98            ObjectKind::Role => (
99                directory.join("roles").join(format!("{}.sql", name)),
100                format!("DROP {} {}", keyword, quote_identifier(name)),
101            ),
102            ObjectKind::Connection
103            | ObjectKind::Secret
104            | ObjectKind::Source
105            | ObjectKind::Table => {
106                let oid = name.parse::<ObjectId>().map_err(CliError::Message)?;
107                (
108                    directory
109                        .join("models")
110                        .join(oid.expect_database())
111                        .join(oid.schema())
112                        .join(format!("{}.sql", oid.object())),
113                    format!(
114                        "DROP {} {}.{}.{}",
115                        keyword,
116                        quote_identifier(oid.expect_database()),
117                        quote_identifier(oid.schema()),
118                        quote_identifier(oid.object()),
119                    ),
120                )
121            }
122        };
123        Ok(Self {
124            file_path,
125            drop_sql,
126        })
127    }
128}
129
130/// Run the `delete` command.
131///
132/// Drops the named object from Materialize and removes the corresponding
133/// project file. Prompts for confirmation unless `yes` is true.
134pub async fn run(
135    settings: &Settings,
136    kind: ObjectKind,
137    name: &str,
138    yes: bool,
139) -> Result<(), CliError> {
140    let profile = settings.connection();
141    let directory = &settings.directory;
142    let label = kind.label();
143
144    let target = DeleteTarget::resolve(directory, kind, name)?;
145    if !target.file_path.exists() {
146        return Err(CliError::Message(format!(
147            "'{name}' is not managed by this project (no file at {})",
148            target.file_path.display()
149        )));
150    }
151
152    if log::json_output_enabled() && !yes {
153        return Err(CliError::Message(
154            "--output json requires --yes to skip interactive confirmation".to_string(),
155        ));
156    }
157
158    if !yes {
159        info!(
160            "This will drop {} '{}' from Materialize and remove {}",
161            label,
162            name,
163            target.file_path.display()
164        );
165        info_nonl!("Continue? [y/N] ");
166        io::stdout().flush()?;
167        let mut input = String::new();
168        io::stdin().read_line(&mut input)?;
169        if !input.trim().eq_ignore_ascii_case("y") {
170            info!("Aborted.");
171            return Ok(());
172        }
173    }
174
175    progress::stage_start(&format!("Dropping {} '{}'", label, name));
176    let client = Client::connect_with_profile(profile.clone())
177        .await
178        .map_err(CliError::Connection)?;
179    super::setup::verify(&client, settings.emulator()).await?;
180    let role = super::setup::validate_connection(&client, settings.emulator()).await?;
181    super::setup::require_deployer(role)?;
182
183    client.execute(&target.drop_sql, &[]).await.map_err(|e| {
184        let msg = e.to_string();
185        if msg.contains("depended upon") || msg.contains("depends on") {
186            CliError::Message(format!(
187                "cannot drop {} '{}' because other objects depend on it.\n\
188                 Drop the dependent objects first, then retry.",
189                label, name
190            ))
191        } else {
192            CliError::Connection(ConnectionError::Message(format!(
193                "failed to drop {} '{}': {}",
194                label, name, e
195            )))
196        }
197    })?;
198
199    if let Err(e) = std::fs::remove_file(&target.file_path) {
200        progress::warn(&format!(
201            "DROP succeeded but failed to remove {}: {}",
202            target.file_path.display(),
203            e
204        ));
205        return Err(e.into());
206    }
207
208    let result = DeleteResult {
209        kind: label.to_string(),
210        name: name.to_string(),
211        file_removed: target.file_path.display().to_string(),
212    };
213    log::output(&result);
214
215    Ok(())
216}