Skip to main content

mz_deploy/client/
validation.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//! Database validation operations.
11//!
12//! Validates that the target Materialize environment satisfies all prerequisites
13//! for deploying the project. Runs before any DDL is executed.
14//!
15//! ## Validation Checklist
16//!
17//! | Check | Function | What it verifies |
18//! |-------|----------|-----------------|
19//! | External databases exist | `find_missing_databases` | Databases referenced by external dependencies are present |
20//! | External schemas exist | `find_missing_schemas` | Schemas referenced by external dependencies are present |
21//! | Clusters exist | `find_missing_clusters` | All clusters in `project.cluster_dependencies` are present |
22//! | External objects exist | `find_missing_external_dependencies` | Objects outside the project that are referenced exist in the catalog |
23//! | Cluster isolation | `validate_cluster_isolation_impl` | Sources/sinks don't share clusters with MVs/indexes (prevents accidental recreation during swap) |
24//! | Privileges | `validate_privileges_impl` | Current role has USAGE on databases and CREATECLUSTER system privilege |
25//! | Sources exist | `validate_sources_exist_impl` | Sources referenced by `CREATE TABLE FROM SOURCE` exist |
26//! | Sink connections exist | `validate_sink_connections_exist_impl` | Connections referenced by sinks exist |
27//! | Schema ownership | `validate_schema_ownership_impl` | Current role owns all production schemas that will be swapped |
28//! | Cluster ownership | `validate_cluster_ownership_impl` | Current role owns all production clusters that will be swapped |
29//! | Table dependencies | `validate_table_dependencies_impl` | Tables depended on by objects being deployed exist |
30//! | Source references | `validate_source_references_impl` | Each `CREATE TABLE FROM SOURCE` names an object its source can read |
31//!
32//! ## Batching Strategy
33//!
34//! Catalog lookups use `IN` clause queries batched in chunks of
35//! `LOOKUP_BATCH_SIZE` (1000) to avoid exceeding query parameter limits
36//! while minimizing round trips.
37
38use crate::client::connection::{Client, ValidationClient};
39use crate::client::errors::{
40    DatabaseValidationError, MissingSourceReference, SourceReferenceMismatch,
41};
42use crate::client::sql_placeholders;
43use crate::project::SchemaQualifier;
44use crate::project::ast::Statement;
45use crate::project::ir::graph;
46use crate::project::ir::object_id::ObjectId;
47use crate::suggest::{MAX_DID_YOU_MEAN, did_you_mean};
48use crate::{info, verbose};
49use mz_sql_parser::ast::{CreateSinkConnection, Ident, UnresolvedItemName};
50use std::collections::{BTreeMap, BTreeSet};
51use std::fmt;
52use std::path::Path;
53use std::path::PathBuf;
54use tokio_postgres::types::ToSql;
55
56const LOOKUP_BATCH_SIZE: usize = 1000;
57
58enum CatalogLookup {
59    Objects,
60    Sources,
61    Tables,
62    Connections,
63}
64
65impl CatalogLookup {
66    fn table_name(&self) -> &'static str {
67        match self {
68            CatalogLookup::Objects => "mz_objects",
69            CatalogLookup::Sources => "mz_sources",
70            CatalogLookup::Tables => "mz_tables",
71            CatalogLookup::Connections => "mz_connections",
72        }
73    }
74}
75
76/// Internal helper to query which sources exist on the given clusters using IN clause.
77pub(crate) async fn query_sources_by_cluster(
78    client: &Client,
79    cluster_names: &BTreeSet<String>,
80) -> Result<BTreeMap<String, Vec<String>>, DatabaseValidationError> {
81    if cluster_names.is_empty() {
82        return Ok(BTreeMap::new());
83    }
84
85    let in_clause = sql_placeholders(cluster_names.len());
86
87    let query = format!(
88        r#"
89        SELECT
90            c.name as cluster_name,
91            d.name || '.' || s.name || '.' || mo.name as fqn
92        FROM mz_catalog.mz_sources src
93        JOIN mz_catalog.mz_objects mo ON src.id = mo.id
94        JOIN mz_catalog.mz_schemas s ON mo.schema_id = s.id
95        JOIN mz_catalog.mz_databases d ON s.database_id = d.id
96        JOIN mz_catalog.mz_clusters c ON src.cluster_id = c.id
97        WHERE mo.id LIKE 'u%' AND c.name IN ({})
98        "#,
99        in_clause
100    );
101
102    #[allow(clippy::as_conversions)]
103    let params: Vec<&(dyn ToSql + Sync)> = cluster_names
104        .iter()
105        .map(|s| s as &(dyn ToSql + Sync))
106        .collect();
107
108    let rows = client
109        .query(&query, &params)
110        .await
111        .map_err(DatabaseValidationError::QueryError)?;
112
113    let mut result: BTreeMap<String, Vec<String>> = BTreeMap::new();
114    for row in rows {
115        let cluster_name: String = row.get("cluster_name");
116        let fqn: String = row.get("fqn");
117        result
118            .entry(cluster_name)
119            .or_insert_with(Vec::new)
120            .push(fqn);
121    }
122
123    Ok(result)
124}
125
126async fn query_existing_names(
127    client: &Client,
128    table_name: &str,
129    column_name: &str,
130    names: &BTreeSet<String>,
131) -> Result<BTreeSet<String>, DatabaseValidationError> {
132    let mut existing = BTreeSet::new();
133    if names.is_empty() {
134        return Ok(existing);
135    }
136
137    let name_list: Vec<String> = names.iter().cloned().collect();
138    for chunk in name_list.chunks(LOOKUP_BATCH_SIZE) {
139        let placeholders = sql_placeholders(chunk.len());
140        let query = format!(
141            "SELECT {column} FROM {table} WHERE {column} IN ({placeholders})",
142            column = column_name,
143            table = table_name,
144            placeholders = placeholders
145        );
146
147        #[allow(clippy::as_conversions)]
148        let params: Vec<&(dyn ToSql + Sync)> = chunk
149            .iter()
150            .map(|name| name as &(dyn ToSql + Sync))
151            .collect();
152
153        let rows = client
154            .query(&query, &params)
155            .await
156            .map_err(DatabaseValidationError::QueryError)?;
157        for row in rows {
158            let name: String = row.get(column_name);
159            existing.insert(name);
160        }
161    }
162
163    Ok(existing)
164}
165
166async fn query_existing_schema_pairs(
167    client: &Client,
168    schema_pairs: &BTreeSet<(String, String)>,
169) -> Result<BTreeSet<(String, String)>, DatabaseValidationError> {
170    let mut existing = BTreeSet::new();
171    if schema_pairs.is_empty() {
172        return Ok(existing);
173    }
174
175    let fqn_to_pair: BTreeMap<String, (String, String)> = schema_pairs
176        .iter()
177        .map(|(database, schema)| {
178            (
179                format!("{}.{}", database, schema),
180                (database.clone(), schema.clone()),
181            )
182        })
183        .collect();
184    let fqns: Vec<String> = fqn_to_pair.keys().cloned().collect();
185
186    for chunk in fqns.chunks(LOOKUP_BATCH_SIZE) {
187        let placeholders = sql_placeholders(chunk.len());
188        let query = format!(
189            r#"
190            SELECT d.name || '.' || s.name AS fqn
191            FROM mz_schemas s
192            JOIN mz_databases d ON s.database_id = d.id
193            WHERE d.name || '.' || s.name IN ({})
194            "#,
195            placeholders
196        );
197
198        #[allow(clippy::as_conversions)]
199        let params: Vec<&(dyn ToSql + Sync)> =
200            chunk.iter().map(|fqn| fqn as &(dyn ToSql + Sync)).collect();
201
202        let rows = client
203            .query(&query, &params)
204            .await
205            .map_err(DatabaseValidationError::QueryError)?;
206        for row in rows {
207            let fqn: String = row.get("fqn");
208            if let Some(pair) = fqn_to_pair.get(&fqn) {
209                existing.insert(pair.clone());
210            }
211        }
212    }
213
214    Ok(existing)
215}
216
217async fn query_existing_object_ids(
218    client: &Client,
219    object_ids: &BTreeSet<ObjectId>,
220    lookup: CatalogLookup,
221) -> Result<BTreeSet<ObjectId>, DatabaseValidationError> {
222    let mut existing = BTreeSet::new();
223    if object_ids.is_empty() {
224        return Ok(existing);
225    }
226
227    let fqn_to_object: BTreeMap<String, ObjectId> = object_ids
228        .iter()
229        .map(|obj| (obj.to_string(), obj.clone()))
230        .collect();
231    let fqns: Vec<String> = fqn_to_object.keys().cloned().collect();
232    let table_name = lookup.table_name();
233
234    for chunk in fqns.chunks(LOOKUP_BATCH_SIZE) {
235        let placeholders = sql_placeholders(chunk.len());
236        let query = format!(
237            r#"
238            SELECT d.name || '.' || s.name || '.' || t.name AS fqn
239            FROM {table_name} t
240            JOIN mz_schemas s ON t.schema_id = s.id
241            JOIN mz_databases d ON s.database_id = d.id
242            WHERE d.name || '.' || s.name || '.' || t.name IN ({placeholders})
243            "#,
244            table_name = table_name,
245            placeholders = placeholders
246        );
247
248        #[allow(clippy::as_conversions)]
249        let params: Vec<&(dyn ToSql + Sync)> =
250            chunk.iter().map(|fqn| fqn as &(dyn ToSql + Sync)).collect();
251
252        let rows = client
253            .query(&query, &params)
254            .await
255            .map_err(DatabaseValidationError::QueryError)?;
256        for row in rows {
257            let fqn: String = row.get("fqn");
258            if let Some(obj) = fqn_to_object.get(&fqn) {
259                existing.insert(obj.clone());
260            }
261        }
262    }
263
264    Ok(existing)
265}
266
267/// Internal implementation of validate_project.
268pub(crate) async fn validate_project_impl(
269    client: &Client,
270    planned_project: &graph::Project,
271    project_root: &Path,
272) -> Result<(), DatabaseValidationError> {
273    let (external_databases, external_schemas) = collect_external_dependencies(planned_project);
274    let missing_databases = find_missing_databases(client, &external_databases).await?;
275    let missing_schemas = find_missing_schemas(client, &external_schemas).await?;
276    let missing_clusters = find_missing_clusters(client, planned_project).await?;
277    let object_paths = build_object_paths(planned_project, project_root);
278    let missing_external_deps = find_missing_external_dependencies(client, planned_project).await?;
279    let compilation_errors =
280        build_compilation_errors(planned_project, &object_paths, &missing_external_deps);
281
282    if !missing_databases.is_empty()
283        || !missing_schemas.is_empty()
284        || !missing_clusters.is_empty()
285        || !compilation_errors.is_empty()
286    {
287        Err(DatabaseValidationError::Multiple {
288            databases: missing_databases,
289            schemas: missing_schemas,
290            clusters: missing_clusters,
291            compilation_errors,
292        })
293    } else {
294        Ok(())
295    }
296}
297
298/// Derives the set of external database/schema prerequisites from project dependencies.
299///
300/// Project-owned databases are excluded because deployment can create them if needed.
301fn collect_external_dependencies(
302    planned_project: &graph::Project,
303) -> (BTreeSet<String>, BTreeSet<(String, String)>) {
304    let project_databases: BTreeSet<_> = planned_project
305        .databases
306        .iter()
307        .map(|db| db.name.clone())
308        .collect();
309
310    let mut external_databases = BTreeSet::new();
311    let mut external_schemas = BTreeSet::new();
312    for ext_dep in &planned_project.external_dependencies {
313        // System-schema deps have no database, so there's nothing to require.
314        let Some(db) = ext_dep.database() else {
315            continue;
316        };
317        if !project_databases.contains(db) {
318            external_databases.insert(db.to_string());
319        }
320        external_schemas.insert((db.to_string(), ext_dep.schema().to_string()));
321    }
322    (external_databases, external_schemas)
323}
324
325/// Checks catalog state for external databases that must pre-exist.
326async fn find_missing_databases(
327    client: &Client,
328    external_databases: &BTreeSet<String>,
329) -> Result<Vec<String>, DatabaseValidationError> {
330    let existing = query_existing_names(client, "mz_databases", "name", external_databases).await?;
331    Ok(external_databases.difference(&existing).cloned().collect())
332}
333
334/// Checks catalog state for external schemas that must pre-exist.
335async fn find_missing_schemas(
336    client: &Client,
337    external_schemas: &BTreeSet<(String, String)>,
338) -> Result<Vec<SchemaQualifier>, DatabaseValidationError> {
339    let existing = query_existing_schema_pairs(client, external_schemas).await?;
340    Ok(external_schemas
341        .difference(&existing)
342        .map(|(db, schema)| SchemaQualifier::new(db.clone(), schema.clone()))
343        .collect())
344}
345
346/// Checks whether all cluster dependencies referenced by the project are present.
347async fn find_missing_clusters(
348    client: &Client,
349    planned_project: &graph::Project,
350) -> Result<Vec<String>, DatabaseValidationError> {
351    let required: BTreeSet<String> = planned_project
352        .cluster_dependencies
353        .iter()
354        .map(|cluster| cluster.name.clone())
355        .collect();
356    let existing = query_existing_names(client, "mz_clusters", "name", &required).await?;
357    Ok(required.difference(&existing).cloned().collect())
358}
359
360/// Reconstructs source file paths for planned objects under `models/`.
361///
362/// These paths are used to attach dependency errors to concrete files for users.
363fn build_object_paths(
364    planned_project: &graph::Project,
365    project_root: &Path,
366) -> BTreeMap<ObjectId, PathBuf> {
367    let mut object_paths = BTreeMap::new();
368    for db in &planned_project.databases {
369        for schema in &db.schemas {
370            for obj in &schema.objects {
371                let file_path = project_root
372                    .join("models")
373                    .join(obj.id.expect_database())
374                    .join(obj.id.schema())
375                    .join(format!("{}.sql", obj.id.object()));
376                object_paths.insert(obj.id.clone(), file_path);
377            }
378        }
379    }
380    object_paths
381}
382
383/// Checks whether externally-referenced objects actually exist in the target catalog.
384async fn find_missing_external_dependencies(
385    client: &Client,
386    planned_project: &graph::Project,
387) -> Result<BTreeSet<ObjectId>, DatabaseValidationError> {
388    // System-schema dependencies are database-less and always present. Their
389    // 2-part name never matches the 3-part FQN the existence query builds, so
390    // including them here would wrongly report them missing.
391    let external_deps: BTreeSet<ObjectId> = planned_project
392        .external_dependencies
393        .iter()
394        .filter(|dep| dep.database().is_some())
395        .cloned()
396        .collect();
397    let existing =
398        query_existing_object_ids(client, &external_deps, CatalogLookup::Objects).await?;
399    Ok(external_deps.difference(&existing).cloned().collect())
400}
401
402/// Converts missing external dependencies into user-facing, file-scoped errors.
403///
404/// Grouping by file/object keeps output aligned with how users navigate project SQL.
405fn build_compilation_errors(
406    planned_project: &graph::Project,
407    object_paths: &BTreeMap<ObjectId, PathBuf>,
408    missing_external_deps: &BTreeSet<ObjectId>,
409) -> Vec<DatabaseValidationError> {
410    let mut errors = Vec::new();
411    for db in &planned_project.databases {
412        for schema in &db.schemas {
413            for obj in &schema.objects {
414                let missing_for_object: Vec<_> = obj
415                    .dependencies
416                    .iter()
417                    .filter(|dep| missing_external_deps.contains(*dep))
418                    .cloned()
419                    .collect();
420                if missing_for_object.is_empty() {
421                    continue;
422                }
423                if let Some(file_path) = object_paths.get(&obj.id) {
424                    errors.push(DatabaseValidationError::CompilationFailed {
425                        file_path: file_path.clone(),
426                        object_name: obj.id.clone(),
427                        missing_dependencies: missing_for_object,
428                    });
429                }
430            }
431        }
432    }
433    errors
434}
435
436impl ValidationClient<'_> {
437    /// Validate that all required databases, schemas, and external dependencies exist.
438    pub async fn validate_project(
439        &self,
440        planned_project: &graph::Project,
441        project_root: &Path,
442    ) -> Result<(), DatabaseValidationError> {
443        validate_project_impl(self.client, planned_project, project_root).await
444    }
445
446    /// Validate that sources and sinks don't share clusters with indexes or materialized views.
447    pub async fn validate_cluster_isolation(
448        &self,
449        planned_project: &graph::Project,
450    ) -> Result<(), DatabaseValidationError> {
451        validate_cluster_isolation_impl(self.client, planned_project).await
452    }
453
454    /// Validate that the user has sufficient privileges to deploy the project.
455    pub async fn validate_privileges(
456        &self,
457        planned_project: &graph::Project,
458    ) -> Result<(), DatabaseValidationError> {
459        validate_privileges_impl(self.client, planned_project).await
460    }
461
462    /// Validate that all sources referenced by CREATE TABLE FROM SOURCE statements exist.
463    pub async fn validate_sources_exist(
464        &self,
465        planned_project: &graph::Project,
466    ) -> Result<(), DatabaseValidationError> {
467        validate_sources_exist_impl(self.client, planned_project).await
468    }
469
470    /// Validate that all connections referenced by CREATE SINK statements exist.
471    pub async fn validate_sink_connections_exist(
472        &self,
473        planned_project: &graph::Project,
474    ) -> Result<(), DatabaseValidationError> {
475        validate_sink_connections_exist_impl(self.client, planned_project).await
476    }
477
478    /// Validate that the current role owns all production schemas that will be swapped.
479    pub async fn validate_schema_ownership(
480        &self,
481        schema_set: &BTreeSet<SchemaQualifier>,
482    ) -> Result<(), DatabaseValidationError> {
483        validate_schema_ownership_impl(self.client, schema_set).await
484    }
485
486    /// Validate that the current role owns all production clusters that will be swapped.
487    pub async fn validate_cluster_ownership(
488        &self,
489        cluster_set: &BTreeSet<String>,
490    ) -> Result<(), DatabaseValidationError> {
491        validate_cluster_ownership_impl(self.client, cluster_set).await
492    }
493
494    /// Validate that every `CREATE TABLE ... FROM SOURCE` in `tables_to_create`
495    /// names an upstream object its source can read.
496    ///
497    /// Refreshes each source's references before checking, so the check reads
498    /// what the upstream system exposes now rather than what it exposed when
499    /// the source was created.
500    pub async fn validate_source_references(
501        &self,
502        planned_project: &graph::Project,
503        tables_to_create: &BTreeSet<ObjectId>,
504    ) -> Result<(), DatabaseValidationError> {
505        validate_source_references_impl(self.client, planned_project, tables_to_create).await
506    }
507
508    /// Validate that all tables referenced by objects to be deployed exist in the database.
509    pub async fn validate_table_dependencies(
510        &self,
511        planned_project: &graph::Project,
512        objects_to_deploy: &BTreeSet<ObjectId>,
513    ) -> Result<(), DatabaseValidationError> {
514        validate_table_dependencies_impl(self.client, planned_project, objects_to_deploy).await
515    }
516}
517
518/// Internal implementation of validate_schema_ownership.
519pub(crate) async fn validate_schema_ownership_impl(
520    client: &Client,
521    schema_set: &BTreeSet<SchemaQualifier>,
522) -> Result<(), DatabaseValidationError> {
523    if schema_set.is_empty() {
524        return Ok(());
525    }
526
527    let fqn_to_schema: BTreeMap<String, &SchemaQualifier> = schema_set
528        .iter()
529        .map(|sq| (format!("{}.{}", sq.database, sq.schema), sq))
530        .collect();
531    let fqns: Vec<String> = fqn_to_schema.keys().cloned().collect();
532
533    let mut unowned_schemas = Vec::new();
534    let mut current_user = String::new();
535
536    for chunk in fqns.chunks(LOOKUP_BATCH_SIZE) {
537        let placeholders = sql_placeholders(chunk.len());
538        let query = format!(
539            r#"
540            SELECT d.name || '.' || s.name AS fqn, current_user() AS current_user
541            FROM mz_schemas s
542            JOIN mz_databases d ON s.database_id = d.id
543            JOIN mz_roles r ON s.owner_id = r.id
544            WHERE d.name || '.' || s.name IN ({placeholders})
545              AND r.name != current_user()
546            "#,
547        );
548
549        #[allow(clippy::as_conversions)]
550        let params: Vec<&(dyn ToSql + Sync)> =
551            chunk.iter().map(|fqn| fqn as &(dyn ToSql + Sync)).collect();
552
553        let rows = client
554            .query(&query, &params)
555            .await
556            .map_err(DatabaseValidationError::QueryError)?;
557
558        for row in rows {
559            let fqn: String = row.get("fqn");
560            if let Some(sq) = fqn_to_schema.get(&fqn) {
561                unowned_schemas.push((*sq).clone());
562            }
563            if current_user.is_empty() {
564                current_user = row.get("current_user");
565            }
566        }
567    }
568
569    if !unowned_schemas.is_empty() {
570        unowned_schemas.sort();
571        return Err(DatabaseValidationError::SchemaOwnershipMismatch {
572            unowned_schemas,
573            current_user,
574        });
575    }
576
577    Ok(())
578}
579
580/// Internal implementation of validate_cluster_ownership.
581pub(crate) async fn validate_cluster_ownership_impl(
582    client: &Client,
583    cluster_set: &BTreeSet<String>,
584) -> Result<(), DatabaseValidationError> {
585    if cluster_set.is_empty() {
586        return Ok(());
587    }
588
589    let cluster_names: Vec<String> = cluster_set.iter().cloned().collect();
590
591    let mut unowned_clusters = Vec::new();
592    let mut current_user = String::new();
593
594    for chunk in cluster_names.chunks(LOOKUP_BATCH_SIZE) {
595        let placeholders = sql_placeholders(chunk.len());
596        let query = format!(
597            r#"
598            SELECT c.name AS cluster_name, current_user() AS current_user
599            FROM mz_clusters c
600            JOIN mz_roles r ON c.owner_id = r.id
601            WHERE c.name IN ({placeholders})
602              AND r.name != current_user()
603            "#,
604        );
605
606        #[allow(clippy::as_conversions)]
607        let params: Vec<&(dyn ToSql + Sync)> = chunk
608            .iter()
609            .map(|name| name as &(dyn ToSql + Sync))
610            .collect();
611
612        let rows = client
613            .query(&query, &params)
614            .await
615            .map_err(DatabaseValidationError::QueryError)?;
616
617        for row in rows {
618            let cluster_name: String = row.get("cluster_name");
619            unowned_clusters.push(cluster_name);
620            if current_user.is_empty() {
621                current_user = row.get("current_user");
622            }
623        }
624    }
625
626    if !unowned_clusters.is_empty() {
627        unowned_clusters.sort();
628        return Err(DatabaseValidationError::ClusterOwnershipMismatch {
629            unowned_clusters,
630            current_user,
631        });
632    }
633
634    Ok(())
635}
636
637/// Internal implementation of validate_cluster_isolation.
638pub(crate) async fn validate_cluster_isolation_impl(
639    client: &Client,
640    planned_project: &graph::Project,
641) -> Result<(), DatabaseValidationError> {
642    // Get all clusters used by the project
643    let mut all_clusters: BTreeSet<String> = BTreeSet::new();
644    for cluster in &planned_project.cluster_dependencies {
645        all_clusters.insert(cluster.name.clone());
646    }
647
648    // Query sources from the database for these clusters
649    let sources_by_cluster = query_sources_by_cluster(client, &all_clusters).await?;
650
651    // Validate cluster isolation using the project's validation method
652    planned_project
653        .validate_cluster_isolation(&sources_by_cluster)
654        .map_err(|(cluster_name, compute_objects, storage_objects)| {
655            DatabaseValidationError::ClusterConflict {
656                cluster_name,
657                compute_objects,
658                storage_objects,
659            }
660        })
661}
662
663/// Internal implementation of validate_privileges.
664pub(crate) async fn validate_privileges_impl(
665    client: &Client,
666    planned_project: &graph::Project,
667) -> Result<(), DatabaseValidationError> {
668    // Check if user is a superuser
669    let row = client
670        .query_one("SELECT mz_is_superuser()", &[])
671        .await
672        .map_err(DatabaseValidationError::QueryError)?;
673    let is_superuser: bool = row.get(0);
674
675    if is_superuser {
676        return Ok(()); // Superuser has all privileges
677    }
678
679    // Collect all required databases from the project
680    let mut priv_required_databases = BTreeSet::new();
681    for db in &planned_project.databases {
682        priv_required_databases.insert(db.name.clone());
683    }
684
685    // Check USAGE privileges on databases using the provided query
686    let missing_usage = if !priv_required_databases.is_empty() {
687        let in_clause = sql_placeholders(priv_required_databases.len());
688
689        let query = format!(
690            r#"
691            SELECT name
692            FROM mz_internal.mz_show_my_database_privileges
693            WHERE name IN ({})
694            GROUP BY name
695            HAVING NOT BOOL_OR(privilege_type = 'USAGE')
696            "#,
697            in_clause
698        );
699
700        #[allow(clippy::as_conversions)]
701        let params: Vec<&(dyn ToSql + Sync)> = priv_required_databases
702            .iter()
703            .map(|s| s as &(dyn ToSql + Sync))
704            .collect();
705
706        let rows = client
707            .query(&query, &params)
708            .await
709            .map_err(DatabaseValidationError::QueryError)?;
710
711        rows.iter()
712            .map(|row| row.get::<_, String>("name"))
713            .collect::<Vec<_>>()
714    } else {
715        Vec::new()
716    };
717
718    // Check CREATECLUSTER privilege if project has cluster dependencies
719    let missing_createcluster = if !planned_project.cluster_dependencies.is_empty() {
720        let query = r#"
721            SELECT EXISTS (
722                SELECT * FROM mz_internal.mz_show_my_system_privileges
723                WHERE privilege_type = 'CREATECLUSTER'
724            )
725        "#;
726
727        let row = client
728            .query_one(query, &[])
729            .await
730            .map_err(DatabaseValidationError::QueryError)?;
731
732        let has_createcluster: bool = row.get(0);
733        !has_createcluster
734    } else {
735        false
736    };
737
738    // Return error if missing any privileges
739    if !missing_usage.is_empty() || missing_createcluster {
740        return Err(DatabaseValidationError::InsufficientPrivileges {
741            missing_database_usage: missing_usage,
742            missing_createcluster,
743        });
744    }
745
746    Ok(())
747}
748
749/// Internal implementation of validate_sources_exist.
750pub(crate) async fn validate_sources_exist_impl(
751    client: &Client,
752    planned_project: &graph::Project,
753) -> Result<(), DatabaseValidationError> {
754    let defined_sources: BTreeSet<ObjectId> = planned_project
755        .iter_objects()
756        .filter(|obj| matches!(obj.typed_object.stmt, Statement::CreateSource(_)))
757        .map(|obj| obj.id.clone())
758        .collect();
759
760    let mut referenced_sources = BTreeSet::new();
761    for obj in planned_project.iter_objects() {
762        if let Statement::CreateTableFromSource(ref stmt) = obj.typed_object.stmt {
763            let source_id = ObjectId::from_raw_item_name(
764                &stmt.source,
765                obj.id.expect_database(),
766                obj.id.schema(),
767            );
768            if !defined_sources.contains(&source_id) {
769                referenced_sources.insert(source_id);
770            }
771        }
772    }
773
774    let existing =
775        query_existing_object_ids(client, &referenced_sources, CatalogLookup::Sources).await?;
776    let missing_sources: Vec<ObjectId> =
777        referenced_sources.difference(&existing).cloned().collect();
778    if !missing_sources.is_empty() {
779        return Err(DatabaseValidationError::MissingSources(missing_sources));
780    }
781
782    Ok(())
783}
784
785/// Internal implementation of validate_sink_connections_exist.
786///
787/// Validates that all connections referenced by sinks exist in the database.
788/// Sinks reference connections (Kafka, Iceberg) that are not managed by mz-deploy.
789pub(crate) async fn validate_sink_connections_exist_impl(
790    client: &Client,
791    planned_project: &graph::Project,
792) -> Result<(), DatabaseValidationError> {
793    let mut referenced_connections = BTreeSet::new();
794    for obj in planned_project.iter_objects() {
795        if let Statement::CreateSink(ref stmt) = obj.typed_object.stmt {
796            let connection_ids = match &stmt.connection {
797                CreateSinkConnection::Kafka { connection, .. } => {
798                    vec![ObjectId::from_raw_item_name(
799                        connection,
800                        obj.id.expect_database(),
801                        obj.id.schema(),
802                    )]
803                }
804                CreateSinkConnection::Iceberg {
805                    catalog_connection,
806                    aws_connection,
807                    ..
808                } => {
809                    let mut ids = vec![ObjectId::from_raw_item_name(
810                        catalog_connection,
811                        obj.id.expect_database(),
812                        obj.id.schema(),
813                    )];
814                    if let Some(aws_connection) = aws_connection {
815                        ids.push(ObjectId::from_raw_item_name(
816                            aws_connection,
817                            obj.id.expect_database(),
818                            obj.id.schema(),
819                        ));
820                    }
821                    ids
822                }
823            };
824
825            for conn_id in connection_ids {
826                referenced_connections.insert(conn_id);
827            }
828        }
829    }
830
831    let existing =
832        query_existing_object_ids(client, &referenced_connections, CatalogLookup::Connections)
833            .await?;
834    let missing_connections: Vec<ObjectId> = referenced_connections
835        .difference(&existing)
836        .cloned()
837        .collect();
838    if !missing_connections.is_empty() {
839        return Err(DatabaseValidationError::MissingConnections(
840            missing_connections,
841        ));
842    }
843
844    Ok(())
845}
846
847/// Internal implementation of validate_table_dependencies.
848pub(crate) async fn validate_table_dependencies_impl(
849    client: &Client,
850    planned_project: &graph::Project,
851    objects_to_deploy: &BTreeSet<ObjectId>,
852) -> Result<(), DatabaseValidationError> {
853    let project_tables: BTreeSet<ObjectId> = planned_project.get_tables().collect();
854
855    let mut required_tables = BTreeSet::new();
856    for object_id in objects_to_deploy {
857        if let Some(obj) = planned_project.find_object(object_id) {
858            for dep_id in &obj.dependencies {
859                if project_tables.contains(dep_id) {
860                    required_tables.insert(dep_id.clone());
861                }
862            }
863        }
864    }
865
866    let existing_tables =
867        query_existing_object_ids(client, &required_tables, CatalogLookup::Tables).await?;
868    let missing_table_set: BTreeSet<ObjectId> = required_tables
869        .difference(&existing_tables)
870        .cloned()
871        .collect();
872
873    let mut objects_needing_tables = Vec::new();
874    for object_id in objects_to_deploy {
875        if let Some(obj) = planned_project.find_object(object_id) {
876            let mut missing_tables = Vec::new();
877            for dep_id in &obj.dependencies {
878                if project_tables.contains(dep_id) && missing_table_set.contains(dep_id) {
879                    missing_tables.push(dep_id.clone());
880                }
881            }
882
883            if !missing_tables.is_empty() {
884                objects_needing_tables.push((object_id.clone(), missing_tables));
885            }
886        }
887    }
888
889    if !objects_needing_tables.is_empty() {
890        return Err(DatabaseValidationError::MissingTableDependencies {
891            objects_needing_tables,
892        });
893    }
894
895    Ok(())
896}
897
898/// One row of `mz_internal.mz_source_references`: an upstream object a source
899/// can read.
900#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
901struct SourceReference {
902    namespace: Option<String>,
903    name: String,
904}
905
906impl fmt::Display for SourceReference {
907    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
908        match &self.namespace {
909            Some(namespace) => write!(f, "{}.{}", namespace, self.name),
910            None => write!(f, "{}", self.name),
911        }
912    }
913}
914
915/// Split a reference as written into its object name and, when the reference is
916/// qualified, the namespace immediately preceding it.
917///
918/// A leading database qualifier (only SQL Server references carry one) is
919/// dropped: `mz_source_references` records no database, so there is nothing to
920/// match it against.
921fn split_reference(reference: &UnresolvedItemName) -> Option<(&Ident, Option<&Ident>)> {
922    let mut parts = reference.0.iter().rev();
923    let name = parts.next()?;
924    Some((name, parts.next()))
925}
926
927/// Whether the recorded references can settle `reference` at all.
928///
929/// MySQL's system schemas are the blind spot. Both `CREATE SOURCE` and
930/// `ALTER SOURCE ... REFRESH REFERENCES` retrieve MySQL tables with system
931/// schemas excluded, so `mz_source_references` never lists a table in `mysql`,
932/// `sys`, `performance_schema`, or `information_schema`. Creating a table from
933/// such a reference does resolve it, so only the server can judge one, and
934/// reporting it missing here would block a deploy that works.
935///
936/// The namespace alone decides this, without consulting the source's connection
937/// type. A Postgres or SQL Server schema that happens to be named `mysql` or
938/// `sys` is skipped too, which costs nothing beyond leaving those references to
939/// the server.
940fn reference_is_verifiable(reference: &UnresolvedItemName) -> bool {
941    let Some((_, Some(namespace))) = split_reference(reference) else {
942        return true;
943    };
944    !mz_mysql_util::SYSTEM_SCHEMAS.contains(&namespace.as_str())
945}
946
947/// Whether `reference`, as written in a `CREATE TABLE ... FROM SOURCE`
948/// statement, names one of `available`.
949///
950/// Mirrors the server's resolution (`SourceReferenceResolver`), except that a
951/// bare object name matches in any namespace: the ambiguous case is left for
952/// the server to report.
953fn reference_is_available(reference: &UnresolvedItemName, available: &[SourceReference]) -> bool {
954    let Some((name, namespace)) = split_reference(reference) else {
955        return false;
956    };
957
958    available.iter().any(|candidate| {
959        candidate.name == name.as_str()
960            && match namespace {
961                Some(namespace) => candidate.namespace.as_deref() == Some(namespace.as_str()),
962                None => true,
963            }
964    })
965}
966
967/// Everything one source records about what it can read.
968#[derive(Debug)]
969struct SourceReferences {
970    /// The source's catalog ID, which names it in an error's suggested query.
971    id: String,
972    references: Vec<SourceReference>,
973}
974
975/// Exposed references spelled closely enough to `reference` to be the one the
976/// project meant, best first. Empty when nothing comes close.
977///
978/// A candidate whose object name is exactly right and whose namespace is not
979/// leads, no matter how unalike the two namespaces are. Naming the right object
980/// in the wrong schema is both a common slip and one edit distance scores as
981/// unrelated. The rest are ranked on the object name alone. Scoring the
982/// reference whole would let a shared namespace pad the distance budget without
983/// saying anything about whether the names match: `public.widgets` and
984/// `public.orders` sit 4 edits apart, inside the budget a name that long earns,
985/// and are nothing alike.
986///
987/// Suggestions carry the namespace even where the project wrote a bare
988/// reference. That stays a valid substitution and says where the object lives.
989fn suggest_references(
990    reference: &UnresolvedItemName,
991    available: &[SourceReference],
992) -> Vec<String> {
993    let Some((name, _)) = split_reference(reference) else {
994        return Vec::new();
995    };
996
997    let (exact, rest): (Vec<&SourceReference>, Vec<&SourceReference>) = available
998        .iter()
999        .partition(|candidate| candidate.name == name.as_str());
1000    let mut suggestions: Vec<String> = exact.iter().map(|c| c.to_string()).collect();
1001
1002    let mut names: Vec<&str> = rest.iter().map(|c| c.name.as_str()).collect();
1003    names.sort();
1004    names.dedup();
1005    for near in did_you_mean(name.as_str(), &names) {
1006        // One name can sit in several namespaces, and which one the project
1007        // meant is exactly what it got wrong, so offer each.
1008        suggestions.extend(
1009            rest.iter()
1010                .filter(|candidate| candidate.name == near)
1011                .map(|candidate| candidate.to_string()),
1012        );
1013    }
1014
1015    suggestions.truncate(MAX_DID_YOU_MEAN);
1016    suggestions
1017}
1018
1019/// Query the references recorded for each of `sources`, keyed by the source's
1020/// fully qualified name.
1021async fn query_source_references(
1022    client: &Client,
1023    sources: &BTreeSet<ObjectId>,
1024) -> Result<BTreeMap<ObjectId, SourceReferences>, DatabaseValidationError> {
1025    let mut by_source: BTreeMap<ObjectId, SourceReferences> = BTreeMap::new();
1026    if sources.is_empty() {
1027        return Ok(by_source);
1028    }
1029
1030    let fqn_to_source: BTreeMap<String, &ObjectId> = sources
1031        .iter()
1032        .map(|source| (source.to_string(), source))
1033        .collect();
1034    let fqns: Vec<String> = fqn_to_source.keys().cloned().collect();
1035
1036    for chunk in fqns.chunks(LOOKUP_BATCH_SIZE) {
1037        let placeholders = sql_placeholders(chunk.len());
1038        let query = format!(
1039            r#"
1040            SELECT d.name || '.' || sc.name || '.' || s.name AS source,
1041                   s.id AS source_id,
1042                   refs.namespace,
1043                   refs.name
1044            FROM mz_internal.mz_source_references refs
1045            JOIN mz_catalog.mz_sources s ON refs.source_id = s.id
1046            JOIN mz_catalog.mz_schemas sc ON s.schema_id = sc.id
1047            JOIN mz_catalog.mz_databases d ON sc.database_id = d.id
1048            WHERE d.name || '.' || sc.name || '.' || s.name IN ({placeholders})
1049            "#,
1050        );
1051
1052        #[allow(clippy::as_conversions)]
1053        let params: Vec<&(dyn ToSql + Sync)> =
1054            chunk.iter().map(|fqn| fqn as &(dyn ToSql + Sync)).collect();
1055
1056        let rows = client
1057            .query(&query, &params)
1058            .await
1059            .map_err(DatabaseValidationError::QueryError)?;
1060
1061        for row in rows {
1062            let fqn: String = row.get("source");
1063            let Some(source) = fqn_to_source.get(&fqn) else {
1064                continue;
1065            };
1066            by_source
1067                .entry((*source).clone())
1068                .or_insert_with(|| SourceReferences {
1069                    id: row.get("source_id"),
1070                    references: Vec::new(),
1071                })
1072                .references
1073                .push(SourceReference {
1074                    namespace: row.get("namespace"),
1075                    name: row.get("name"),
1076                });
1077        }
1078    }
1079
1080    for source in by_source.values_mut() {
1081        source.references.sort();
1082    }
1083
1084    Ok(by_source)
1085}
1086
1087/// Internal implementation of validate_source_references.
1088pub(crate) async fn validate_source_references_impl(
1089    client: &Client,
1090    planned_project: &graph::Project,
1091    tables_to_create: &BTreeSet<ObjectId>,
1092) -> Result<(), DatabaseValidationError> {
1093    let mut requested: BTreeMap<ObjectId, Vec<(ObjectId, UnresolvedItemName)>> = BTreeMap::new();
1094    for table_id in tables_to_create {
1095        let Some(obj) = planned_project.find_object(table_id) else {
1096            continue;
1097        };
1098        let Statement::CreateTableFromSource(ref stmt) = obj.typed_object.stmt else {
1099            continue;
1100        };
1101        // Without a REFERENCE clause the table reads the source's single
1102        // output, so there is no name to check.
1103        let Some(reference) = &stmt.external_reference else {
1104            continue;
1105        };
1106        if !reference_is_verifiable(reference) {
1107            continue;
1108        }
1109        let source_id = ObjectId::from_raw_item_name(
1110            &stmt.source,
1111            table_id.expect_database(),
1112            table_id.schema(),
1113        );
1114        requested
1115            .entry(source_id)
1116            .or_default()
1117            .push((table_id.clone(), reference.clone()));
1118    }
1119    if requested.is_empty() {
1120        return Ok(());
1121    }
1122
1123    // A source the project creates in this same run does not exist yet: apply
1124    // plans every phase before executing any of it. Nothing can be checked
1125    // against a source that isn't there, and its references will be recorded
1126    // when it is created.
1127    let sources: BTreeSet<ObjectId> = requested.keys().cloned().collect();
1128    let existing_sources =
1129        query_existing_object_ids(client, &sources, CatalogLookup::Sources).await?;
1130    requested.retain(|source, _| existing_sources.contains(source));
1131    if requested.is_empty() {
1132        return Ok(());
1133    }
1134
1135    // The recorded references are a snapshot from when the source was created,
1136    // while creating the table resolves its reference against the upstream
1137    // system as it is now. Refresh first so a miss here is a real miss.
1138    let mut unreadable: BTreeMap<ObjectId, String> = BTreeMap::new();
1139    for source in requested.keys() {
1140        let sql = format!(
1141            "ALTER SOURCE {} REFRESH REFERENCES",
1142            source.to_unresolved_item_name()
1143        );
1144        verbose!("{}", sql);
1145        if let Err(e) = client.execute(&sql, &[]).await {
1146            // The role may not own the source, or the upstream system may be
1147            // unreachable. The check falls back to the recorded references, a
1148            // snapshot from when the source was created, so it still runs but
1149            // may be judging stale data. Say so here as well as in a mismatch:
1150            // a stale snapshot that happens to match is otherwise
1151            // indistinguishable from a fresh one.
1152            info!("warning: could not refresh the references for {source}: {e}");
1153            unreadable.insert(source.clone(), e.to_string());
1154        }
1155    }
1156
1157    let available = query_source_references(client, &existing_sources).await?;
1158
1159    let mut mismatches = Vec::new();
1160    for (source, tables) in requested {
1161        let Some(recorded) = available.get(&source) else {
1162            // A source with no recorded references tells us nothing: an empty
1163            // record is not the same as an empty upstream system, and failing
1164            // here would reject tables that apply fine.
1165            continue;
1166        };
1167        let missing: Vec<MissingSourceReference> = tables
1168            .into_iter()
1169            .filter(|(_, reference)| !reference_is_available(reference, &recorded.references))
1170            .map(|(table, reference)| MissingSourceReference {
1171                table,
1172                suggestions: suggest_references(&reference, &recorded.references),
1173                reference: reference.to_string(),
1174            })
1175            .collect();
1176        if missing.is_empty() {
1177            continue;
1178        }
1179        mismatches.push(SourceReferenceMismatch {
1180            unreadable: unreadable.get(&source).cloned(),
1181            source_id: recorded.id.clone(),
1182            available_count: recorded.references.len(),
1183            source,
1184            tables: missing,
1185        });
1186    }
1187
1188    if !mismatches.is_empty() {
1189        return Err(DatabaseValidationError::MissingSourceReferences(mismatches));
1190    }
1191
1192    Ok(())
1193}
1194
1195#[cfg(test)]
1196mod tests {
1197    use super::*;
1198
1199    fn reference(parts: &[&str]) -> UnresolvedItemName {
1200        UnresolvedItemName(parts.iter().map(|p| Ident::new_unchecked(*p)).collect())
1201    }
1202
1203    fn available(namespace: Option<&str>, name: &str) -> SourceReference {
1204        SourceReference {
1205            namespace: namespace.map(str::to_string),
1206            name: name.to_string(),
1207        }
1208    }
1209
1210    #[mz_ore::test]
1211    fn test_reference_is_available() {
1212        let refs = vec![
1213            available(Some("public"), "users"),
1214            available(Some("sales"), "orders"),
1215        ];
1216
1217        assert!(reference_is_available(
1218            &reference(&["public", "users"]),
1219            &refs
1220        ));
1221        // A bare name resolves in whichever namespace holds it.
1222        assert!(reference_is_available(&reference(&["orders"]), &refs));
1223        // A leading database qualifier has nothing to match against.
1224        assert!(reference_is_available(
1225            &reference(&["upstream", "sales", "orders"]),
1226            &refs
1227        ));
1228
1229        assert!(!reference_is_available(
1230            &reference(&["sales", "users"]),
1231            &refs
1232        ));
1233        assert!(!reference_is_available(&reference(&["widgets"]), &refs));
1234        assert!(!reference_is_available(
1235            &reference(&["public", "users"]),
1236            &[]
1237        ));
1238    }
1239
1240    #[mz_ore::test]
1241    fn test_reference_is_verifiable() {
1242        assert!(reference_is_verifiable(&reference(&["public", "users"])));
1243        assert!(reference_is_verifiable(&reference(&["users"])));
1244
1245        // MySQL system schemas are never recorded, so nothing here can be
1246        // judged against the recorded set.
1247        assert!(!reference_is_verifiable(&reference(&["mysql", "users"])));
1248        assert!(!reference_is_verifiable(&reference(&["sys", "users"])));
1249        assert!(!reference_is_verifiable(&reference(&[
1250            "performance_schema",
1251            "users"
1252        ])));
1253        assert!(!reference_is_verifiable(&reference(&[
1254            "information_schema",
1255            "tables"
1256        ])));
1257        // The namespace is the part before the object name, whatever precedes it.
1258        assert!(!reference_is_verifiable(&reference(&[
1259            "upstream", "mysql", "users"
1260        ])));
1261    }
1262
1263    #[mz_ore::test]
1264    fn test_suggest_references_catches_a_typo() {
1265        let refs = vec![
1266            available(Some("public"), "widgets"),
1267            available(Some("public"), "orders"),
1268        ];
1269
1270        assert_eq!(
1271            suggest_references(&reference(&["public", "widgest"]), &refs),
1272            vec!["public.widgets".to_string()]
1273        );
1274        // A bare reference is answered with the namespace the object lives in.
1275        assert_eq!(
1276            suggest_references(&reference(&["widgest"]), &refs),
1277            vec!["public.widgets".to_string()]
1278        );
1279    }
1280
1281    #[mz_ore::test]
1282    fn test_suggest_references_leads_with_the_right_name_in_another_namespace() {
1283        let refs = vec![
1284            available(Some("public"), "widgets"),
1285            available(Some("staging"), "widgets"),
1286            available(Some("sales"), "widgetry"),
1287        ];
1288
1289        // Edit distance alone would rank sales.widgetry, in the very namespace
1290        // asked for and two characters off, ahead of the two exact name matches.
1291        assert_eq!(
1292            suggest_references(&reference(&["sales", "widgets"]), &refs),
1293            vec![
1294                "public.widgets".to_string(),
1295                "staging.widgets".to_string(),
1296                "sales.widgetry".to_string()
1297            ]
1298        );
1299    }
1300
1301    #[mz_ore::test]
1302    fn test_suggest_references_ignores_the_namespace_when_scoring() {
1303        // Scoring whole references would put public.orders within 4 edits of
1304        // public.widgets, inside the budget a name that long earns, purely
1305        // because they share a namespace.
1306        let refs = vec![
1307            available(Some("public"), "orders"),
1308            available(Some("public"), "users"),
1309            available(Some("public"), "products"),
1310        ];
1311
1312        assert!(suggest_references(&reference(&["public", "widgets"]), &refs).is_empty());
1313    }
1314
1315    #[mz_ore::test]
1316    fn test_suggest_references_stays_quiet_when_nothing_is_close() {
1317        let refs = vec![
1318            available(Some("public"), "users"),
1319            available(Some("public"), "orders"),
1320        ];
1321
1322        assert!(
1323            suggest_references(&reference(&["public", "shipping_manifests"]), &refs).is_empty()
1324        );
1325        assert!(suggest_references(&reference(&["public", "users"]), &[]).is_empty());
1326    }
1327
1328    #[mz_ore::test]
1329    fn test_suggest_references_is_capped() {
1330        let refs: Vec<SourceReference> = (0..10)
1331            .map(|i| available(Some(&format!("s{i}")), "widgets"))
1332            .collect();
1333
1334        assert_eq!(
1335            suggest_references(&reference(&["public", "widgets"]), &refs).len(),
1336            MAX_DID_YOU_MEAN
1337        );
1338    }
1339
1340    #[mz_ore::test]
1341    fn test_suggest_references_offers_every_namespace_holding_the_name() {
1342        // Which namespace holds the object is the part the project got wrong,
1343        // so a misspelling that resolves to one name in two schemas offers both.
1344        let refs = vec![
1345            available(Some("public"), "widgets"),
1346            available(Some("staging"), "widgets"),
1347        ];
1348
1349        assert_eq!(
1350            suggest_references(&reference(&["widgest"]), &refs),
1351            vec!["public.widgets".to_string(), "staging.widgets".to_string()]
1352        );
1353    }
1354
1355    #[mz_ore::test]
1356    fn test_reference_is_available_without_namespace() {
1357        // Kafka topics and other unnamespaced references record a null namespace.
1358        let refs = vec![available(None, "events")];
1359
1360        assert!(reference_is_available(&reference(&["events"]), &refs));
1361        assert!(!reference_is_available(
1362            &reference(&["public", "events"]),
1363            &refs
1364        ));
1365    }
1366}