1use super::cache::BuildArtifact;
16use crate::project::ast::Statement;
17use crate::project::ir::compiled::FullyQualifiedName;
18use crate::project::ir::graph::Project;
19use crate::project::ir::object_id::ObjectId;
20use crate::types::{ColumnType, ObjectKind, Types, TypesError};
21use crate::verbose;
22use sha2::{Digest, Sha256};
23use std::collections::{BTreeMap, BTreeSet};
24use std::path::Path;
25use std::sync::Arc;
26
27mod bootstrap;
28mod catalog;
29mod convert;
30mod error;
31mod executor;
32
33pub(crate) use error::{ObjectTypeCheckError, ObjectTypeCheckErrorKind, TypeCheckError};
34
35#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
40pub(crate) struct TypecheckStats {
41 pub ran: usize,
42 pub skipped: usize,
43 pub schema_stable: usize,
44 pub schema_changed: usize,
45}
46
47#[derive(Debug, Clone)]
50struct NodeValue {
51 columns: BTreeMap<String, ColumnType>,
52 schema_stable: bool,
53}
54
55pub(crate) fn run(
73 directory: &Path,
74 profile: &str,
75 profile_suffix: Option<&str>,
76 variables: &BTreeMap<String, String>,
77 project: &Project,
78 external_types: Types,
79) -> Result<(Types, TypecheckStats), TypeCheckError> {
80 let sorted = project.get_sorted_objects()?;
81 let typed_objects: BTreeMap<ObjectId, &crate::project::ir::compiled::DatabaseObject> = sorted
82 .iter()
83 .filter(|(_, db_obj)| {
84 matches!(
85 db_obj.stmt,
86 Statement::CreateView(_) | Statement::CreateMaterializedView(_)
87 )
88 })
89 .map(|(id, db_obj)| (id.clone(), *db_obj))
90 .collect();
91
92 let mut db = BuildArtifact::open(directory, profile, profile_suffix, variables)
95 .map_err(TypesError::from)?;
96
97 let cached_columns_by_key: BTreeMap<String, BTreeMap<String, ColumnType>> =
101 db.load_typecheck_columns().map_err(TypesError::from)?;
102
103 let current_ext_digests = compute_external_digests(&external_types);
107 let cached_ext_digests = db.load_external_type_digests().map_err(TypesError::from)?;
108 let changed_externals: BTreeSet<ObjectId> = current_ext_digests
109 .iter()
110 .filter(|(k, v)| cached_ext_digests.get(*k) != Some(*v))
111 .filter_map(|(k, _)| k.parse().ok())
112 .chain(
113 cached_ext_digests
114 .keys()
115 .filter(|k| !current_ext_digests.contains_key(*k))
116 .filter_map(|k| k.parse().ok()),
117 )
118 .collect();
119
120 let reverse_graph = project.build_reverse_dependency_graph();
121
122 let initial_dirty: BTreeSet<ObjectId> = typed_objects
123 .keys()
124 .filter(|id| {
125 if project.compile_dirty.contains(id) {
127 return true;
128 }
129 if !cached_columns_by_key.contains_key(&id.to_string()) {
132 return true;
133 }
134 let Some(deps) = project.dependency_graph.get(id) else {
138 return false;
139 };
140 let external_schema_changed = |d: &ObjectId| changed_externals.contains(d);
141 let non_view_dep_recompiled =
142 |d: &ObjectId| project.compile_dirty.contains(d) && !typed_objects.contains_key(d);
143 deps.iter()
144 .any(|d| external_schema_changed(d) || non_view_dep_recompiled(d))
145 })
146 .cloned()
147 .collect();
148
149 let mut pessimistic_dirty: BTreeSet<ObjectId> = BTreeSet::new();
152 let mut stack: Vec<ObjectId> = initial_dirty.iter().cloned().collect();
153 while let Some(id) = stack.pop() {
154 if !pessimistic_dirty.insert(id.clone()) {
155 continue;
156 }
157 if let Some(downs) = reverse_graph.get(&id) {
158 for d in downs {
159 if typed_objects.contains_key(d) {
160 stack.push(d.clone());
161 }
162 }
163 }
164 }
165
166 let mut dag_nodes: BTreeSet<ObjectId> = pessimistic_dirty.clone();
170 let mut bootstrap_set: BTreeSet<ObjectId> = BTreeSet::new();
171 for id in &pessimistic_dirty {
172 let Some(deps) = project.dependency_graph.get(id) else {
173 continue;
174 };
175 for d in deps {
176 if typed_objects.contains_key(d) {
177 dag_nodes.insert(d.clone());
178 } else {
179 bootstrap_set.insert(d.clone());
180 }
181 }
182 }
183
184 let (base_catalog, base_columns) =
185 bootstrap::bootstrap_catalog(project, &external_types, Some(&bootstrap_set))?;
186
187 let dag_node_ids: Vec<ObjectId> = typed_objects
191 .keys()
192 .filter(|id| dag_nodes.contains(id))
193 .cloned()
194 .collect();
195 let mut direct_deps: BTreeMap<ObjectId, Vec<ObjectId>> = BTreeMap::new();
196 let mut dependents: BTreeMap<ObjectId, Vec<ObjectId>> = BTreeMap::new();
197 for node_id in &dag_node_ids {
198 let node_deps = project
199 .dependency_graph
200 .get(node_id)
201 .into_iter()
202 .flatten()
203 .filter(|d| dag_nodes.contains(d))
204 .cloned()
205 .collect();
206 direct_deps.insert(node_id.clone(), node_deps);
207
208 let node_dependents = reverse_graph
209 .get(node_id)
210 .into_iter()
211 .flatten()
212 .filter(|d| dag_nodes.contains(d))
213 .cloned()
214 .collect();
215 dependents.insert(node_id.clone(), node_dependents);
216 }
217
218 let stats_counter = Arc::new(StatsCounter::default());
219
220 let typed_objects = Arc::new(typed_objects);
221 let cached_columns_by_key = Arc::new(cached_columns_by_key);
222 let outcomes = {
223 let typed_objects = Arc::clone(&typed_objects);
224 let base_catalog = Arc::clone(&base_catalog);
225 let initial_dirty = Arc::new(initial_dirty);
226 let cached_columns_by_key = Arc::clone(&cached_columns_by_key);
227 let stats_counter = Arc::clone(&stats_counter);
228 executor::run::<NodeValue, _>(
229 dag_node_ids.clone(),
230 direct_deps,
231 dependents,
232 move |node_id, dep_results| {
233 let db_obj = typed_objects
234 .get(node_id)
235 .expect("typed_object exists for every scheduled node");
236
237 let cached_columns = cached_columns_by_key.get(&node_id.to_string()).cloned();
238 let any_dep_changed = dep_results.values().any(|v| !v.schema_stable);
239 let must_typecheck =
240 initial_dirty.contains(node_id) || any_dep_changed || cached_columns.is_none();
241
242 if !must_typecheck {
243 let columns = cached_columns.expect("must_typecheck guards None");
244 return Ok(NodeValue {
245 columns,
246 schema_stable: true,
247 });
248 }
249
250 let value = typecheck_node(
251 node_id,
252 db_obj,
253 Arc::clone(&base_catalog),
254 dep_results,
255 cached_columns.as_ref(),
256 )?;
257 stats_counter.record(value.schema_stable);
258 Ok(value)
259 },
260 )
261 };
262
263 let mut errors: Vec<ObjectTypeCheckError> = Vec::new();
264 let mut upsert_rows: Vec<(String, String, BTreeMap<String, ColumnType>)> = Vec::new();
265 let mut merged_tables: BTreeMap<ObjectId, BTreeMap<String, ColumnType>> = BTreeMap::new();
266 let mut merged_kinds: BTreeMap<ObjectId, ObjectKind> = BTreeMap::new();
267
268 let project_kinds: BTreeMap<&ObjectId, ObjectKind> = project
269 .iter_objects()
270 .map(|obj| (&obj.id, obj.typed_object.stmt.kind()))
271 .collect();
272 for (id, columns) in base_columns.iter() {
273 merged_tables.insert(id.clone(), columns.clone());
274 if let Some(kind) = project_kinds.get(id) {
275 merged_kinds.insert(id.clone(), *kind);
276 }
277 }
278 for (id, columns) in &external_types.tables {
279 merged_tables.insert(id.clone(), columns.clone());
280 if let Some(kind) = external_types.kinds.get(id) {
281 merged_kinds.insert(id.clone(), *kind);
282 }
283 }
284
285 let mut unhealthy: BTreeSet<String> = BTreeSet::new();
286 for node_id in typed_objects.keys() {
287 let Some(outcome) = outcomes.get(node_id) else {
288 continue;
289 };
290 match outcome {
291 executor::NodeOutcome::Ok(value) => {
292 let db_obj = typed_objects
293 .get(node_id)
294 .expect("typed_object exists for outcome");
295 let kind = db_obj.stmt.kind();
296 merged_tables.insert(node_id.clone(), value.columns.clone());
297 merged_kinds.insert(node_id.clone(), kind);
298 if !value.schema_stable {
302 upsert_rows.push((
303 node_id.to_string(),
304 kind.as_str().to_string(),
305 value.columns.clone(),
306 ));
307 }
308 }
309 executor::NodeOutcome::Failed(err) => {
310 let mut err = err.clone();
313 if let Some(db_obj) = typed_objects.get(node_id) {
314 err.file_path = directory.join(&db_obj.path);
315 }
316 errors.push(err);
317 unhealthy.insert(node_id.to_string());
318 }
319 executor::NodeOutcome::Blocked(blocker) => {
320 verbose!(
321 "Skipping {}: blocked by upstream error in {}",
322 node_id,
323 blocker
324 );
325 unhealthy.insert(node_id.to_string());
326 }
327 }
328 }
329
330 db.upsert_typecheck_results(&upsert_rows)
334 .map_err(TypesError::from)?;
335 let keep: BTreeSet<String> = typed_objects
336 .keys()
337 .map(|id| id.to_string())
338 .filter(|key| !unhealthy.contains(key))
339 .collect();
340 db.prune_typecheck_results(&keep)
341 .map_err(TypesError::from)?;
342 db.replace_external_type_digests(¤t_ext_digests)
343 .map_err(TypesError::from)?;
344
345 if !errors.is_empty() {
346 return Err(TypeCheckError::Multiple(errors));
347 }
348
349 let stats = stats_counter.snapshot(typed_objects.len());
350
351 Ok((
352 Types {
353 tables: merged_tables,
354 kinds: merged_kinds,
355 comments: BTreeMap::new(),
356 },
357 stats,
358 ))
359}
360
361#[derive(Default)]
363struct StatsCounter {
364 schema_stable: std::sync::atomic::AtomicUsize,
365 schema_changed: std::sync::atomic::AtomicUsize,
366}
367
368impl StatsCounter {
369 fn record(&self, schema_stable: bool) {
370 let counter = if schema_stable {
371 &self.schema_stable
372 } else {
373 &self.schema_changed
374 };
375 counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
376 }
377
378 fn snapshot(&self, total_nodes: usize) -> TypecheckStats {
379 use std::sync::atomic::Ordering::Relaxed;
380 let schema_stable = self.schema_stable.load(Relaxed);
381 let schema_changed = self.schema_changed.load(Relaxed);
382 let ran = schema_stable + schema_changed;
383 TypecheckStats {
384 ran,
385 skipped: total_nodes.saturating_sub(ran),
386 schema_stable,
387 schema_changed,
388 }
389 }
390}
391
392fn typecheck_node(
393 node_id: &ObjectId,
394 db_obj: &crate::project::ir::compiled::DatabaseObject,
395 base_catalog: Arc<catalog::CatalogRuntime>,
396 dep_results: &BTreeMap<ObjectId, Arc<NodeValue>>,
397 cached_columns: Option<&BTreeMap<String, ColumnType>>,
398) -> Result<NodeValue, ObjectTypeCheckError> {
399 let mut runtime = catalog::TaskCatalog::new(base_catalog);
400 for (dep_id, dep_value) in dep_results {
401 runtime
402 .create_stub_table(dep_id, &dep_value.columns)
403 .map_err(|err| {
404 ObjectTypeCheckError::internal(
405 node_id.clone(),
406 db_obj.path.clone(),
407 format!("internal: failed to stub dependency: {err}"),
408 )
409 })?;
410 }
411 let fqn: FullyQualifiedName = node_id.clone().into();
412 let ast = convert::create_catalog_item_ast(&db_obj.stmt, &fqn).ok_or_else(|| {
413 ObjectTypeCheckError::internal(
414 node_id.clone(),
415 db_obj.path.clone(),
416 "internal: failed to build catalog AST".into(),
417 )
418 })?;
419 let desc = runtime.create_item_from_ast(node_id, ast)?;
420 let columns = convert::relation_desc_to_columns(&desc);
421 let schema_stable = cached_columns.is_some_and(|cached| cached == &columns);
422 Ok(NodeValue {
423 columns,
424 schema_stable,
425 })
426}
427
428fn digest_columns(cols: &BTreeMap<String, ColumnType>) -> String {
431 let mut hasher = Sha256::new();
432 for (name, t) in cols {
433 hasher.update(name.as_bytes());
434 hasher.update(b"\0");
435 hasher.update(t.r#type.to_string().as_bytes());
436 hasher.update(b"\0");
437 hasher.update([u8::from(t.nullable)]);
438 hasher.update(b"\0");
439 hasher.update(u64::try_from(t.position).unwrap_or(u64::MAX).to_le_bytes());
440 hasher.update(b"\0");
441 }
442 format!("{:x}", hasher.finalize())
443}
444
445fn compute_external_digests(external_types: &Types) -> BTreeMap<String, String> {
447 external_types
448 .tables
449 .iter()
450 .map(|(id, cols)| (id.to_string(), digest_columns(cols)))
451 .collect()
452}
453
454#[cfg(test)]
455mod run_tests {
456 use super::*;
457 use crate::project::compiler::compile_sync;
458 use crate::types::DataType;
459 use std::collections::BTreeMap;
460 use std::fs;
461 use tempfile::tempdir;
462
463 fn write_sql(root: &Path, rel: &str, sql: &str) {
464 let path = root.join(rel);
465 fs::create_dir_all(path.parent().unwrap()).unwrap();
466 fs::write(path, sql).unwrap();
467 }
468
469 #[cfg_attr(miri, ignore)] #[mz_ore::test]
471 fn run_typechecks_simple_view_and_persists_columns() {
472 let temp = tempdir().unwrap();
473 let root = temp.path();
474 write_sql(
476 root,
477 "models/materialize/storage/t1.sql",
478 "CREATE TABLE t1 (a int)",
479 );
480 write_sql(
481 root,
482 "models/materialize/public/v1.sql",
483 "CREATE VIEW v1 AS SELECT a FROM materialize.storage.t1",
484 );
485
486 let fs = crate::fs::FileSystem::new();
487 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
488 let (merged, _stats) = run(
489 root,
490 "default",
491 None,
492 &BTreeMap::new(),
493 &project,
494 Types::default(),
495 )
496 .unwrap();
497
498 assert!(
499 merged
500 .tables
501 .contains_key(&"materialize.public.v1".parse::<ObjectId>().unwrap())
502 );
503 assert!(
504 merged
505 .tables
506 .contains_key(&"materialize.storage.t1".parse::<ObjectId>().unwrap())
507 );
508 }
509
510 #[cfg_attr(miri, ignore)] #[mz_ore::test]
515 fn sum_of_integer_column_stubs_for_dependents() {
516 let temp = tempdir().unwrap();
517 let root = temp.path();
518 write_sql(
519 root,
520 "models/materialize/storage/t1.sql",
521 "CREATE TABLE t1 (u uint8, b bigint, g int)",
522 );
523 write_sql(
524 root,
525 "models/materialize/public/agg.sql",
526 "CREATE VIEW agg AS SELECT sum(u) AS su, sum(b) AS sb, g \
527 FROM materialize.storage.t1 GROUP BY g",
528 );
529 write_sql(
530 root,
531 "models/materialize/public/downstream.sql",
532 "CREATE VIEW downstream AS SELECT su, sb, g FROM materialize.public.agg",
533 );
534
535 let fs = crate::fs::FileSystem::new();
536 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
537 let (merged, _stats) = run(
538 root,
539 "default",
540 None,
541 &BTreeMap::new(),
542 &project,
543 Types::default(),
544 )
545 .unwrap();
546
547 let agg = &merged.tables[&"materialize.public.agg".parse::<ObjectId>().unwrap()];
548 assert_eq!(agg["su"].r#type.to_string(), "numeric(39,0)");
549 assert_eq!(agg["sb"].r#type.to_string(), "numeric(39,0)");
550 assert!(
551 merged
552 .tables
553 .contains_key(&"materialize.public.downstream".parse::<ObjectId>().unwrap())
554 );
555 }
556
557 #[cfg_attr(miri, ignore)] #[mz_ore::test]
561 fn record_column_stubs_for_dependents() {
562 let temp = tempdir().unwrap();
563 let root = temp.path();
564 write_sql(
565 root,
566 "models/materialize/storage/t1.sql",
567 "CREATE TABLE t1 (a int NOT NULL, b text)",
568 );
569 write_sql(
570 root,
571 "models/materialize/public/wrapped.sql",
572 "CREATE VIEW wrapped AS SELECT ROW(a, b) AS r FROM materialize.storage.t1",
573 );
574 write_sql(
575 root,
576 "models/materialize/public/downstream.sql",
577 "CREATE VIEW downstream AS SELECT (r).f1 AS a FROM materialize.public.wrapped",
578 );
579
580 let fs = crate::fs::FileSystem::new();
581 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
582 let (merged, _stats) = run(
583 root,
584 "default",
585 None,
586 &BTreeMap::new(),
587 &project,
588 Types::default(),
589 )
590 .unwrap();
591
592 let wrapped = &merged.tables[&"materialize.public.wrapped".parse::<ObjectId>().unwrap()];
593 assert_eq!(
594 wrapped["r"].r#type.to_string(),
595 "record(f1: int4,f2: text?)"
596 );
597 let downstream =
598 &merged.tables[&"materialize.public.downstream".parse::<ObjectId>().unwrap()];
599 assert_eq!(downstream["a"].r#type.to_string(), "int4");
600 assert!(
601 !downstream["a"].nullable,
602 "field nullability must survive the stub"
603 );
604 }
605
606 #[cfg_attr(miri, ignore)] #[mz_ore::test]
610 fn external_record_column_typechecks() {
611 use crate::types::{ObjectKind, RecordField};
612
613 let temp = tempdir().unwrap();
614 let root = temp.path();
615 write_sql(
616 root,
617 "models/materialize/public/v_ext.sql",
618 "CREATE VIEW v_ext AS SELECT (payload).a AS a, (payload).n AS n FROM ext.public.t",
619 );
620
621 let payload = DataType::Record(vec![
622 RecordField {
623 name: "a".into(),
624 r#type: DataType::named("int4"),
625 nullable: false,
626 },
627 RecordField {
628 name: "n".into(),
629 r#type: DataType::Record(vec![RecordField {
630 name: "x".into(),
631 r#type: DataType::named("text"),
632 nullable: true,
633 }]),
634 nullable: true,
635 },
636 ]);
637 let t: ObjectId = "ext.public.t".parse().unwrap();
638 let external = Types {
639 tables: BTreeMap::from([(
640 t.clone(),
641 BTreeMap::from([(
642 "payload".to_string(),
643 ColumnType {
644 r#type: payload,
645 nullable: false,
646 position: 0,
647 comment: None,
648 },
649 )]),
650 )]),
651 kinds: BTreeMap::from([(t, ObjectKind::Table)]),
652 comments: BTreeMap::new(),
653 };
654
655 let fs = crate::fs::FileSystem::new();
656 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
657 let (merged, _stats) = run(root, "default", None, &BTreeMap::new(), &project, external)
658 .expect("record-typed external dependency should typecheck");
659
660 let v_ext = &merged.tables[&"materialize.public.v_ext".parse::<ObjectId>().unwrap()];
661 assert_eq!(v_ext["a"].r#type.to_string(), "int4");
662 assert!(!v_ext["a"].nullable, "field nullability must survive");
663 assert_eq!(v_ext["n"].r#type.to_string(), "record(x: text?)");
664 assert!(v_ext["n"].nullable);
665 }
666
667 #[cfg_attr(miri, ignore)] #[mz_ore::test]
670 fn second_run_skips_all_nodes_when_nothing_changed() {
671 let temp = tempdir().unwrap();
672 let root = temp.path();
673 write_sql(
674 root,
675 "models/materialize/storage/t1.sql",
676 "CREATE TABLE t1 (a int)",
677 );
678 write_sql(
679 root,
680 "models/materialize/public/v1.sql",
681 "CREATE VIEW v1 AS SELECT a FROM materialize.storage.t1",
682 );
683 write_sql(
684 root,
685 "models/materialize/public/v2.sql",
686 "CREATE VIEW v2 AS SELECT a FROM materialize.public.v1",
687 );
688
689 let fs = crate::fs::FileSystem::new();
690 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
692 let (_, first) = run(
693 root,
694 "default",
695 None,
696 &BTreeMap::new(),
697 &project,
698 Types::default(),
699 )
700 .unwrap();
701 assert_eq!(first.ran, 2, "first run should typecheck v1 and v2");
702 assert_eq!(first.skipped, 0);
703
704 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
706 let (_, second) = run(
707 root,
708 "default",
709 None,
710 &BTreeMap::new(),
711 &project,
712 Types::default(),
713 )
714 .unwrap();
715 assert_eq!(second.ran, 0, "second run should skip everything");
716 assert_eq!(second.skipped, 2);
717 }
718
719 #[cfg_attr(miri, ignore)] #[mz_ore::test]
723 fn schema_stable_edit_does_not_dirty_dependents() {
724 let temp = tempdir().unwrap();
725 let root = temp.path();
726 write_sql(
727 root,
728 "models/materialize/storage/t1.sql",
729 "CREATE TABLE t1 (a int)",
730 );
731 write_sql(
732 root,
733 "models/materialize/public/v1.sql",
734 "CREATE VIEW v1 AS SELECT a FROM materialize.storage.t1",
735 );
736 write_sql(
737 root,
738 "models/materialize/public/v2.sql",
739 "CREATE VIEW v2 AS SELECT a FROM materialize.public.v1",
740 );
741
742 let fs = crate::fs::FileSystem::new();
743 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
744 let _ = run(
745 root,
746 "default",
747 None,
748 &BTreeMap::new(),
749 &project,
750 Types::default(),
751 )
752 .unwrap();
753
754 write_sql(
756 root,
757 "models/materialize/public/v1.sql",
758 "CREATE VIEW v1 AS SELECT a FROM (SELECT * FROM materialize.storage.t1)",
759 );
760 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
761 let (_, stats) = run(
762 root,
763 "default",
764 None,
765 &BTreeMap::new(),
766 &project,
767 Types::default(),
768 )
769 .unwrap();
770
771 assert_eq!(stats.ran, 1, "only v1 should re-typecheck");
772 assert_eq!(stats.schema_stable, 1, "v1 output unchanged");
773 assert_eq!(stats.schema_changed, 0);
774 assert_eq!(stats.skipped, 1, "v2 should skip on stable upstream");
775 }
776
777 #[cfg_attr(miri, ignore)] #[mz_ore::test]
781 fn external_type_change_dirties_only_consumers() {
782 use crate::types::ObjectKind;
783
784 let temp = tempdir().unwrap();
785 let root = temp.path();
786 write_sql(
790 root,
791 "models/materialize/public/v_ext_a.sql",
792 "CREATE VIEW v_ext_a AS SELECT a FROM ext.public.t_a",
793 );
794 write_sql(
795 root,
796 "models/materialize/public/v_ext_b.sql",
797 "CREATE VIEW v_ext_b AS SELECT a FROM ext.public.t_b",
798 );
799
800 let mk_types = |a_type: &str, b_type: &str| {
801 let mut tables: BTreeMap<ObjectId, BTreeMap<String, ColumnType>> = BTreeMap::new();
802 let mut kinds: BTreeMap<ObjectId, ObjectKind> = BTreeMap::new();
803 let t_a: ObjectId = "ext.public.t_a".parse().unwrap();
804 let t_b: ObjectId = "ext.public.t_b".parse().unwrap();
805 tables.insert(
806 t_a.clone(),
807 BTreeMap::from([(
808 "a".to_string(),
809 ColumnType {
810 r#type: DataType::named(a_type),
811 nullable: true,
812 position: 0,
813 comment: None,
814 },
815 )]),
816 );
817 tables.insert(
818 t_b.clone(),
819 BTreeMap::from([(
820 "a".to_string(),
821 ColumnType {
822 r#type: DataType::named(b_type),
823 nullable: true,
824 position: 0,
825 comment: None,
826 },
827 )]),
828 );
829 kinds.insert(t_a, ObjectKind::Table);
830 kinds.insert(t_b, ObjectKind::Table);
831 Types {
832 tables,
833 kinds,
834 comments: BTreeMap::new(),
835 }
836 };
837
838 let fs = crate::fs::FileSystem::new();
839 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
841 let _ = run(
842 root,
843 "default",
844 None,
845 &BTreeMap::new(),
846 &project,
847 mk_types("integer", "integer"),
848 )
849 .unwrap();
850
851 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
853 let (_, stats) = run(
854 root,
855 "default",
856 None,
857 &BTreeMap::new(),
858 &project,
859 mk_types("integer", "integer"),
860 )
861 .unwrap();
862 assert_eq!(stats.skipped, 2, "no external change → both skip");
863 assert_eq!(stats.ran, 0);
864
865 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
867 let (_, stats) = run(
868 root,
869 "default",
870 None,
871 &BTreeMap::new(),
872 &project,
873 mk_types("text", "integer"),
874 )
875 .unwrap();
876 assert_eq!(stats.ran, 1, "only v_ext_a should re-run");
877 assert_eq!(stats.skipped, 1, "v_ext_b should skip");
878 }
879
880 #[cfg_attr(miri, ignore)] #[mz_ore::test]
883 fn schema_change_dirties_dependents() {
884 let temp = tempdir().unwrap();
885 let root = temp.path();
886 write_sql(
887 root,
888 "models/materialize/storage/t1.sql",
889 "CREATE TABLE t1 (a int, b int)",
890 );
891 write_sql(
892 root,
893 "models/materialize/public/v1.sql",
894 "CREATE VIEW v1 AS SELECT a FROM materialize.storage.t1",
895 );
896 write_sql(
897 root,
898 "models/materialize/public/v2.sql",
899 "CREATE VIEW v2 AS SELECT * FROM materialize.public.v1",
900 );
901
902 let fs = crate::fs::FileSystem::new();
903 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
904 let _ = run(
905 root,
906 "default",
907 None,
908 &BTreeMap::new(),
909 &project,
910 Types::default(),
911 )
912 .unwrap();
913
914 write_sql(
916 root,
917 "models/materialize/public/v1.sql",
918 "CREATE VIEW v1 AS SELECT a, b FROM materialize.storage.t1",
919 );
920 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
921 let (_, stats) = run(
922 root,
923 "default",
924 None,
925 &BTreeMap::new(),
926 &project,
927 Types::default(),
928 )
929 .unwrap();
930
931 assert_eq!(stats.ran, 2, "v1 changed, v2 must re-run");
932 assert_eq!(stats.schema_changed, 2);
933 assert_eq!(stats.skipped, 0);
934 }
935
936 #[cfg_attr(miri, ignore)] #[mz_ore::test]
941 fn previous_typecheck_failure_re_runs_next_invocation() {
942 let temp = tempdir().unwrap();
943 let root = temp.path();
944 write_sql(
945 root,
946 "models/materialize/storage/t1.sql",
947 "CREATE TABLE t1 (a int)",
948 );
949 write_sql(
951 root,
952 "models/materialize/public/v1.sql",
953 "CREATE VIEW v1 AS SELECT no_such_column FROM materialize.storage.t1",
954 );
955
956 let fs = crate::fs::FileSystem::new();
957 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
958 let first = run(
959 root,
960 "default",
961 None,
962 &BTreeMap::new(),
963 &project,
964 Types::default(),
965 );
966 assert!(first.is_err(), "first run should fail typechecking v1");
967
968 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
971 let second = run(
972 root,
973 "default",
974 None,
975 &BTreeMap::new(),
976 &project,
977 Types::default(),
978 );
979 assert!(
980 second.is_err(),
981 "second run must also fail — typecheck cache must not mask an unfixed error"
982 );
983 }
984
985 #[cfg_attr(miri, ignore)] #[mz_ore::test]
989 fn typecheck_failure_after_successful_run_persists() {
990 let temp = tempdir().unwrap();
991 let root = temp.path();
992 write_sql(
993 root,
994 "models/materialize/storage/t1.sql",
995 "CREATE TABLE t1 (a int)",
996 );
997 write_sql(
998 root,
999 "models/materialize/public/v1.sql",
1000 "CREATE VIEW v1 AS SELECT a FROM materialize.storage.t1",
1001 );
1002
1003 let fs = crate::fs::FileSystem::new();
1004 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
1005 run(
1006 root,
1007 "default",
1008 None,
1009 &BTreeMap::new(),
1010 &project,
1011 Types::default(),
1012 )
1013 .expect("first run typechecks cleanly");
1014
1015 write_sql(
1016 root,
1017 "models/materialize/public/v1.sql",
1018 "CREATE VIEW v1 AS SELECT no_such_column FROM materialize.storage.t1",
1019 );
1020 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
1021 let second = run(
1022 root,
1023 "default",
1024 None,
1025 &BTreeMap::new(),
1026 &project,
1027 Types::default(),
1028 );
1029 assert!(second.is_err(), "edit should surface typecheck error");
1030
1031 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
1032 let third = run(
1033 root,
1034 "default",
1035 None,
1036 &BTreeMap::new(),
1037 &project,
1038 Types::default(),
1039 );
1040 assert!(
1041 third.is_err(),
1042 "stale cache row from before the edit must not let a broken view skip typecheck"
1043 );
1044 }
1045
1046 #[cfg_attr(miri, ignore)] #[mz_ore::test]
1051 fn table_edit_dirties_dependent_view() {
1052 let temp = tempdir().unwrap();
1053 let root = temp.path();
1054 write_sql(
1055 root,
1056 "models/materialize/storage/t1.sql",
1057 "CREATE TABLE t1 (a int)",
1058 );
1059 write_sql(
1060 root,
1061 "models/materialize/public/v1.sql",
1062 "CREATE VIEW v1 AS SELECT a FROM materialize.storage.t1",
1063 );
1064
1065 let fs = crate::fs::FileSystem::new();
1066 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
1067 let _ = run(
1068 root,
1069 "default",
1070 None,
1071 &BTreeMap::new(),
1072 &project,
1073 Types::default(),
1074 )
1075 .unwrap();
1076
1077 write_sql(
1079 root,
1080 "models/materialize/storage/t1.sql",
1081 "CREATE TABLE t1 (b int)",
1082 );
1083 let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
1084 let result = run(
1085 root,
1086 "default",
1087 None,
1088 &BTreeMap::new(),
1089 &project,
1090 Types::default(),
1091 );
1092 assert!(
1093 result.is_err(),
1094 "v1 references column `a` which no longer exists on t1; \
1095 dependent view must re-typecheck and surface the error"
1096 );
1097 }
1098}