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            tables: merged_tables,
354            kinds: merged_kinds,
355            comments: BTreeMap::new(),
356        },
357        stats,
358    ))
359}
360
361/// Lock-free per-node decision counters aggregated across the parallel executor.
362#[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
428/// SHA-256 digest of a column map, deterministic across runs because the
429/// underlying `BTreeMap` iterates in sorted key order.
430fn 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
445/// Per-external-table digests keyed by `ObjectId.to_string()`.
446fn 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)] // 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    /// Needs three objects: `sum()` types as `Numeric { max_scale: Some(0) }`,
511    /// but that type only has to survive SQL round-tripping once a dependent
512    /// forces the aggregate to be stubbed.
513    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
514    #[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    /// A record column has no data-type syntax, so it only survives once a
558    /// dependent forces the producing view to be stubbed.
559    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
560    #[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    /// The same for a record arriving over the data contract rather than from
607    /// a project object.
608    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
609    #[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    /// A second `run` after no source change should typecheck zero nodes.
668    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
669    #[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        // First run: prime the cache.
691        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        // Second run: nothing changed, both views should be skipped.
705        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    /// Editing a leaf view in a way that doesn't change its output schema
720    /// should re-typecheck the leaf but skip its dependents.
721    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
722    #[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        // Rewrite v1 in a way that produces the same column schema.
755        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    /// Changing one external table's schema only dirties objects that depend
778    /// on that specific table. Unrelated objects keep their cached results.
779    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
780    #[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        // v_ext_a depends on ext.public.t_a; v_ext_b depends on ext.public.t_b.
787        // Both are in storage so the project itself has no internal deps to
788        // muddy the test.
789        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        // Prime.
840        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        // Same externals → both views skip.
852        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        // Change t_a's column type → only v_ext_a dirties.
866        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    /// A leaf edit that changes the output schema must cascade to dependents.
881    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
882    #[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        // Add a column to v1's projection — its schema changes.
915        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    /// A view whose typecheck failed must be re-run on the next invocation,
937    /// even if no source files changed. Otherwise an unfixed broken project
938    /// would silently start passing on the second compile.
939    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
940    #[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        // v1 references a column that doesn't exist on t1 — typecheck fails.
950        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        // Second run: identical project, identical files. The failed view
969        // must run again and surface the same error — not be skipped.
970        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    /// Editing a previously-successful view to introduce a typecheck error must
986    /// surface that error on every subsequent run — not just the first one.
987    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
988    #[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    /// Editing a non-view object (e.g. a table) must invalidate dependent
1047    /// views' cached typecheck results, because the table's column schema
1048    /// flows into the catalog views are validated against.
1049    #[cfg_attr(miri, ignore)] // unsupported operation: can't call foreign function `rust_psm_stack_pointer` on OS `linux`
1050    #[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        // Edit the table to remove the column the view depends on.
1078        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}