1use 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
76pub(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, ¶ms)
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, ¶ms)
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, ¶ms)
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, ¶ms)
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
267pub(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
298fn 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 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
325async 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
334async 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
346async 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
360fn 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
383async fn find_missing_external_dependencies(
385 client: &Client,
386 planned_project: &graph::Project,
387) -> Result<BTreeSet<ObjectId>, DatabaseValidationError> {
388 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
402fn 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 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 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 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 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 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 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 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 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 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
518pub(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, ¶ms)
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
580pub(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, ¶ms)
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
637pub(crate) async fn validate_cluster_isolation_impl(
639 client: &Client,
640 planned_project: &graph::Project,
641) -> Result<(), DatabaseValidationError> {
642 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 let sources_by_cluster = query_sources_by_cluster(client, &all_clusters).await?;
650
651 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
663pub(crate) async fn validate_privileges_impl(
665 client: &Client,
666 planned_project: &graph::Project,
667) -> Result<(), DatabaseValidationError> {
668 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(()); }
678
679 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 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, ¶ms)
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 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 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
749pub(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
785pub(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
847pub(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#[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
915fn 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
927fn 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
947fn 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#[derive(Debug)]
969struct SourceReferences {
970 id: String,
972 references: Vec<SourceReference>,
973}
974
975fn 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 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
1019async 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, ¶ms)
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
1087pub(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 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 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 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 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 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 assert!(reference_is_available(&reference(&["orders"]), &refs));
1223 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 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 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 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 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 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 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 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}