Skip to main content

mz_deploy/project/compiler/
typecheck.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//! Runtime typechecking integrated with the project compiler.
11//!
12//! Validation runs against an `mz-deploy` in-memory catalog using `mz-sql`
13//! directly (see [`catalog`]). See [`run`] for the algorithm.
14
15use 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/// Counts of incremental typecheck behavior during a single `run` call.
36///
37/// `ran + skipped` partitions all typecheck-eligible nodes;
38/// `schema_stable + schema_changed` partitions `ran`.
39#[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/// `schema_stable` is true when `columns` matches the cached result; dependents
48/// only re-typecheck when at least one dep is not schema-stable.
49#[derive(Debug, Clone)]
50struct NodeValue {
51    columns: BTreeMap<String, ColumnType>,
52    schema_stable: bool,
53}
54
55/// Full-typecheck entrypoint with incremental reuse.
56///
57/// Runs three phases:
58///
59/// 1. Build the base catalog (serial): seeds builtins, namespaces, external
60///    types, and all non-typechecked project objects.
61/// 2. Run the DAG executor (parallel): each view/MV is a node. A node either
62///    re-typechecks (when its file or any upstream output changed) or returns
63///    its cached column schema directly. Dependents only re-typecheck when at
64///    least one upstream dep was schema-changed, which keeps a leaf edit that
65///    doesn't change the leaf's output schema from cascading.
66/// 3. Persist newly-validated columns to SQLite. Failed and blocked objects
67///    keep their last successful row in the cache.
68///
69/// Returns the merged `Types` covering validated columns, base columns
70/// (tables/sources/etc.), and external `types.lock` entries, plus stats
71/// describing how much work the incremental layer skipped.
72pub(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    // Open the build artifact db now so we can use it for incremental reads
93    // (cached columns, prior external-type digests) and the final upserts.
94    let mut db = BuildArtifact::open(directory, profile, profile_suffix, variables)
95        .map_err(TypesError::from)?;
96
97    // Snapshot all cached typecheck columns up front. Reading inside the DAG
98    // would require a Sync SQLite handle, which rusqlite::Connection isn't.
99    // The map is keyed by `ObjectId.to_string()` (matches sqlite layout).
100    let cached_columns_by_key: BTreeMap<String, BTreeMap<String, ColumnType>> =
101        db.load_typecheck_columns().map_err(TypesError::from)?;
102
103    // Diff per-external-table digests against the cached set. Any project
104    // object whose `external_dependencies` intersects the changed set is added
105    // to the dirty set on top of `project.compile_dirty`.
106    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            // 1. The view's own source changed.
126            if project.compile_dirty.contains(id) {
127                return true;
128            }
129            // 2. The view has no cached typecheck row — it was either never
130            //    validated or its previous run failed. Either way, retry.
131            if !cached_columns_by_key.contains_key(&id.to_string()) {
132                return true;
133            }
134            // 3. A non-view direct dep was recompiled, or an external schema
135            //    changed. View deps are deliberately ignored here — the DAG's
136            //    schema-stability propagation handles those.
137            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    // `pessimistic_dirty` = `initial_dirty` plus every transitive view
150    // dependent — a schema change in an upstream view may cascade.
151    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    // Direct deps of `pessimistic_dirty`: view deps join the DAG; non-view
167    // deps go into the bootstrap set. Clean DAG nodes skip-return their
168    // cached columns, so transitive deps don't need expansion.
169    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    // Build the DAG only over `dag_nodes`. Direct-dep edges are filtered to
188    // node IDs actually present in the DAG (other deps are already stubbed
189    // into the base catalog above).
190    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                // Only persist nodes whose schema actually changed (or are
299                // brand new). Skipped and schema-stable nodes already have a
300                // matching row in the cache.
301                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                // Replace the catalog's synthesized placeholder path with the
311                // real source path so diagnostics point at the user's file.
312                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    // Drop cache rows for failed/blocked nodes. Without this, a previously
331    // successful row would let a now-broken view skip typecheck on the next
332    // run and silently report success.
333    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(&current_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            version: 1,
354            tables: merged_tables,
355            kinds: merged_kinds,
356            comments: BTreeMap::new(),
357        },
358        stats,
359    ))
360}
361
362/// Lock-free per-node decision counters aggregated across the parallel executor.
363#[derive(Default)]
364struct StatsCounter {
365    schema_stable: std::sync::atomic::AtomicUsize,
366    schema_changed: std::sync::atomic::AtomicUsize,
367}
368
369impl StatsCounter {
370    fn record(&self, schema_stable: bool) {
371        let counter = if schema_stable {
372            &self.schema_stable
373        } else {
374            &self.schema_changed
375        };
376        counter.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
377    }
378
379    fn snapshot(&self, total_nodes: usize) -> TypecheckStats {
380        use std::sync::atomic::Ordering::Relaxed;
381        let schema_stable = self.schema_stable.load(Relaxed);
382        let schema_changed = self.schema_changed.load(Relaxed);
383        let ran = schema_stable + schema_changed;
384        TypecheckStats {
385            ran,
386            skipped: total_nodes.saturating_sub(ran),
387            schema_stable,
388            schema_changed,
389        }
390    }
391}
392
393fn typecheck_node(
394    node_id: &ObjectId,
395    db_obj: &crate::project::ir::compiled::DatabaseObject,
396    base_catalog: Arc<catalog::CatalogRuntime>,
397    dep_results: &BTreeMap<ObjectId, Arc<NodeValue>>,
398    cached_columns: Option<&BTreeMap<String, ColumnType>>,
399) -> Result<NodeValue, ObjectTypeCheckError> {
400    let mut runtime = catalog::TaskCatalog::new(base_catalog);
401    for (dep_id, dep_value) in dep_results {
402        runtime
403            .create_stub_table(dep_id, &dep_value.columns)
404            .map_err(|err| {
405                ObjectTypeCheckError::internal(
406                    dep_id.clone(),
407                    db_obj.path.clone(),
408                    format!("failed to stub dependency: {err}"),
409                )
410            })?;
411    }
412    let fqn: FullyQualifiedName = node_id.clone().into();
413    let ast = convert::create_catalog_item_ast(&db_obj.stmt, &fqn).ok_or_else(|| {
414        ObjectTypeCheckError::internal(
415            node_id.clone(),
416            db_obj.path.clone(),
417            "internal: failed to build catalog AST".into(),
418        )
419    })?;
420    let desc = runtime.create_item_from_ast(node_id, ast)?;
421    let columns = convert::relation_desc_to_columns(&desc);
422    let schema_stable = cached_columns.is_some_and(|cached| cached == &columns);
423    Ok(NodeValue {
424        columns,
425        schema_stable,
426    })
427}
428
429/// SHA-256 digest of a column map, deterministic across runs because the
430/// underlying `BTreeMap` iterates in sorted key order.
431fn digest_columns(cols: &BTreeMap<String, ColumnType>) -> String {
432    let mut hasher = Sha256::new();
433    for (name, t) in cols {
434        hasher.update(name.as_bytes());
435        hasher.update(b"\0");
436        hasher.update(t.r#type.as_bytes());
437        hasher.update(b"\0");
438        hasher.update([u8::from(t.nullable)]);
439        hasher.update(b"\0");
440        hasher.update(u64::try_from(t.position).unwrap_or(u64::MAX).to_le_bytes());
441        hasher.update(b"\0");
442    }
443    format!("{:x}", hasher.finalize())
444}
445
446/// Per-external-table digests keyed by `ObjectId.to_string()`.
447fn compute_external_digests(external_types: &Types) -> BTreeMap<String, String> {
448    external_types
449        .tables
450        .iter()
451        .map(|(id, cols)| (id.to_string(), digest_columns(cols)))
452        .collect()
453}
454
455#[cfg(test)]
456mod run_tests {
457    use super::*;
458    use crate::project::compiler::compile_sync;
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)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
470    #[mz_ore::test]
471    fn run_typechecks_simple_view_and_persists_columns() {
472        let temp = tempdir().unwrap();
473        let root = temp.path();
474        // Tables (storage) and views (computation) must be in separate schemas.
475        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    /// A second `run` after no source change should typecheck zero nodes.
511    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
512    #[mz_ore::test]
513    fn second_run_skips_all_nodes_when_nothing_changed() {
514        let temp = tempdir().unwrap();
515        let root = temp.path();
516        write_sql(
517            root,
518            "models/materialize/storage/t1.sql",
519            "CREATE TABLE t1 (a int)",
520        );
521        write_sql(
522            root,
523            "models/materialize/public/v1.sql",
524            "CREATE VIEW v1 AS SELECT a FROM materialize.storage.t1",
525        );
526        write_sql(
527            root,
528            "models/materialize/public/v2.sql",
529            "CREATE VIEW v2 AS SELECT a FROM materialize.public.v1",
530        );
531
532        let fs = crate::fs::FileSystem::new();
533        // First run: prime the cache.
534        let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
535        let (_, first) = run(
536            root,
537            "default",
538            None,
539            &BTreeMap::new(),
540            &project,
541            Types::default(),
542        )
543        .unwrap();
544        assert_eq!(first.ran, 2, "first run should typecheck v1 and v2");
545        assert_eq!(first.skipped, 0);
546
547        // Second run: nothing changed, both views should be skipped.
548        let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
549        let (_, second) = run(
550            root,
551            "default",
552            None,
553            &BTreeMap::new(),
554            &project,
555            Types::default(),
556        )
557        .unwrap();
558        assert_eq!(second.ran, 0, "second run should skip everything");
559        assert_eq!(second.skipped, 2);
560    }
561
562    /// Editing a leaf view in a way that doesn't change its output schema
563    /// should re-typecheck the leaf but skip its dependents.
564    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
565    #[mz_ore::test]
566    fn schema_stable_edit_does_not_dirty_dependents() {
567        let temp = tempdir().unwrap();
568        let root = temp.path();
569        write_sql(
570            root,
571            "models/materialize/storage/t1.sql",
572            "CREATE TABLE t1 (a int)",
573        );
574        write_sql(
575            root,
576            "models/materialize/public/v1.sql",
577            "CREATE VIEW v1 AS SELECT a FROM materialize.storage.t1",
578        );
579        write_sql(
580            root,
581            "models/materialize/public/v2.sql",
582            "CREATE VIEW v2 AS SELECT a FROM materialize.public.v1",
583        );
584
585        let fs = crate::fs::FileSystem::new();
586        let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
587        let _ = run(
588            root,
589            "default",
590            None,
591            &BTreeMap::new(),
592            &project,
593            Types::default(),
594        )
595        .unwrap();
596
597        // Rewrite v1 in a way that produces the same column schema.
598        write_sql(
599            root,
600            "models/materialize/public/v1.sql",
601            "CREATE VIEW v1 AS SELECT a FROM (SELECT * FROM materialize.storage.t1)",
602        );
603        let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
604        let (_, stats) = run(
605            root,
606            "default",
607            None,
608            &BTreeMap::new(),
609            &project,
610            Types::default(),
611        )
612        .unwrap();
613
614        assert_eq!(stats.ran, 1, "only v1 should re-typecheck");
615        assert_eq!(stats.schema_stable, 1, "v1 output unchanged");
616        assert_eq!(stats.schema_changed, 0);
617        assert_eq!(stats.skipped, 1, "v2 should skip on stable upstream");
618    }
619
620    /// Changing one external table's schema only dirties objects that depend
621    /// on that specific table. Unrelated objects keep their cached results.
622    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
623    #[mz_ore::test]
624    fn external_type_change_dirties_only_consumers() {
625        use crate::types::ObjectKind;
626
627        let temp = tempdir().unwrap();
628        let root = temp.path();
629        // v_ext_a depends on ext.public.t_a; v_ext_b depends on ext.public.t_b.
630        // Both are in storage so the project itself has no internal deps to
631        // muddy the test.
632        write_sql(
633            root,
634            "models/materialize/public/v_ext_a.sql",
635            "CREATE VIEW v_ext_a AS SELECT a FROM ext.public.t_a",
636        );
637        write_sql(
638            root,
639            "models/materialize/public/v_ext_b.sql",
640            "CREATE VIEW v_ext_b AS SELECT a FROM ext.public.t_b",
641        );
642
643        let mk_types = |a_type: &str, b_type: &str| {
644            let mut tables: BTreeMap<ObjectId, BTreeMap<String, ColumnType>> = BTreeMap::new();
645            let mut kinds: BTreeMap<ObjectId, ObjectKind> = BTreeMap::new();
646            let t_a: ObjectId = "ext.public.t_a".parse().unwrap();
647            let t_b: ObjectId = "ext.public.t_b".parse().unwrap();
648            tables.insert(
649                t_a.clone(),
650                BTreeMap::from([(
651                    "a".to_string(),
652                    ColumnType {
653                        r#type: a_type.to_string(),
654                        nullable: true,
655                        position: 0,
656                        comment: None,
657                    },
658                )]),
659            );
660            tables.insert(
661                t_b.clone(),
662                BTreeMap::from([(
663                    "a".to_string(),
664                    ColumnType {
665                        r#type: b_type.to_string(),
666                        nullable: true,
667                        position: 0,
668                        comment: None,
669                    },
670                )]),
671            );
672            kinds.insert(t_a, ObjectKind::Table);
673            kinds.insert(t_b, ObjectKind::Table);
674            Types {
675                version: 1,
676                tables,
677                kinds,
678                comments: BTreeMap::new(),
679            }
680        };
681
682        let fs = crate::fs::FileSystem::new();
683        // Prime.
684        let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
685        let _ = run(
686            root,
687            "default",
688            None,
689            &BTreeMap::new(),
690            &project,
691            mk_types("integer", "integer"),
692        )
693        .unwrap();
694
695        // Same externals → both views skip.
696        let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
697        let (_, stats) = run(
698            root,
699            "default",
700            None,
701            &BTreeMap::new(),
702            &project,
703            mk_types("integer", "integer"),
704        )
705        .unwrap();
706        assert_eq!(stats.skipped, 2, "no external change → both skip");
707        assert_eq!(stats.ran, 0);
708
709        // Change t_a's column type → only v_ext_a dirties.
710        let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
711        let (_, stats) = run(
712            root,
713            "default",
714            None,
715            &BTreeMap::new(),
716            &project,
717            mk_types("text", "integer"),
718        )
719        .unwrap();
720        assert_eq!(stats.ran, 1, "only v_ext_a should re-run");
721        assert_eq!(stats.skipped, 1, "v_ext_b should skip");
722    }
723
724    /// A leaf edit that changes the output schema must cascade to dependents.
725    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
726    #[mz_ore::test]
727    fn schema_change_dirties_dependents() {
728        let temp = tempdir().unwrap();
729        let root = temp.path();
730        write_sql(
731            root,
732            "models/materialize/storage/t1.sql",
733            "CREATE TABLE t1 (a int, b int)",
734        );
735        write_sql(
736            root,
737            "models/materialize/public/v1.sql",
738            "CREATE VIEW v1 AS SELECT a FROM materialize.storage.t1",
739        );
740        write_sql(
741            root,
742            "models/materialize/public/v2.sql",
743            "CREATE VIEW v2 AS SELECT * FROM materialize.public.v1",
744        );
745
746        let fs = crate::fs::FileSystem::new();
747        let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
748        let _ = run(
749            root,
750            "default",
751            None,
752            &BTreeMap::new(),
753            &project,
754            Types::default(),
755        )
756        .unwrap();
757
758        // Add a column to v1's projection — its schema changes.
759        write_sql(
760            root,
761            "models/materialize/public/v1.sql",
762            "CREATE VIEW v1 AS SELECT a, b FROM materialize.storage.t1",
763        );
764        let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
765        let (_, stats) = run(
766            root,
767            "default",
768            None,
769            &BTreeMap::new(),
770            &project,
771            Types::default(),
772        )
773        .unwrap();
774
775        assert_eq!(stats.ran, 2, "v1 changed, v2 must re-run");
776        assert_eq!(stats.schema_changed, 2);
777        assert_eq!(stats.skipped, 0);
778    }
779
780    /// A view whose typecheck failed must be re-run on the next invocation,
781    /// even if no source files changed. Otherwise an unfixed broken project
782    /// would silently start passing on the second compile.
783    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
784    #[mz_ore::test]
785    fn previous_typecheck_failure_re_runs_next_invocation() {
786        let temp = tempdir().unwrap();
787        let root = temp.path();
788        write_sql(
789            root,
790            "models/materialize/storage/t1.sql",
791            "CREATE TABLE t1 (a int)",
792        );
793        // v1 references a column that doesn't exist on t1 — typecheck fails.
794        write_sql(
795            root,
796            "models/materialize/public/v1.sql",
797            "CREATE VIEW v1 AS SELECT no_such_column FROM materialize.storage.t1",
798        );
799
800        let fs = crate::fs::FileSystem::new();
801        let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
802        let first = run(
803            root,
804            "default",
805            None,
806            &BTreeMap::new(),
807            &project,
808            Types::default(),
809        );
810        assert!(first.is_err(), "first run should fail typechecking v1");
811
812        // Second run: identical project, identical files. The failed view
813        // must run again and surface the same error — not be skipped.
814        let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
815        let second = run(
816            root,
817            "default",
818            None,
819            &BTreeMap::new(),
820            &project,
821            Types::default(),
822        );
823        assert!(
824            second.is_err(),
825            "second run must also fail — typecheck cache must not mask an unfixed error"
826        );
827    }
828
829    /// Editing a previously-successful view to introduce a typecheck error must
830    /// surface that error on every subsequent run — not just the first one.
831    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
832    #[mz_ore::test]
833    fn typecheck_failure_after_successful_run_persists() {
834        let temp = tempdir().unwrap();
835        let root = temp.path();
836        write_sql(
837            root,
838            "models/materialize/storage/t1.sql",
839            "CREATE TABLE t1 (a int)",
840        );
841        write_sql(
842            root,
843            "models/materialize/public/v1.sql",
844            "CREATE VIEW v1 AS SELECT a FROM materialize.storage.t1",
845        );
846
847        let fs = crate::fs::FileSystem::new();
848        let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
849        run(
850            root,
851            "default",
852            None,
853            &BTreeMap::new(),
854            &project,
855            Types::default(),
856        )
857        .expect("first run typechecks cleanly");
858
859        write_sql(
860            root,
861            "models/materialize/public/v1.sql",
862            "CREATE VIEW v1 AS SELECT no_such_column FROM materialize.storage.t1",
863        );
864        let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
865        let second = run(
866            root,
867            "default",
868            None,
869            &BTreeMap::new(),
870            &project,
871            Types::default(),
872        );
873        assert!(second.is_err(), "edit should surface typecheck error");
874
875        let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
876        let third = run(
877            root,
878            "default",
879            None,
880            &BTreeMap::new(),
881            &project,
882            Types::default(),
883        );
884        assert!(
885            third.is_err(),
886            "stale cache row from before the edit must not let a broken view skip typecheck"
887        );
888    }
889
890    /// Editing a non-view object (e.g. a table) must invalidate dependent
891    /// views' cached typecheck results, because the table's column schema
892    /// flows into the catalog views are validated against.
893    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
894    #[mz_ore::test]
895    fn table_edit_dirties_dependent_view() {
896        let temp = tempdir().unwrap();
897        let root = temp.path();
898        write_sql(
899            root,
900            "models/materialize/storage/t1.sql",
901            "CREATE TABLE t1 (a int)",
902        );
903        write_sql(
904            root,
905            "models/materialize/public/v1.sql",
906            "CREATE VIEW v1 AS SELECT a FROM materialize.storage.t1",
907        );
908
909        let fs = crate::fs::FileSystem::new();
910        let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
911        let _ = run(
912            root,
913            "default",
914            None,
915            &BTreeMap::new(),
916            &project,
917            Types::default(),
918        )
919        .unwrap();
920
921        // Edit the table to remove the column the view depends on.
922        write_sql(
923            root,
924            "models/materialize/storage/t1.sql",
925            "CREATE TABLE t1 (b int)",
926        );
927        let project = compile_sync(&fs, root, None, None, &BTreeMap::new()).unwrap();
928        let result = run(
929            root,
930            "default",
931            None,
932            &BTreeMap::new(),
933            &project,
934            Types::default(),
935        );
936        assert!(
937            result.is_err(),
938            "v1 references column `a` which no longer exists on t1; \
939             dependent view must re-typecheck and surface the error"
940        );
941    }
942}