1use crate::client::connection::{Client, ValidationClient};
38use crate::client::errors::DatabaseValidationError;
39use crate::client::sql_placeholders;
40use crate::project::SchemaQualifier;
41use crate::project::ast::Statement;
42use crate::project::ir::graph;
43use crate::project::ir::object_id::ObjectId;
44use mz_sql_parser::ast::CreateSinkConnection;
45use std::collections::{BTreeMap, BTreeSet};
46use std::path::Path;
47use std::path::PathBuf;
48use tokio_postgres::types::ToSql;
49
50const LOOKUP_BATCH_SIZE: usize = 1000;
51
52enum CatalogLookup {
53 Objects,
54 Sources,
55 Tables,
56 Connections,
57}
58
59impl CatalogLookup {
60 fn table_name(&self) -> &'static str {
61 match self {
62 CatalogLookup::Objects => "mz_objects",
63 CatalogLookup::Sources => "mz_sources",
64 CatalogLookup::Tables => "mz_tables",
65 CatalogLookup::Connections => "mz_connections",
66 }
67 }
68}
69
70pub(crate) async fn query_sources_by_cluster(
72 client: &Client,
73 cluster_names: &BTreeSet<String>,
74) -> Result<BTreeMap<String, Vec<String>>, DatabaseValidationError> {
75 if cluster_names.is_empty() {
76 return Ok(BTreeMap::new());
77 }
78
79 let in_clause = sql_placeholders(cluster_names.len());
80
81 let query = format!(
82 r#"
83 SELECT
84 c.name as cluster_name,
85 d.name || '.' || s.name || '.' || mo.name as fqn
86 FROM mz_catalog.mz_sources src
87 JOIN mz_catalog.mz_objects mo ON src.id = mo.id
88 JOIN mz_catalog.mz_schemas s ON mo.schema_id = s.id
89 JOIN mz_catalog.mz_databases d ON s.database_id = d.id
90 JOIN mz_catalog.mz_clusters c ON src.cluster_id = c.id
91 WHERE mo.id LIKE 'u%' AND c.name IN ({})
92 "#,
93 in_clause
94 );
95
96 #[allow(clippy::as_conversions)]
97 let params: Vec<&(dyn ToSql + Sync)> = cluster_names
98 .iter()
99 .map(|s| s as &(dyn ToSql + Sync))
100 .collect();
101
102 let rows = client
103 .query(&query, ¶ms)
104 .await
105 .map_err(DatabaseValidationError::QueryError)?;
106
107 let mut result: BTreeMap<String, Vec<String>> = BTreeMap::new();
108 for row in rows {
109 let cluster_name: String = row.get("cluster_name");
110 let fqn: String = row.get("fqn");
111 result
112 .entry(cluster_name)
113 .or_insert_with(Vec::new)
114 .push(fqn);
115 }
116
117 Ok(result)
118}
119
120async fn query_existing_names(
121 client: &Client,
122 table_name: &str,
123 column_name: &str,
124 names: &BTreeSet<String>,
125) -> Result<BTreeSet<String>, DatabaseValidationError> {
126 let mut existing = BTreeSet::new();
127 if names.is_empty() {
128 return Ok(existing);
129 }
130
131 let name_list: Vec<String> = names.iter().cloned().collect();
132 for chunk in name_list.chunks(LOOKUP_BATCH_SIZE) {
133 let placeholders = sql_placeholders(chunk.len());
134 let query = format!(
135 "SELECT {column} FROM {table} WHERE {column} IN ({placeholders})",
136 column = column_name,
137 table = table_name,
138 placeholders = placeholders
139 );
140
141 #[allow(clippy::as_conversions)]
142 let params: Vec<&(dyn ToSql + Sync)> = chunk
143 .iter()
144 .map(|name| name as &(dyn ToSql + Sync))
145 .collect();
146
147 let rows = client
148 .query(&query, ¶ms)
149 .await
150 .map_err(DatabaseValidationError::QueryError)?;
151 for row in rows {
152 let name: String = row.get(column_name);
153 existing.insert(name);
154 }
155 }
156
157 Ok(existing)
158}
159
160async fn query_existing_schema_pairs(
161 client: &Client,
162 schema_pairs: &BTreeSet<(String, String)>,
163) -> Result<BTreeSet<(String, String)>, DatabaseValidationError> {
164 let mut existing = BTreeSet::new();
165 if schema_pairs.is_empty() {
166 return Ok(existing);
167 }
168
169 let fqn_to_pair: BTreeMap<String, (String, String)> = schema_pairs
170 .iter()
171 .map(|(database, schema)| {
172 (
173 format!("{}.{}", database, schema),
174 (database.clone(), schema.clone()),
175 )
176 })
177 .collect();
178 let fqns: Vec<String> = fqn_to_pair.keys().cloned().collect();
179
180 for chunk in fqns.chunks(LOOKUP_BATCH_SIZE) {
181 let placeholders = sql_placeholders(chunk.len());
182 let query = format!(
183 r#"
184 SELECT d.name || '.' || s.name AS fqn
185 FROM mz_schemas s
186 JOIN mz_databases d ON s.database_id = d.id
187 WHERE d.name || '.' || s.name IN ({})
188 "#,
189 placeholders
190 );
191
192 #[allow(clippy::as_conversions)]
193 let params: Vec<&(dyn ToSql + Sync)> =
194 chunk.iter().map(|fqn| fqn as &(dyn ToSql + Sync)).collect();
195
196 let rows = client
197 .query(&query, ¶ms)
198 .await
199 .map_err(DatabaseValidationError::QueryError)?;
200 for row in rows {
201 let fqn: String = row.get("fqn");
202 if let Some(pair) = fqn_to_pair.get(&fqn) {
203 existing.insert(pair.clone());
204 }
205 }
206 }
207
208 Ok(existing)
209}
210
211async fn query_existing_object_ids(
212 client: &Client,
213 object_ids: &BTreeSet<ObjectId>,
214 lookup: CatalogLookup,
215) -> Result<BTreeSet<ObjectId>, DatabaseValidationError> {
216 let mut existing = BTreeSet::new();
217 if object_ids.is_empty() {
218 return Ok(existing);
219 }
220
221 let fqn_to_object: BTreeMap<String, ObjectId> = object_ids
222 .iter()
223 .map(|obj| (obj.to_string(), obj.clone()))
224 .collect();
225 let fqns: Vec<String> = fqn_to_object.keys().cloned().collect();
226 let table_name = lookup.table_name();
227
228 for chunk in fqns.chunks(LOOKUP_BATCH_SIZE) {
229 let placeholders = sql_placeholders(chunk.len());
230 let query = format!(
231 r#"
232 SELECT d.name || '.' || s.name || '.' || t.name AS fqn
233 FROM {table_name} t
234 JOIN mz_schemas s ON t.schema_id = s.id
235 JOIN mz_databases d ON s.database_id = d.id
236 WHERE d.name || '.' || s.name || '.' || t.name IN ({placeholders})
237 "#,
238 table_name = table_name,
239 placeholders = placeholders
240 );
241
242 #[allow(clippy::as_conversions)]
243 let params: Vec<&(dyn ToSql + Sync)> =
244 chunk.iter().map(|fqn| fqn as &(dyn ToSql + Sync)).collect();
245
246 let rows = client
247 .query(&query, ¶ms)
248 .await
249 .map_err(DatabaseValidationError::QueryError)?;
250 for row in rows {
251 let fqn: String = row.get("fqn");
252 if let Some(obj) = fqn_to_object.get(&fqn) {
253 existing.insert(obj.clone());
254 }
255 }
256 }
257
258 Ok(existing)
259}
260
261pub(crate) async fn validate_project_impl(
263 client: &Client,
264 planned_project: &graph::Project,
265 project_root: &Path,
266) -> Result<(), DatabaseValidationError> {
267 let (external_databases, external_schemas) = collect_external_dependencies(planned_project);
268 let missing_databases = find_missing_databases(client, &external_databases).await?;
269 let missing_schemas = find_missing_schemas(client, &external_schemas).await?;
270 let missing_clusters = find_missing_clusters(client, planned_project).await?;
271 let object_paths = build_object_paths(planned_project, project_root);
272 let missing_external_deps = find_missing_external_dependencies(client, planned_project).await?;
273 let compilation_errors =
274 build_compilation_errors(planned_project, &object_paths, &missing_external_deps);
275
276 if !missing_databases.is_empty()
277 || !missing_schemas.is_empty()
278 || !missing_clusters.is_empty()
279 || !compilation_errors.is_empty()
280 {
281 Err(DatabaseValidationError::Multiple {
282 databases: missing_databases,
283 schemas: missing_schemas,
284 clusters: missing_clusters,
285 compilation_errors,
286 })
287 } else {
288 Ok(())
289 }
290}
291
292fn collect_external_dependencies(
296 planned_project: &graph::Project,
297) -> (BTreeSet<String>, BTreeSet<(String, String)>) {
298 let project_databases: BTreeSet<_> = planned_project
299 .databases
300 .iter()
301 .map(|db| db.name.clone())
302 .collect();
303
304 let mut external_databases = BTreeSet::new();
305 let mut external_schemas = BTreeSet::new();
306 for ext_dep in &planned_project.external_dependencies {
307 let Some(db) = ext_dep.database() else {
309 continue;
310 };
311 if !project_databases.contains(db) {
312 external_databases.insert(db.to_string());
313 }
314 external_schemas.insert((db.to_string(), ext_dep.schema().to_string()));
315 }
316 (external_databases, external_schemas)
317}
318
319async fn find_missing_databases(
321 client: &Client,
322 external_databases: &BTreeSet<String>,
323) -> Result<Vec<String>, DatabaseValidationError> {
324 let existing = query_existing_names(client, "mz_databases", "name", external_databases).await?;
325 Ok(external_databases.difference(&existing).cloned().collect())
326}
327
328async fn find_missing_schemas(
330 client: &Client,
331 external_schemas: &BTreeSet<(String, String)>,
332) -> Result<Vec<SchemaQualifier>, DatabaseValidationError> {
333 let existing = query_existing_schema_pairs(client, external_schemas).await?;
334 Ok(external_schemas
335 .difference(&existing)
336 .map(|(db, schema)| SchemaQualifier::new(db.clone(), schema.clone()))
337 .collect())
338}
339
340async fn find_missing_clusters(
342 client: &Client,
343 planned_project: &graph::Project,
344) -> Result<Vec<String>, DatabaseValidationError> {
345 let required: BTreeSet<String> = planned_project
346 .cluster_dependencies
347 .iter()
348 .map(|cluster| cluster.name.clone())
349 .collect();
350 let existing = query_existing_names(client, "mz_clusters", "name", &required).await?;
351 Ok(required.difference(&existing).cloned().collect())
352}
353
354fn build_object_paths(
358 planned_project: &graph::Project,
359 project_root: &Path,
360) -> BTreeMap<ObjectId, PathBuf> {
361 let mut object_paths = BTreeMap::new();
362 for db in &planned_project.databases {
363 for schema in &db.schemas {
364 for obj in &schema.objects {
365 let file_path = project_root
366 .join("models")
367 .join(obj.id.expect_database())
368 .join(obj.id.schema())
369 .join(format!("{}.sql", obj.id.object()));
370 object_paths.insert(obj.id.clone(), file_path);
371 }
372 }
373 }
374 object_paths
375}
376
377async fn find_missing_external_dependencies(
379 client: &Client,
380 planned_project: &graph::Project,
381) -> Result<BTreeSet<ObjectId>, DatabaseValidationError> {
382 let external_deps: BTreeSet<ObjectId> = planned_project
386 .external_dependencies
387 .iter()
388 .filter(|dep| dep.database().is_some())
389 .cloned()
390 .collect();
391 let existing =
392 query_existing_object_ids(client, &external_deps, CatalogLookup::Objects).await?;
393 Ok(external_deps.difference(&existing).cloned().collect())
394}
395
396fn build_compilation_errors(
400 planned_project: &graph::Project,
401 object_paths: &BTreeMap<ObjectId, PathBuf>,
402 missing_external_deps: &BTreeSet<ObjectId>,
403) -> Vec<DatabaseValidationError> {
404 let mut errors = Vec::new();
405 for db in &planned_project.databases {
406 for schema in &db.schemas {
407 for obj in &schema.objects {
408 let missing_for_object: Vec<_> = obj
409 .dependencies
410 .iter()
411 .filter(|dep| missing_external_deps.contains(*dep))
412 .cloned()
413 .collect();
414 if missing_for_object.is_empty() {
415 continue;
416 }
417 if let Some(file_path) = object_paths.get(&obj.id) {
418 errors.push(DatabaseValidationError::CompilationFailed {
419 file_path: file_path.clone(),
420 object_name: obj.id.clone(),
421 missing_dependencies: missing_for_object,
422 });
423 }
424 }
425 }
426 }
427 errors
428}
429
430impl ValidationClient<'_> {
431 pub async fn validate_project(
433 &self,
434 planned_project: &graph::Project,
435 project_root: &Path,
436 ) -> Result<(), DatabaseValidationError> {
437 validate_project_impl(self.client, planned_project, project_root).await
438 }
439
440 pub async fn validate_cluster_isolation(
442 &self,
443 planned_project: &graph::Project,
444 ) -> Result<(), DatabaseValidationError> {
445 validate_cluster_isolation_impl(self.client, planned_project).await
446 }
447
448 pub async fn validate_privileges(
450 &self,
451 planned_project: &graph::Project,
452 ) -> Result<(), DatabaseValidationError> {
453 validate_privileges_impl(self.client, planned_project).await
454 }
455
456 pub async fn validate_sources_exist(
458 &self,
459 planned_project: &graph::Project,
460 ) -> Result<(), DatabaseValidationError> {
461 validate_sources_exist_impl(self.client, planned_project).await
462 }
463
464 pub async fn validate_sink_connections_exist(
466 &self,
467 planned_project: &graph::Project,
468 ) -> Result<(), DatabaseValidationError> {
469 validate_sink_connections_exist_impl(self.client, planned_project).await
470 }
471
472 pub async fn validate_schema_ownership(
474 &self,
475 schema_set: &BTreeSet<SchemaQualifier>,
476 ) -> Result<(), DatabaseValidationError> {
477 validate_schema_ownership_impl(self.client, schema_set).await
478 }
479
480 pub async fn validate_cluster_ownership(
482 &self,
483 cluster_set: &BTreeSet<String>,
484 ) -> Result<(), DatabaseValidationError> {
485 validate_cluster_ownership_impl(self.client, cluster_set).await
486 }
487
488 pub async fn validate_table_dependencies(
490 &self,
491 planned_project: &graph::Project,
492 objects_to_deploy: &BTreeSet<ObjectId>,
493 ) -> Result<(), DatabaseValidationError> {
494 validate_table_dependencies_impl(self.client, planned_project, objects_to_deploy).await
495 }
496}
497
498pub(crate) async fn validate_schema_ownership_impl(
500 client: &Client,
501 schema_set: &BTreeSet<SchemaQualifier>,
502) -> Result<(), DatabaseValidationError> {
503 if schema_set.is_empty() {
504 return Ok(());
505 }
506
507 let fqn_to_schema: BTreeMap<String, &SchemaQualifier> = schema_set
508 .iter()
509 .map(|sq| (format!("{}.{}", sq.database, sq.schema), sq))
510 .collect();
511 let fqns: Vec<String> = fqn_to_schema.keys().cloned().collect();
512
513 let mut unowned_schemas = Vec::new();
514 let mut current_user = String::new();
515
516 for chunk in fqns.chunks(LOOKUP_BATCH_SIZE) {
517 let placeholders = sql_placeholders(chunk.len());
518 let query = format!(
519 r#"
520 SELECT d.name || '.' || s.name AS fqn, current_user() AS current_user
521 FROM mz_schemas s
522 JOIN mz_databases d ON s.database_id = d.id
523 JOIN mz_roles r ON s.owner_id = r.id
524 WHERE d.name || '.' || s.name IN ({placeholders})
525 AND r.name != current_user()
526 "#,
527 );
528
529 #[allow(clippy::as_conversions)]
530 let params: Vec<&(dyn ToSql + Sync)> =
531 chunk.iter().map(|fqn| fqn as &(dyn ToSql + Sync)).collect();
532
533 let rows = client
534 .query(&query, ¶ms)
535 .await
536 .map_err(DatabaseValidationError::QueryError)?;
537
538 for row in rows {
539 let fqn: String = row.get("fqn");
540 if let Some(sq) = fqn_to_schema.get(&fqn) {
541 unowned_schemas.push((*sq).clone());
542 }
543 if current_user.is_empty() {
544 current_user = row.get("current_user");
545 }
546 }
547 }
548
549 if !unowned_schemas.is_empty() {
550 unowned_schemas.sort();
551 return Err(DatabaseValidationError::SchemaOwnershipMismatch {
552 unowned_schemas,
553 current_user,
554 });
555 }
556
557 Ok(())
558}
559
560pub(crate) async fn validate_cluster_ownership_impl(
562 client: &Client,
563 cluster_set: &BTreeSet<String>,
564) -> Result<(), DatabaseValidationError> {
565 if cluster_set.is_empty() {
566 return Ok(());
567 }
568
569 let cluster_names: Vec<String> = cluster_set.iter().cloned().collect();
570
571 let mut unowned_clusters = Vec::new();
572 let mut current_user = String::new();
573
574 for chunk in cluster_names.chunks(LOOKUP_BATCH_SIZE) {
575 let placeholders = sql_placeholders(chunk.len());
576 let query = format!(
577 r#"
578 SELECT c.name AS cluster_name, current_user() AS current_user
579 FROM mz_clusters c
580 JOIN mz_roles r ON c.owner_id = r.id
581 WHERE c.name IN ({placeholders})
582 AND r.name != current_user()
583 "#,
584 );
585
586 #[allow(clippy::as_conversions)]
587 let params: Vec<&(dyn ToSql + Sync)> = chunk
588 .iter()
589 .map(|name| name as &(dyn ToSql + Sync))
590 .collect();
591
592 let rows = client
593 .query(&query, ¶ms)
594 .await
595 .map_err(DatabaseValidationError::QueryError)?;
596
597 for row in rows {
598 let cluster_name: String = row.get("cluster_name");
599 unowned_clusters.push(cluster_name);
600 if current_user.is_empty() {
601 current_user = row.get("current_user");
602 }
603 }
604 }
605
606 if !unowned_clusters.is_empty() {
607 unowned_clusters.sort();
608 return Err(DatabaseValidationError::ClusterOwnershipMismatch {
609 unowned_clusters,
610 current_user,
611 });
612 }
613
614 Ok(())
615}
616
617pub(crate) async fn validate_cluster_isolation_impl(
619 client: &Client,
620 planned_project: &graph::Project,
621) -> Result<(), DatabaseValidationError> {
622 let mut all_clusters: BTreeSet<String> = BTreeSet::new();
624 for cluster in &planned_project.cluster_dependencies {
625 all_clusters.insert(cluster.name.clone());
626 }
627
628 let sources_by_cluster = query_sources_by_cluster(client, &all_clusters).await?;
630
631 planned_project
633 .validate_cluster_isolation(&sources_by_cluster)
634 .map_err(|(cluster_name, compute_objects, storage_objects)| {
635 DatabaseValidationError::ClusterConflict {
636 cluster_name,
637 compute_objects,
638 storage_objects,
639 }
640 })
641}
642
643pub(crate) async fn validate_privileges_impl(
645 client: &Client,
646 planned_project: &graph::Project,
647) -> Result<(), DatabaseValidationError> {
648 let row = client
650 .query_one("SELECT mz_is_superuser()", &[])
651 .await
652 .map_err(DatabaseValidationError::QueryError)?;
653 let is_superuser: bool = row.get(0);
654
655 if is_superuser {
656 return Ok(()); }
658
659 let mut priv_required_databases = BTreeSet::new();
661 for db in &planned_project.databases {
662 priv_required_databases.insert(db.name.clone());
663 }
664
665 let missing_usage = if !priv_required_databases.is_empty() {
667 let in_clause = sql_placeholders(priv_required_databases.len());
668
669 let query = format!(
670 r#"
671 SELECT name
672 FROM mz_internal.mz_show_my_database_privileges
673 WHERE name IN ({})
674 GROUP BY name
675 HAVING NOT BOOL_OR(privilege_type = 'USAGE')
676 "#,
677 in_clause
678 );
679
680 #[allow(clippy::as_conversions)]
681 let params: Vec<&(dyn ToSql + Sync)> = priv_required_databases
682 .iter()
683 .map(|s| s as &(dyn ToSql + Sync))
684 .collect();
685
686 let rows = client
687 .query(&query, ¶ms)
688 .await
689 .map_err(DatabaseValidationError::QueryError)?;
690
691 rows.iter()
692 .map(|row| row.get::<_, String>("name"))
693 .collect::<Vec<_>>()
694 } else {
695 Vec::new()
696 };
697
698 let missing_createcluster = if !planned_project.cluster_dependencies.is_empty() {
700 let query = r#"
701 SELECT EXISTS (
702 SELECT * FROM mz_internal.mz_show_my_system_privileges
703 WHERE privilege_type = 'CREATECLUSTER'
704 )
705 "#;
706
707 let row = client
708 .query_one(query, &[])
709 .await
710 .map_err(DatabaseValidationError::QueryError)?;
711
712 let has_createcluster: bool = row.get(0);
713 !has_createcluster
714 } else {
715 false
716 };
717
718 if !missing_usage.is_empty() || missing_createcluster {
720 return Err(DatabaseValidationError::InsufficientPrivileges {
721 missing_database_usage: missing_usage,
722 missing_createcluster,
723 });
724 }
725
726 Ok(())
727}
728
729pub(crate) async fn validate_sources_exist_impl(
731 client: &Client,
732 planned_project: &graph::Project,
733) -> Result<(), DatabaseValidationError> {
734 let defined_sources: BTreeSet<ObjectId> = planned_project
735 .iter_objects()
736 .filter(|obj| matches!(obj.typed_object.stmt, Statement::CreateSource(_)))
737 .map(|obj| obj.id.clone())
738 .collect();
739
740 let mut referenced_sources = BTreeSet::new();
741 for obj in planned_project.iter_objects() {
742 if let Statement::CreateTableFromSource(ref stmt) = obj.typed_object.stmt {
743 let source_id = ObjectId::from_raw_item_name(
744 &stmt.source,
745 obj.id.expect_database(),
746 obj.id.schema(),
747 );
748 if !defined_sources.contains(&source_id) {
749 referenced_sources.insert(source_id);
750 }
751 }
752 }
753
754 let existing =
755 query_existing_object_ids(client, &referenced_sources, CatalogLookup::Sources).await?;
756 let missing_sources: Vec<ObjectId> =
757 referenced_sources.difference(&existing).cloned().collect();
758 if !missing_sources.is_empty() {
759 return Err(DatabaseValidationError::MissingSources(missing_sources));
760 }
761
762 Ok(())
763}
764
765pub(crate) async fn validate_sink_connections_exist_impl(
770 client: &Client,
771 planned_project: &graph::Project,
772) -> Result<(), DatabaseValidationError> {
773 let mut referenced_connections = BTreeSet::new();
774 for obj in planned_project.iter_objects() {
775 if let Statement::CreateSink(ref stmt) = obj.typed_object.stmt {
776 let connection_ids = match &stmt.connection {
777 CreateSinkConnection::Kafka { connection, .. } => {
778 vec![ObjectId::from_raw_item_name(
779 connection,
780 obj.id.expect_database(),
781 obj.id.schema(),
782 )]
783 }
784 CreateSinkConnection::Iceberg {
785 catalog_connection,
786 aws_connection,
787 ..
788 } => {
789 let mut ids = vec![ObjectId::from_raw_item_name(
790 catalog_connection,
791 obj.id.expect_database(),
792 obj.id.schema(),
793 )];
794 if let Some(aws_connection) = aws_connection {
795 ids.push(ObjectId::from_raw_item_name(
796 aws_connection,
797 obj.id.expect_database(),
798 obj.id.schema(),
799 ));
800 }
801 ids
802 }
803 };
804
805 for conn_id in connection_ids {
806 referenced_connections.insert(conn_id);
807 }
808 }
809 }
810
811 let existing =
812 query_existing_object_ids(client, &referenced_connections, CatalogLookup::Connections)
813 .await?;
814 let missing_connections: Vec<ObjectId> = referenced_connections
815 .difference(&existing)
816 .cloned()
817 .collect();
818 if !missing_connections.is_empty() {
819 return Err(DatabaseValidationError::MissingConnections(
820 missing_connections,
821 ));
822 }
823
824 Ok(())
825}
826
827pub(crate) async fn validate_table_dependencies_impl(
829 client: &Client,
830 planned_project: &graph::Project,
831 objects_to_deploy: &BTreeSet<ObjectId>,
832) -> Result<(), DatabaseValidationError> {
833 let project_tables: BTreeSet<ObjectId> = planned_project.get_tables().collect();
834
835 let mut required_tables = BTreeSet::new();
836 for object_id in objects_to_deploy {
837 if let Some(obj) = planned_project.find_object(object_id) {
838 for dep_id in &obj.dependencies {
839 if project_tables.contains(dep_id) {
840 required_tables.insert(dep_id.clone());
841 }
842 }
843 }
844 }
845
846 let existing_tables =
847 query_existing_object_ids(client, &required_tables, CatalogLookup::Tables).await?;
848 let missing_table_set: BTreeSet<ObjectId> = required_tables
849 .difference(&existing_tables)
850 .cloned()
851 .collect();
852
853 let mut objects_needing_tables = Vec::new();
854 for object_id in objects_to_deploy {
855 if let Some(obj) = planned_project.find_object(object_id) {
856 let mut missing_tables = Vec::new();
857 for dep_id in &obj.dependencies {
858 if project_tables.contains(dep_id) && missing_table_set.contains(dep_id) {
859 missing_tables.push(dep_id.clone());
860 }
861 }
862
863 if !missing_tables.is_empty() {
864 objects_needing_tables.push((object_id.clone(), missing_tables));
865 }
866 }
867 }
868
869 if !objects_needing_tables.is_empty() {
870 return Err(DatabaseValidationError::MissingTableDependencies {
871 objects_needing_tables,
872 });
873 }
874
875 Ok(())
876}