Skip to main content

mz_deploy/cli/commands/test/
lower.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//! Validate a [`UnitTest`] and lower it into SQL Materialize can execute.
11//!
12//! The lowered form is a sequence of `CREATE TEMPORARY VIEW` statements
13//! (mocks, expected, target) followed by an assertion query whose rows
14//! describe mismatches. An empty result means the test passed.
15//!
16//! ```sql
17//! EXECUTE UNIT TEST test_name
18//! FOR database.schema.view_name
19//! [AT TIME 'timestamp']  -- optional, sets mz_now() during test
20//! MOCK database.schema.mock1(col1 TYPE1, col2 TYPE2) AS (
21//!   SELECT * FROM VALUES (...)
22//! ),
23//! MOCK database.schema.mock2(col TYPE) AS (
24//!   SELECT * FROM VALUES (...)
25//! )
26//! EXPECTED(col1 TYPE1, col2 TYPE2) AS (
27//!   SELECT * FROM VALUES (...)
28//! );
29//! ```
30
31use crate::project::ast::Statement;
32use crate::project::ir::compiled::FullyQualifiedName;
33use crate::project::ir::object_id::ObjectId;
34use crate::project::ir::unit_test::{ExpectedResult, MockView, UnitTest};
35use crate::project::resolve::normalize::NormalizingVisitor;
36use crate::types::ColumnType;
37#[cfg(test)]
38use crate::types::Types;
39use mz_sql_parser::ast::{CreateViewStatement, IfExistsBehavior, ViewDefinition};
40use owo_colors::{OwoColorize, Stream, Style};
41use serde::Serialize;
42use std::collections::{BTreeMap, BTreeSet};
43use std::fmt;
44use thiserror::Error;
45
46/// Errors that can occur during unit test validation.
47#[derive(Debug, Error, Serialize)]
48pub enum TestValidationError {
49    /// A required dependency is not mocked
50    #[error("unmocked dependency")]
51    UnmockedDependency(UnmockedDependencyError),
52
53    /// A mock is missing required columns
54    #[error("mock schema mismatch")]
55    MockSchemaMismatch(MockSchemaMismatchError),
56
57    /// Expected output doesn't match target view schema
58    #[error("expected output schema mismatch")]
59    ExpectedSchemaMismatch(ExpectedSchemaMismatchError),
60
61    /// The AT TIME value is not a valid timestamp
62    #[error("invalid at_time timestamp")]
63    InvalidAtTime(InvalidAtTimeError),
64
65    /// Types cache is missing or stale
66    #[error("types cache unavailable: {reason}")]
67    TypesCacheUnavailable { reason: String },
68}
69
70/// Error: A dependency of the target view is not mocked.
71#[derive(Debug, Serialize)]
72pub struct UnmockedDependencyError {
73    /// Test name
74    pub test_name: String,
75    /// The target view being tested
76    pub target_view: String,
77    /// Dependencies that are not mocked
78    pub missing_mocks: Vec<String>,
79}
80
81impl fmt::Display for UnmockedDependencyError {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        let error_style = Style::new().bright_red().bold();
84        let marker_style = Style::new().bright_blue().bold();
85        writeln!(
86            f,
87            "{}: test '{}' has unmocked dependencies",
88            "error".if_supports_color(Stream::Stderr, |t| error_style.style(t)),
89            self.test_name
90                .if_supports_color(Stream::Stderr, |t| t.cyan())
91        )?;
92        writeln!(
93            f,
94            " {} target view: {}",
95            "-->".if_supports_color(Stream::Stderr, |t| marker_style.style(t)),
96            self.target_view
97                .if_supports_color(Stream::Stderr, |t| t.yellow())
98        )?;
99        writeln!(f)?;
100        writeln!(
101            f,
102            "  {} The following dependencies must be mocked:",
103            "|".if_supports_color(Stream::Stderr, |t| marker_style.style(t))
104        )?;
105        for dep in &self.missing_mocks {
106            writeln!(
107                f,
108                "  {}   - {}",
109                "|".if_supports_color(Stream::Stderr, |t| marker_style.style(t)),
110                dep.if_supports_color(Stream::Stderr, |t| t.yellow())
111            )?;
112        }
113        writeln!(f)?;
114        writeln!(
115            f,
116            "  {} Add mocks for these dependencies in the WITH clause of the test",
117            "=".if_supports_color(Stream::Stderr, |t| marker_style.style(t))
118        )?;
119        Ok(())
120    }
121}
122
123impl std::error::Error for UnmockedDependencyError {}
124
125/// Error: A mock's columns don't match the actual schema.
126#[derive(Debug, Serialize)]
127pub struct MockSchemaMismatchError {
128    /// Test name
129    pub test_name: String,
130    /// The mock that has mismatched columns
131    pub mock_fqn: String,
132    /// Columns in mock that don't exist in actual schema
133    pub extra_columns: Vec<String>,
134    /// Columns in actual schema missing from mock (name, type)
135    pub missing_columns: Vec<(String, String)>,
136    /// Columns with wrong types (column_name, mock_type, actual_type)
137    pub type_mismatches: Vec<(String, String, String)>,
138    /// The actual schema columns with types (for showing expected signature)
139    pub actual_schema: Vec<(String, String)>,
140}
141
142impl fmt::Display for MockSchemaMismatchError {
143    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
144        let error_style = Style::new().bright_red().bold();
145        let marker_style = Style::new().bright_blue().bold();
146        writeln!(
147            f,
148            "{}: mock '{}' schema doesn't match actual schema",
149            "error".if_supports_color(Stream::Stderr, |t| error_style.style(t)),
150            self.mock_fqn
151                .if_supports_color(Stream::Stderr, |t| t.cyan())
152        )?;
153        writeln!(
154            f,
155            " {} in test: {}",
156            "-->".if_supports_color(Stream::Stderr, |t| marker_style.style(t)),
157            self.test_name
158                .if_supports_color(Stream::Stderr, |t| t.yellow())
159        )?;
160        writeln!(f)?;
161
162        if !self.missing_columns.is_empty() {
163            writeln!(
164                f,
165                "  {} Missing columns (required but not in mock):",
166                "|".if_supports_color(Stream::Stderr, |t| marker_style.style(t))
167            )?;
168            for (col, typ) in &self.missing_columns {
169                writeln!(
170                    f,
171                    "  {}   - {} {}",
172                    "|".if_supports_color(Stream::Stderr, |t| marker_style.style(t)),
173                    col.if_supports_color(Stream::Stderr, |t| t.red()),
174                    typ.to_uppercase()
175                        .if_supports_color(Stream::Stderr, |t| t.dimmed())
176                )?;
177            }
178        }
179
180        if !self.extra_columns.is_empty() {
181            writeln!(
182                f,
183                "  {} Extra columns (in mock but not in actual schema):",
184                "|".if_supports_color(Stream::Stderr, |t| marker_style.style(t))
185            )?;
186            for col in &self.extra_columns {
187                writeln!(
188                    f,
189                    "  {}   - {}",
190                    "|".if_supports_color(Stream::Stderr, |t| marker_style.style(t)),
191                    col.if_supports_color(Stream::Stderr, |t| t.yellow())
192                )?;
193            }
194        }
195
196        if !self.type_mismatches.is_empty() {
197            writeln!(
198                f,
199                "  {} Type mismatches:",
200                "|".if_supports_color(Stream::Stderr, |t| marker_style.style(t))
201            )?;
202            for (col, mock_type, actual_type) in &self.type_mismatches {
203                writeln!(
204                    f,
205                    "  {}   - {}: mock has '{}', expected '{}'",
206                    "|".if_supports_color(Stream::Stderr, |t| marker_style.style(t)),
207                    col.if_supports_color(Stream::Stderr, |t| t.cyan()),
208                    mock_type.if_supports_color(Stream::Stderr, |t| t.red()),
209                    actual_type.if_supports_color(Stream::Stderr, |t| t.green())
210                )?;
211            }
212        }
213
214        writeln!(f)?;
215
216        if !self.actual_schema.is_empty() {
217            writeln!(
218                f,
219                "  {} Expected mock signature:",
220                "=".if_supports_color(Stream::Stderr, |t| marker_style.style(t))
221            )?;
222            let cols: Vec<String> = self
223                .actual_schema
224                .iter()
225                .map(|(name, typ)| format!("{} {}", name, typ.to_uppercase()))
226                .collect();
227            writeln!(
228                f,
229                "  {}   MOCK {}({}) AS (...)",
230                "|".if_supports_color(Stream::Stderr, |t| marker_style.style(t)),
231                self.mock_fqn
232                    .if_supports_color(Stream::Stderr, |t| t.green()),
233                cols.join(", ")
234                    .if_supports_color(Stream::Stderr, |t| t.green())
235            )?;
236        }
237
238        Ok(())
239    }
240}
241
242impl std::error::Error for MockSchemaMismatchError {}
243
244/// Error: Expected output columns don't match the target view schema.
245#[derive(Debug, Serialize)]
246pub struct ExpectedSchemaMismatchError {
247    /// Test name
248    pub test_name: String,
249    /// The target view being tested
250    pub target_view: String,
251    /// Columns in expected that don't exist in target schema
252    pub extra_columns: Vec<String>,
253    /// Columns in target schema missing from expected (name, type)
254    pub missing_columns: Vec<(String, String)>,
255    /// Columns with wrong types (column_name, expected_type, actual_type)
256    pub type_mismatches: Vec<(String, String, String)>,
257    /// The actual schema columns with types (for showing expected signature)
258    pub actual_schema: Vec<(String, String)>,
259}
260
261impl fmt::Display for ExpectedSchemaMismatchError {
262    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
263        let error_style = Style::new().bright_red().bold();
264        let marker_style = Style::new().bright_blue().bold();
265        writeln!(
266            f,
267            "{}: expected output schema doesn't match target view",
268            "error".if_supports_color(Stream::Stderr, |t| error_style.style(t))
269        )?;
270        writeln!(
271            f,
272            " {} target: {} | test: {}",
273            "-->".if_supports_color(Stream::Stderr, |t| marker_style.style(t)),
274            self.target_view
275                .if_supports_color(Stream::Stderr, |t| t.cyan()),
276            self.test_name
277                .if_supports_color(Stream::Stderr, |t| t.yellow())
278        )?;
279        writeln!(f)?;
280
281        if !self.missing_columns.is_empty() {
282            writeln!(
283                f,
284                "  {} Missing columns (in target view but not in expected):",
285                "|".if_supports_color(Stream::Stderr, |t| marker_style.style(t))
286            )?;
287            for (col, typ) in &self.missing_columns {
288                writeln!(
289                    f,
290                    "  {}   - {} {}",
291                    "|".if_supports_color(Stream::Stderr, |t| marker_style.style(t)),
292                    col.if_supports_color(Stream::Stderr, |t| t.red()),
293                    typ.to_uppercase()
294                        .if_supports_color(Stream::Stderr, |t| t.dimmed())
295                )?;
296            }
297        }
298
299        if !self.extra_columns.is_empty() {
300            writeln!(
301                f,
302                "  {} Extra columns (in expected but not in target view):",
303                "|".if_supports_color(Stream::Stderr, |t| marker_style.style(t))
304            )?;
305            for col in &self.extra_columns {
306                writeln!(
307                    f,
308                    "  {}   - {}",
309                    "|".if_supports_color(Stream::Stderr, |t| marker_style.style(t)),
310                    col.if_supports_color(Stream::Stderr, |t| t.yellow())
311                )?;
312            }
313        }
314
315        if !self.type_mismatches.is_empty() {
316            writeln!(
317                f,
318                "  {} Type mismatches:",
319                "|".if_supports_color(Stream::Stderr, |t| marker_style.style(t))
320            )?;
321            for (col, expected_type, actual_type) in &self.type_mismatches {
322                writeln!(
323                    f,
324                    "  {}   - {}: has '{}', expected '{}'",
325                    "|".if_supports_color(Stream::Stderr, |t| marker_style.style(t)),
326                    col.if_supports_color(Stream::Stderr, |t| t.cyan()),
327                    expected_type.if_supports_color(Stream::Stderr, |t| t.red()),
328                    actual_type.if_supports_color(Stream::Stderr, |t| t.green())
329                )?;
330            }
331        }
332
333        writeln!(f)?;
334
335        if !self.actual_schema.is_empty() {
336            writeln!(
337                f,
338                "  {} Expected signature:",
339                "=".if_supports_color(Stream::Stderr, |t| marker_style.style(t))
340            )?;
341            let cols: Vec<String> = self
342                .actual_schema
343                .iter()
344                .map(|(name, typ)| format!("{} {}", name, typ.to_uppercase()))
345                .collect();
346            writeln!(
347                f,
348                "  {}   EXPECTED({}) AS (...)",
349                "|".if_supports_color(Stream::Stderr, |t| marker_style.style(t)),
350                cols.join(", ")
351                    .if_supports_color(Stream::Stderr, |t| t.green())
352            )?;
353        }
354
355        Ok(())
356    }
357}
358
359impl std::error::Error for ExpectedSchemaMismatchError {}
360
361/// Error: The AT TIME value is not a valid timestamp.
362#[derive(Debug, Serialize)]
363pub struct InvalidAtTimeError {
364    /// Test name
365    pub test_name: String,
366    /// The invalid AT TIME value
367    pub at_time_value: String,
368    /// The database error message
369    pub db_error: String,
370}
371
372impl fmt::Display for InvalidAtTimeError {
373    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
374        let error_style = Style::new().bright_red().bold();
375        let marker_style = Style::new().bright_blue().bold();
376        writeln!(
377            f,
378            "{}: test '{}' has invalid AT TIME value",
379            "error".if_supports_color(Stream::Stderr, |t| error_style.style(t)),
380            self.test_name
381                .if_supports_color(Stream::Stderr, |t| t.cyan())
382        )?;
383        writeln!(
384            f,
385            " {} value: {}",
386            "-->".if_supports_color(Stream::Stderr, |t| marker_style.style(t)),
387            self.at_time_value
388                .if_supports_color(Stream::Stderr, |t| t.yellow())
389        )?;
390        writeln!(f)?;
391
392        // Show the useful tail of DB errors like:
393        //   "Error: invalid input syntax for type mz_timestamp: ..."
394        let display_error = self
395            .db_error
396            .find("invalid input syntax")
397            .map(|idx| &self.db_error[idx..])
398            .unwrap_or(&self.db_error);
399
400        writeln!(
401            f,
402            "  {} {}",
403            "|".if_supports_color(Stream::Stderr, |t| marker_style.style(t)),
404            display_error.if_supports_color(Stream::Stderr, |t| t.red())
405        )?;
406        writeln!(f)?;
407        writeln!(
408            f,
409            "  {} The AT TIME value must be a valid timestamp that can be cast to mz_timestamp",
410            "=".if_supports_color(Stream::Stderr, |t| marker_style.style(t))
411        )?;
412        writeln!(
413            f,
414            "  {} Example: AT TIME '2024-01-15 10:00:00'",
415            "=".if_supports_color(Stream::Stderr, |t| marker_style.style(t))
416        )?;
417        Ok(())
418    }
419}
420
421impl std::error::Error for InvalidAtTimeError {}
422
423/// Validate a unit test against the known types.
424///
425/// This function performs three validations:
426/// 1. All dependencies of the target view are mocked
427/// 2. Each mock's columns match the actual schema of the mocked object
428/// 3. The expected output columns match the target view's output schema
429///
430/// # Arguments
431/// * `test` - The unit test to validate
432/// * `target_id` - The ObjectId of the target view
433/// * `get_columns` - Lookup for the column schema of an object, sourced from
434///   types.lock (external) and the build artifact database (internal)
435/// * `dependencies` - Dependencies of the target view from the project's
436///   dependency graph
437pub(super) fn validate_unit_test(
438    test: &UnitTest,
439    target_id: &ObjectId,
440    get_columns: &dyn Fn(&ObjectId) -> Option<BTreeMap<String, ColumnType>>,
441    dependencies: &BTreeSet<ObjectId>,
442) -> Result<(), TestValidationError> {
443    let mocked_ids: BTreeSet<ObjectId> = test
444        .mocks
445        .iter()
446        .map(|m| normalize_fqn(&m.fqn, target_id))
447        .collect();
448
449    let missing_mocks: Vec<String> = dependencies
450        .iter()
451        .filter(|dep| !mocked_ids.contains(*dep))
452        .map(|dep| dep.to_string())
453        .collect();
454
455    if !missing_mocks.is_empty() {
456        return Err(TestValidationError::UnmockedDependency(
457            UnmockedDependencyError {
458                test_name: test.name.clone(),
459                target_view: target_id.to_string(),
460                missing_mocks,
461            },
462        ));
463    }
464
465    for mock in &test.mocks {
466        let mock_id = normalize_fqn(&mock.fqn, target_id);
467
468        if let Some(actual_columns) = get_columns(&mock_id) {
469            let (extra, missing, type_mismatches) = compare_columns(&mock.columns, &actual_columns);
470
471            if !extra.is_empty() || !missing.is_empty() || !type_mismatches.is_empty() {
472                let actual_schema: Vec<(String, String)> = actual_columns
473                    .iter()
474                    .map(|(name, col_type)| (name.clone(), col_type.r#type.clone()))
475                    .collect();
476
477                return Err(TestValidationError::MockSchemaMismatch(
478                    MockSchemaMismatchError {
479                        test_name: test.name.clone(),
480                        mock_fqn: mock_id.to_string(),
481                        extra_columns: extra,
482                        missing_columns: missing,
483                        type_mismatches,
484                        actual_schema,
485                    },
486                ));
487            }
488        }
489        // Mocks not present in types are likely external dependencies not in
490        // types.lock; allow them through and let the database surface any
491        // mismatch at execution time.
492    }
493
494    if let Some(target_columns) = get_columns(target_id) {
495        let (extra, missing, type_mismatches) =
496            compare_columns(&test.expected.columns, &target_columns);
497
498        if !extra.is_empty() || !missing.is_empty() || !type_mismatches.is_empty() {
499            let actual_schema: Vec<(String, String)> = target_columns
500                .iter()
501                .map(|(name, col_type)| (name.clone(), col_type.r#type.clone()))
502                .collect();
503
504            return Err(TestValidationError::ExpectedSchemaMismatch(
505                ExpectedSchemaMismatchError {
506                    test_name: test.name.clone(),
507                    target_view: target_id.to_string(),
508                    extra_columns: extra,
509                    missing_columns: missing,
510                    type_mismatches,
511                    actual_schema,
512                },
513            ));
514        }
515    }
516    // If target isn't in types, we'll catch it during test execution.
517
518    Ok(())
519}
520
521/// Normalize a potentially partial FQN to a fully qualified `ObjectId` using the target's context.
522fn normalize_fqn(fqn: &str, target_id: &ObjectId) -> ObjectId {
523    let parts: Vec<&str> = fqn.split('.').collect();
524    match parts.as_slice() {
525        [object] => ObjectId::new(
526            target_id.expect_database().to_string(),
527            target_id.schema().to_string(),
528            (*object).to_string(),
529        ),
530        [schema, object] => ObjectId::new(
531            target_id.expect_database().to_string(),
532            (*schema).to_string(),
533            (*object).to_string(),
534        ),
535        [db, schema, object] => ObjectId::new(
536            (*db).to_string(),
537            (*schema).to_string(),
538            (*object).to_string(),
539        ),
540        _ => ObjectId::new(String::new(), String::new(), fqn.to_string()),
541    }
542}
543
544/// Compare test columns against actual schema columns.
545///
546/// Returns (extra_columns, missing_columns_with_types, type_mismatches).
547fn compare_columns(
548    test_columns: &[(String, String)],
549    actual_columns: &BTreeMap<String, ColumnType>,
550) -> (
551    Vec<String>,
552    Vec<(String, String)>,
553    Vec<(String, String, String)>,
554) {
555    let test_col_names: BTreeSet<&str> = test_columns.iter().map(|(n, _)| n.as_str()).collect();
556    let actual_col_names: BTreeSet<&str> = actual_columns.keys().map(|s| s.as_str()).collect();
557
558    let extra: Vec<String> = test_col_names
559        .difference(&actual_col_names)
560        .map(|s| (*s).to_string())
561        .collect();
562
563    let missing: Vec<(String, String)> = actual_col_names
564        .difference(&test_col_names)
565        .map(|s| {
566            let typ = actual_columns
567                .get(*s)
568                .map(|c| c.r#type.clone())
569                .unwrap_or_default();
570            ((*s).to_string(), typ)
571        })
572        .collect();
573
574    let type_mismatches: Vec<(String, String, String)> = test_columns
575        .iter()
576        .filter_map(|(name, test_type)| {
577            actual_columns.get(name).and_then(|actual| {
578                let test_normalized = normalize_type(test_type);
579                let actual_normalized = normalize_type(&actual.r#type);
580
581                if test_normalized != actual_normalized {
582                    // SHOW COLUMNS returns bare container types (e.g. "list"
583                    // instead of "int8 list"); treat bare containers as matching
584                    // any parameterized variant.
585                    if types_match_with_bare_containers(&test_normalized, &actual_normalized) {
586                        None
587                    } else {
588                        Some((name.clone(), test_type.clone(), actual.r#type.clone()))
589                    }
590                } else {
591                    None
592                }
593            })
594        })
595        .collect();
596
597    (extra, missing, type_mismatches)
598}
599
600/// Check if two normalized types match when accounting for bare container types.
601///
602/// SHOW COLUMNS returns bare container types (e.g. "list" instead of "int8 list"),
603/// stripping the element type. This function treats bare containers as matching
604/// any parameterized variant of the same container.
605fn types_match_with_bare_containers(a: &str, b: &str) -> bool {
606    if a == "list" && b.ends_with(" list") || b == "list" && a.ends_with(" list") {
607        return true;
608    }
609    if a == "[]" && b.ends_with("[]") || b == "[]" && a.ends_with("[]") {
610        return true;
611    }
612    if a == "map" && b.starts_with("map[") || b == "map" && a.starts_with("map[") {
613        return true;
614    }
615    false
616}
617
618/// Normalize a SQL type for comparison.
619///
620/// Handles Materialize type aliases so that equivalent types compare equal.
621/// See: <https://materialize.com/docs/sql/types/>
622fn normalize_type(t: &str) -> String {
623    let normalized = t.trim().to_lowercase();
624
625    if let Some(element) = normalized.strip_suffix(" list") {
626        if !element.is_empty() {
627            return format!("{} list", normalize_type(element));
628        }
629    }
630
631    if let Some(element) = normalized.strip_suffix("[]") {
632        if !element.is_empty() {
633            return format!("{}[]", normalize_type(element));
634        }
635    }
636
637    if let Some(inner) = normalized
638        .strip_prefix("map[")
639        .and_then(|s| s.strip_suffix(']'))
640    {
641        if let Some((key, value)) = inner.split_once("=>") {
642            return format!("map[{}=>{}]", normalize_type(key), normalize_type(value));
643        }
644    }
645
646    match normalized.as_str() {
647        "int" | "int4" | "integer" => "integer".to_string(),
648        "int8" | "bigint" => "bigint".to_string(),
649        "int2" | "smallint" => "smallint".to_string(),
650
651        "float4" | "real" => "real".to_string(),
652        "float" | "float8" | "double" | "double precision" => "double precision".to_string(),
653
654        "bool" | "boolean" => "boolean".to_string(),
655
656        "string" | "text" => "text".to_string(),
657        "varchar" | "character varying" => "text".to_string(),
658
659        "decimal" | "numeric" => "numeric".to_string(),
660
661        "json" | "jsonb" => "jsonb".to_string(),
662
663        "timestamp" | "timestamp without time zone" => "timestamp without time zone".to_string(),
664        "timestamptz" | "timestamp with time zone" => "timestamp with time zone".to_string(),
665
666        _ => {
667            if normalized.starts_with("varchar") || normalized.starts_with("character varying") {
668                "text".to_string()
669            } else if normalized.starts_with("numeric") || normalized.starts_with("decimal") {
670                "numeric".to_string()
671            } else if normalized.starts_with("timestamp with time zone")
672                || normalized.starts_with("timestamptz")
673            {
674                "timestamp with time zone".to_string()
675            } else if normalized.starts_with("timestamp without time zone")
676                || normalized == "timestamp"
677            {
678                "timestamp without time zone".to_string()
679            } else {
680                normalized
681            }
682        }
683    }
684}
685
686/// Lower a unit test into executable SQL statements.
687///
688/// Returns a vector of SQL strings in order:
689/// 1. CREATE TEMPORARY VIEW for each mock
690/// 2. CREATE TEMPORARY VIEW for expected
691/// 3. CREATE TEMPORARY VIEW for the target (flattened)
692/// 4. Test query with status column
693pub(super) fn lower_unit_test(
694    test: &UnitTest,
695    target_stmt: &Statement,
696    target_fqn: &FullyQualifiedName,
697) -> Result<Vec<String>, String> {
698    let mut statements = Vec::new();
699
700    for mock in &test.mocks {
701        let qualified_mock = qualify_mock_name(mock, target_fqn);
702        statements.push(create_mock_view_sql(&qualified_mock));
703    }
704
705    statements.push(create_expected_view_sql(&test.expected));
706
707    statements.push(create_target_view_sql(target_stmt, target_fqn)?);
708
709    let target_fqn_str = format!(
710        "{}.{}.{}",
711        target_fqn.database(),
712        target_fqn.schema(),
713        target_fqn.object()
714    );
715    let flattened_target_name = flatten_fqn(&target_fqn_str);
716    statements.push(create_test_query_sql(
717        &flattened_target_name,
718        test.at_time.as_deref(),
719    ));
720
721    Ok(statements)
722}
723
724/// Quote a fully qualified name as a single identifier with dots.
725fn flatten_fqn(fqn: &str) -> String {
726    format!("\"{}\"", fqn)
727}
728
729/// Qualify a mock name with the target's FQN context if it's not already qualified.
730fn qualify_mock_name(mock: &MockView, target_fqn: &FullyQualifiedName) -> MockView {
731    let parts = mock.fqn.matches('.').count() + 1;
732
733    let qualified_fqn = match parts {
734        1 => format!(
735            "{}.{}.{}",
736            target_fqn.database(),
737            target_fqn.schema(),
738            mock.fqn
739        ),
740        2 => format!("{}.{}", target_fqn.database(), mock.fqn),
741        _ => mock.fqn.clone(),
742    };
743
744    MockView {
745        fqn: qualified_fqn,
746        columns: mock.columns.clone(),
747        query: mock.query.clone(),
748    }
749}
750
751fn create_mock_view_sql(mock: &MockView) -> String {
752    let flattened_name = flatten_fqn(&mock.fqn);
753    let columns_def = mock
754        .columns
755        .iter()
756        .map(|(name, typ)| format!("{} {}", name, typ))
757        .collect::<Vec<_>>()
758        .join(", ");
759
760    format!(
761        "CREATE TEMPORARY VIEW {} AS\nWITH MUTUALLY RECURSIVE data({}) AS (\n  {}\n)\nSELECT * FROM data;",
762        flattened_name, columns_def, mock.query
763    )
764}
765
766fn create_expected_view_sql(expected: &ExpectedResult) -> String {
767    let columns_def = expected
768        .columns
769        .iter()
770        .map(|(name, typ)| format!("{} {}", name, typ))
771        .collect::<Vec<_>>()
772        .join(", ");
773
774    format!(
775        "CREATE TEMPORARY VIEW expected AS\nWITH MUTUALLY RECURSIVE data({}) AS (\n  {}\n)\nSELECT * FROM data;",
776        columns_def, expected.query
777    )
778}
779
780/// Create SQL for the target view as a temporary view with flattened naming.
781///
782/// Returns an error if the target statement is not a `CREATE VIEW` or
783/// `CREATE MATERIALIZED VIEW` — unit tests only apply to those object types.
784fn create_target_view_sql(stmt: &Statement, fqn: &FullyQualifiedName) -> Result<String, String> {
785    let mut visitor = NormalizingVisitor::flattening(fqn);
786    let transformed_stmt = stmt
787        .clone()
788        .normalize_name_with(&visitor, &fqn.to_item_name())
789        .normalize_dependencies_with(&mut visitor);
790
791    let view_stmt = match transformed_stmt {
792        Statement::CreateView(view) => CreateViewStatement {
793            if_exists: IfExistsBehavior::Error,
794            temporary: true,
795            definition: view.definition.clone(),
796        },
797        Statement::CreateMaterializedView(mv) => CreateViewStatement {
798            if_exists: IfExistsBehavior::Error,
799            temporary: true,
800            definition: ViewDefinition {
801                name: mv.name,
802                columns: mv.columns,
803                query: mv.query,
804            },
805        },
806        other => {
807            return Err(format!(
808                "unit tests are only supported on views and materialized views; \
809                 target '{}.{}.{}' is a {}",
810                fqn.database(),
811                fqn.schema(),
812                fqn.object(),
813                other.kind(),
814            ));
815        }
816    };
817    Ok(view_stmt.to_string())
818}
819
820/// Create the test assertion query that returns failures.
821///
822/// Returns rows with a 'status' column indicating the failure mode:
823/// - 'MISSING': Expected rows not found in actual results
824/// - 'UNEXPECTED': Actual rows not found in expected results
825///
826/// Empty result means the test passed.
827///
828/// If `at_time` is provided, the query includes an `AS OF` clause to set
829/// the value of `mz_now()` during test execution.
830fn create_test_query_sql(flattened_target_name: &str, at_time: Option<&str>) -> String {
831    let as_of_clause = at_time
832        .map(|t| format!(" AS OF {}::mz_timestamp", t))
833        .unwrap_or_default();
834    format!(
835        r#"SELECT 'MISSING' as status, * FROM expected
836EXCEPT
837SELECT 'MISSING', * FROM {}
838
839UNION ALL
840
841SELECT 'UNEXPECTED' as status, * FROM {}
842EXCEPT
843SELECT 'UNEXPECTED', * FROM expected{}"#,
844        flattened_target_name, flattened_target_name, as_of_clause
845    )
846}
847
848#[cfg(test)]
849mod tests {
850    use super::*;
851    use crate::types::ColumnType;
852    use std::collections::BTreeMap;
853
854    #[mz_ore::test]
855    fn test_flatten_fqn() {
856        assert_eq!(
857            flatten_fqn("materialize.public.flippers"),
858            "\"materialize.public.flippers\""
859        );
860        assert_eq!(flatten_fqn("a.b.c"), "\"a.b.c\"");
861        assert_eq!(flatten_fqn("single"), "\"single\"");
862    }
863
864    #[mz_ore::test]
865    fn test_create_mock_view_sql() {
866        let mock = MockView {
867            fqn: "materialize.public.users".to_string(),
868            columns: vec![
869                ("id".to_string(), "BIGINT".to_string()),
870                ("name".to_string(), "TEXT".to_string()),
871            ],
872            query: "SELECT * FROM VALUES ((1, 'alice'))".to_string(),
873        };
874
875        let sql = create_mock_view_sql(&mock);
876
877        assert!(sql.contains("CREATE TEMPORARY VIEW \"materialize.public.users\""));
878        assert!(sql.contains("WITH MUTUALLY RECURSIVE data(id BIGINT, name TEXT)"));
879        assert!(sql.contains("SELECT * FROM VALUES ((1, 'alice'))"));
880        assert!(sql.contains("SELECT * FROM data"));
881    }
882
883    #[mz_ore::test]
884    fn test_create_expected_view_sql() {
885        let expected = ExpectedResult {
886            columns: vec![
887                ("id".to_string(), "BIGINT".to_string()),
888                ("count".to_string(), "INT".to_string()),
889            ],
890            query: "SELECT * FROM VALUES ((1, 10))".to_string(),
891        };
892
893        let sql = create_expected_view_sql(&expected);
894
895        assert!(sql.contains("CREATE TEMPORARY VIEW expected"));
896        assert!(sql.contains("WITH MUTUALLY RECURSIVE data(id BIGINT, count INT)"));
897        assert!(sql.contains("SELECT * FROM VALUES ((1, 10))"));
898        assert!(sql.contains("SELECT * FROM data"));
899    }
900
901    #[mz_ore::test]
902    fn test_create_test_query_sql() {
903        let sql = create_test_query_sql("materialize_public_my_view", None);
904
905        assert!(sql.contains("SELECT 'MISSING' as status, * FROM expected"));
906        assert!(sql.contains("SELECT 'MISSING', * FROM materialize_public_my_view"));
907        assert!(sql.contains("SELECT 'UNEXPECTED' as status, * FROM materialize_public_my_view"));
908        assert!(sql.contains("SELECT 'UNEXPECTED', * FROM expected"));
909        assert!(sql.contains("UNION ALL"));
910        assert!(sql.contains("EXCEPT"));
911        assert!(!sql.contains("AS OF"));
912    }
913
914    #[mz_ore::test]
915    fn test_create_test_query_sql_with_at_time() {
916        let sql =
917            create_test_query_sql("materialize_public_my_view", Some("'2024-01-15 10:00:00'"));
918
919        assert!(sql.contains("SELECT 'MISSING' as status, * FROM expected"));
920        assert!(sql.contains("AS OF '2024-01-15 10:00:00'::mz_timestamp"));
921    }
922
923    fn make_test_types() -> Types {
924        let mut objects = BTreeMap::new();
925
926        let mut users_cols = BTreeMap::new();
927        users_cols.insert(
928            "id".to_string(),
929            ColumnType {
930                r#type: "bigint".to_string(),
931                nullable: false,
932                position: 0,
933                comment: None,
934            },
935        );
936        users_cols.insert(
937            "name".to_string(),
938            ColumnType {
939                r#type: "text".to_string(),
940                nullable: true,
941                position: 1,
942                comment: None,
943            },
944        );
945        users_cols.insert(
946            "email".to_string(),
947            ColumnType {
948                r#type: "text".to_string(),
949                nullable: true,
950                position: 2,
951                comment: None,
952            },
953        );
954        objects.insert(
955            "materialize.public.users".parse::<ObjectId>().unwrap(),
956            users_cols,
957        );
958
959        let mut orders_cols = BTreeMap::new();
960        orders_cols.insert(
961            "id".to_string(),
962            ColumnType {
963                r#type: "bigint".to_string(),
964                nullable: false,
965                position: 0,
966                comment: None,
967            },
968        );
969        orders_cols.insert(
970            "user_id".to_string(),
971            ColumnType {
972                r#type: "bigint".to_string(),
973                nullable: false,
974                position: 1,
975                comment: None,
976            },
977        );
978        orders_cols.insert(
979            "amount".to_string(),
980            ColumnType {
981                r#type: "numeric".to_string(),
982                nullable: true,
983                position: 2,
984                comment: None,
985            },
986        );
987        objects.insert(
988            "materialize.public.orders".parse::<ObjectId>().unwrap(),
989            orders_cols,
990        );
991
992        let mut summary_cols = BTreeMap::new();
993        summary_cols.insert(
994            "user_id".to_string(),
995            ColumnType {
996                r#type: "bigint".to_string(),
997                nullable: false,
998                position: 0,
999                comment: None,
1000            },
1001        );
1002        summary_cols.insert(
1003            "user_name".to_string(),
1004            ColumnType {
1005                r#type: "text".to_string(),
1006                nullable: true,
1007                position: 1,
1008                comment: None,
1009            },
1010        );
1011        summary_cols.insert(
1012            "total_orders".to_string(),
1013            ColumnType {
1014                r#type: "bigint".to_string(),
1015                nullable: true,
1016                position: 2,
1017                comment: None,
1018            },
1019        );
1020        objects.insert(
1021            "materialize.public.user_order_summary"
1022                .parse::<ObjectId>()
1023                .unwrap(),
1024            summary_cols,
1025        );
1026
1027        Types {
1028            version: 1,
1029            tables: objects,
1030            kinds: BTreeMap::new(),
1031            comments: BTreeMap::new(),
1032        }
1033    }
1034
1035    fn make_target_id() -> ObjectId {
1036        ObjectId::new(
1037            "materialize".to_string(),
1038            "public".to_string(),
1039            "user_order_summary".to_string(),
1040        )
1041    }
1042
1043    fn make_dependencies() -> BTreeSet<ObjectId> {
1044        let mut deps = BTreeSet::new();
1045        deps.insert(ObjectId::new(
1046            "materialize".to_string(),
1047            "public".to_string(),
1048            "users".to_string(),
1049        ));
1050        deps.insert(ObjectId::new(
1051            "materialize".to_string(),
1052            "public".to_string(),
1053            "orders".to_string(),
1054        ));
1055        deps
1056    }
1057
1058    #[mz_ore::test]
1059    fn test_validate_unit_test_passes_with_correct_mocks() {
1060        let test = UnitTest {
1061            name: "test_user_summary".to_string(),
1062            target_view: "materialize.public.user_order_summary".to_string(),
1063            at_time: None,
1064            mocks: vec![
1065                MockView {
1066                    fqn: "materialize.public.users".to_string(),
1067                    columns: vec![
1068                        ("id".to_string(), "bigint".to_string()),
1069                        ("name".to_string(), "text".to_string()),
1070                        ("email".to_string(), "text".to_string()),
1071                    ],
1072                    query: "SELECT * FROM VALUES (1, 'alice', 'alice@example.com')".to_string(),
1073                },
1074                MockView {
1075                    fqn: "materialize.public.orders".to_string(),
1076                    columns: vec![
1077                        ("id".to_string(), "bigint".to_string()),
1078                        ("user_id".to_string(), "bigint".to_string()),
1079                        ("amount".to_string(), "numeric".to_string()),
1080                    ],
1081                    query: "SELECT * FROM VALUES (1, 1, 100.00)".to_string(),
1082                },
1083            ],
1084            expected: ExpectedResult {
1085                columns: vec![
1086                    ("user_id".to_string(), "bigint".to_string()),
1087                    ("user_name".to_string(), "text".to_string()),
1088                    ("total_orders".to_string(), "bigint".to_string()),
1089                ],
1090                query: "SELECT * FROM VALUES (1, 'alice', 1)".to_string(),
1091            },
1092        };
1093
1094        let types = make_test_types();
1095        let target_id = make_target_id();
1096        let dependencies = make_dependencies();
1097
1098        let result = validate_unit_test(
1099            &test,
1100            &target_id,
1101            &|fqn| types.get_table(fqn).cloned(),
1102            &dependencies,
1103        );
1104        assert!(result.is_ok(), "Expected validation to pass: {:?}", result);
1105    }
1106
1107    #[mz_ore::test]
1108    fn test_validate_unit_test_fails_with_unmocked_dependency() {
1109        let test = UnitTest {
1110            name: "test_user_summary".to_string(),
1111            target_view: "materialize.public.user_order_summary".to_string(),
1112            at_time: None,
1113            mocks: vec![MockView {
1114                fqn: "materialize.public.users".to_string(),
1115                columns: vec![
1116                    ("id".to_string(), "bigint".to_string()),
1117                    ("name".to_string(), "text".to_string()),
1118                    ("email".to_string(), "text".to_string()),
1119                ],
1120                query: "SELECT * FROM VALUES (1, 'alice', 'alice@example.com')".to_string(),
1121            }],
1122            expected: ExpectedResult {
1123                columns: vec![
1124                    ("user_id".to_string(), "bigint".to_string()),
1125                    ("user_name".to_string(), "text".to_string()),
1126                    ("total_orders".to_string(), "bigint".to_string()),
1127                ],
1128                query: "SELECT * FROM VALUES (1, 'alice', 1)".to_string(),
1129            },
1130        };
1131
1132        let types = make_test_types();
1133        let target_id = make_target_id();
1134        let dependencies = make_dependencies();
1135
1136        let result = validate_unit_test(
1137            &test,
1138            &target_id,
1139            &|fqn| types.get_table(fqn).cloned(),
1140            &dependencies,
1141        );
1142        assert!(result.is_err());
1143
1144        match result.unwrap_err() {
1145            TestValidationError::UnmockedDependency(err) => {
1146                assert_eq!(err.test_name, "test_user_summary");
1147                assert!(
1148                    err.missing_mocks
1149                        .contains(&"materialize.public.orders".to_string())
1150                );
1151            }
1152            other => panic!("Expected UnmockedDependency error, got: {:?}", other),
1153        }
1154    }
1155
1156    #[mz_ore::test]
1157    fn test_validate_unit_test_fails_with_missing_mock_column() {
1158        let test = UnitTest {
1159            name: "test_user_summary".to_string(),
1160            target_view: "materialize.public.user_order_summary".to_string(),
1161            at_time: None,
1162            mocks: vec![
1163                MockView {
1164                    fqn: "materialize.public.users".to_string(),
1165                    columns: vec![
1166                        ("id".to_string(), "bigint".to_string()),
1167                        ("name".to_string(), "text".to_string()),
1168                    ],
1169                    query: "SELECT * FROM VALUES (1, 'alice')".to_string(),
1170                },
1171                MockView {
1172                    fqn: "materialize.public.orders".to_string(),
1173                    columns: vec![
1174                        ("id".to_string(), "bigint".to_string()),
1175                        ("user_id".to_string(), "bigint".to_string()),
1176                        ("amount".to_string(), "numeric".to_string()),
1177                    ],
1178                    query: "SELECT * FROM VALUES (1, 1, 100.00)".to_string(),
1179                },
1180            ],
1181            expected: ExpectedResult {
1182                columns: vec![
1183                    ("user_id".to_string(), "bigint".to_string()),
1184                    ("user_name".to_string(), "text".to_string()),
1185                    ("total_orders".to_string(), "bigint".to_string()),
1186                ],
1187                query: "SELECT * FROM VALUES (1, 'alice', 1)".to_string(),
1188            },
1189        };
1190
1191        let types = make_test_types();
1192        let target_id = make_target_id();
1193        let dependencies = make_dependencies();
1194
1195        let result = validate_unit_test(
1196            &test,
1197            &target_id,
1198            &|fqn| types.get_table(fqn).cloned(),
1199            &dependencies,
1200        );
1201        assert!(result.is_err());
1202
1203        match result.unwrap_err() {
1204            TestValidationError::MockSchemaMismatch(err) => {
1205                assert_eq!(err.test_name, "test_user_summary");
1206                assert_eq!(err.mock_fqn, "materialize.public.users");
1207                assert!(err.missing_columns.iter().any(|(name, _)| name == "email"));
1208                assert!(err.extra_columns.is_empty());
1209            }
1210            other => panic!("Expected MockSchemaMismatch error, got: {:?}", other),
1211        }
1212    }
1213
1214    #[mz_ore::test]
1215    fn test_validate_unit_test_fails_with_extra_mock_column() {
1216        let test = UnitTest {
1217            name: "test_user_summary".to_string(),
1218            target_view: "materialize.public.user_order_summary".to_string(),
1219            at_time: None,
1220            mocks: vec![
1221                MockView {
1222                    fqn: "materialize.public.users".to_string(),
1223                    columns: vec![
1224                        ("id".to_string(), "bigint".to_string()),
1225                        ("name".to_string(), "text".to_string()),
1226                        ("email".to_string(), "text".to_string()),
1227                        ("extra_column".to_string(), "int".to_string()),
1228                    ],
1229                    query: "SELECT * FROM VALUES (1, 'alice', 'alice@example.com', 42)".to_string(),
1230                },
1231                MockView {
1232                    fqn: "materialize.public.orders".to_string(),
1233                    columns: vec![
1234                        ("id".to_string(), "bigint".to_string()),
1235                        ("user_id".to_string(), "bigint".to_string()),
1236                        ("amount".to_string(), "numeric".to_string()),
1237                    ],
1238                    query: "SELECT * FROM VALUES (1, 1, 100.00)".to_string(),
1239                },
1240            ],
1241            expected: ExpectedResult {
1242                columns: vec![
1243                    ("user_id".to_string(), "bigint".to_string()),
1244                    ("user_name".to_string(), "text".to_string()),
1245                    ("total_orders".to_string(), "bigint".to_string()),
1246                ],
1247                query: "SELECT * FROM VALUES (1, 'alice', 1)".to_string(),
1248            },
1249        };
1250
1251        let types = make_test_types();
1252        let target_id = make_target_id();
1253        let dependencies = make_dependencies();
1254
1255        let result = validate_unit_test(
1256            &test,
1257            &target_id,
1258            &|fqn| types.get_table(fqn).cloned(),
1259            &dependencies,
1260        );
1261        assert!(result.is_err());
1262
1263        match result.unwrap_err() {
1264            TestValidationError::MockSchemaMismatch(err) => {
1265                assert_eq!(err.mock_fqn, "materialize.public.users");
1266                assert!(err.extra_columns.contains(&"extra_column".to_string()));
1267                assert!(err.missing_columns.is_empty());
1268            }
1269            other => panic!("Expected MockSchemaMismatch error, got: {:?}", other),
1270        }
1271    }
1272
1273    #[mz_ore::test]
1274    fn test_validate_unit_test_fails_with_type_mismatch() {
1275        let test = UnitTest {
1276            name: "test_user_summary".to_string(),
1277            target_view: "materialize.public.user_order_summary".to_string(),
1278            at_time: None,
1279            mocks: vec![
1280                MockView {
1281                    fqn: "materialize.public.users".to_string(),
1282                    columns: vec![
1283                        ("id".to_string(), "text".to_string()),
1284                        ("name".to_string(), "text".to_string()),
1285                        ("email".to_string(), "text".to_string()),
1286                    ],
1287                    query: "SELECT * FROM VALUES ('1', 'alice', 'alice@example.com')".to_string(),
1288                },
1289                MockView {
1290                    fqn: "materialize.public.orders".to_string(),
1291                    columns: vec![
1292                        ("id".to_string(), "bigint".to_string()),
1293                        ("user_id".to_string(), "bigint".to_string()),
1294                        ("amount".to_string(), "numeric".to_string()),
1295                    ],
1296                    query: "SELECT * FROM VALUES (1, 1, 100.00)".to_string(),
1297                },
1298            ],
1299            expected: ExpectedResult {
1300                columns: vec![
1301                    ("user_id".to_string(), "bigint".to_string()),
1302                    ("user_name".to_string(), "text".to_string()),
1303                    ("total_orders".to_string(), "bigint".to_string()),
1304                ],
1305                query: "SELECT * FROM VALUES (1, 'alice', 1)".to_string(),
1306            },
1307        };
1308
1309        let types = make_test_types();
1310        let target_id = make_target_id();
1311        let dependencies = make_dependencies();
1312
1313        let result = validate_unit_test(
1314            &test,
1315            &target_id,
1316            &|fqn| types.get_table(fqn).cloned(),
1317            &dependencies,
1318        );
1319        assert!(result.is_err());
1320
1321        match result.unwrap_err() {
1322            TestValidationError::MockSchemaMismatch(err) => {
1323                assert_eq!(err.mock_fqn, "materialize.public.users");
1324                assert!(
1325                    err.type_mismatches
1326                        .iter()
1327                        .any(|(col, mock_t, _)| { col == "id" && mock_t == "text" })
1328                );
1329            }
1330            other => panic!("Expected MockSchemaMismatch error, got: {:?}", other),
1331        }
1332    }
1333
1334    #[mz_ore::test]
1335    fn test_validate_unit_test_fails_with_expected_schema_mismatch() {
1336        let test = UnitTest {
1337            name: "test_user_summary".to_string(),
1338            target_view: "materialize.public.user_order_summary".to_string(),
1339            at_time: None,
1340            mocks: vec![
1341                MockView {
1342                    fqn: "materialize.public.users".to_string(),
1343                    columns: vec![
1344                        ("id".to_string(), "bigint".to_string()),
1345                        ("name".to_string(), "text".to_string()),
1346                        ("email".to_string(), "text".to_string()),
1347                    ],
1348                    query: "SELECT * FROM VALUES (1, 'alice', 'alice@example.com')".to_string(),
1349                },
1350                MockView {
1351                    fqn: "materialize.public.orders".to_string(),
1352                    columns: vec![
1353                        ("id".to_string(), "bigint".to_string()),
1354                        ("user_id".to_string(), "bigint".to_string()),
1355                        ("amount".to_string(), "numeric".to_string()),
1356                    ],
1357                    query: "SELECT * FROM VALUES (1, 1, 100.00)".to_string(),
1358                },
1359            ],
1360            expected: ExpectedResult {
1361                columns: vec![
1362                    ("user_id".to_string(), "bigint".to_string()),
1363                    ("total_orders".to_string(), "bigint".to_string()),
1364                ],
1365                query: "SELECT * FROM VALUES (1, 1)".to_string(),
1366            },
1367        };
1368
1369        let types = make_test_types();
1370        let target_id = make_target_id();
1371        let dependencies = make_dependencies();
1372
1373        let result = validate_unit_test(
1374            &test,
1375            &target_id,
1376            &|fqn| types.get_table(fqn).cloned(),
1377            &dependencies,
1378        );
1379        assert!(result.is_err());
1380
1381        match result.unwrap_err() {
1382            TestValidationError::ExpectedSchemaMismatch(err) => {
1383                assert_eq!(err.test_name, "test_user_summary");
1384                assert_eq!(err.target_view, "materialize.public.user_order_summary");
1385                assert!(
1386                    err.missing_columns
1387                        .iter()
1388                        .any(|(name, _)| name == "user_name")
1389                );
1390            }
1391            other => panic!("Expected ExpectedSchemaMismatch error, got: {:?}", other),
1392        }
1393    }
1394
1395    #[mz_ore::test]
1396    fn test_validate_unit_test_fails_with_expected_type_mismatch() {
1397        let test = UnitTest {
1398            name: "test_user_summary".to_string(),
1399            target_view: "materialize.public.user_order_summary".to_string(),
1400            at_time: None,
1401            mocks: vec![
1402                MockView {
1403                    fqn: "materialize.public.users".to_string(),
1404                    columns: vec![
1405                        ("id".to_string(), "bigint".to_string()),
1406                        ("name".to_string(), "text".to_string()),
1407                        ("email".to_string(), "text".to_string()),
1408                    ],
1409                    query: "SELECT * FROM VALUES (1, 'alice', 'alice@example.com')".to_string(),
1410                },
1411                MockView {
1412                    fqn: "materialize.public.orders".to_string(),
1413                    columns: vec![
1414                        ("id".to_string(), "bigint".to_string()),
1415                        ("user_id".to_string(), "bigint".to_string()),
1416                        ("amount".to_string(), "numeric".to_string()),
1417                    ],
1418                    query: "SELECT * FROM VALUES (1, 1, 100.00)".to_string(),
1419                },
1420            ],
1421            expected: ExpectedResult {
1422                columns: vec![
1423                    ("user_id".to_string(), "bigint".to_string()),
1424                    ("user_name".to_string(), "bigint".to_string()),
1425                    ("total_orders".to_string(), "bigint".to_string()),
1426                ],
1427                query: "SELECT * FROM VALUES (1, 1, 1)".to_string(),
1428            },
1429        };
1430
1431        let types = make_test_types();
1432        let target_id = make_target_id();
1433        let dependencies = make_dependencies();
1434
1435        let result = validate_unit_test(
1436            &test,
1437            &target_id,
1438            &|fqn| types.get_table(fqn).cloned(),
1439            &dependencies,
1440        );
1441        assert!(result.is_err());
1442
1443        match result.unwrap_err() {
1444            TestValidationError::ExpectedSchemaMismatch(err) => {
1445                assert!(
1446                    err.type_mismatches
1447                        .iter()
1448                        .any(|(col, exp_t, _)| { col == "user_name" && exp_t == "bigint" })
1449                );
1450            }
1451            other => panic!("Expected ExpectedSchemaMismatch error, got: {:?}", other),
1452        }
1453    }
1454
1455    #[mz_ore::test]
1456    fn test_normalize_fqn_unqualified() {
1457        let target_id = ObjectId::new(
1458            "mydb".to_string(),
1459            "myschema".to_string(),
1460            "myview".to_string(),
1461        );
1462
1463        let normalized = normalize_fqn("users", &target_id);
1464        assert_eq!(normalized.to_string(), "mydb.myschema.users");
1465    }
1466
1467    #[mz_ore::test]
1468    fn test_normalize_fqn_schema_qualified() {
1469        let target_id = ObjectId::new(
1470            "mydb".to_string(),
1471            "myschema".to_string(),
1472            "myview".to_string(),
1473        );
1474
1475        let normalized = normalize_fqn("other_schema.users", &target_id);
1476        assert_eq!(normalized.to_string(), "mydb.other_schema.users");
1477    }
1478
1479    #[mz_ore::test]
1480    fn test_normalize_fqn_fully_qualified() {
1481        let target_id = ObjectId::new(
1482            "mydb".to_string(),
1483            "myschema".to_string(),
1484            "myview".to_string(),
1485        );
1486
1487        let normalized = normalize_fqn("other_db.other_schema.users", &target_id);
1488        assert_eq!(normalized.to_string(), "other_db.other_schema.users");
1489    }
1490
1491    #[mz_ore::test]
1492    fn test_normalize_type_integer_aliases() {
1493        assert_eq!(normalize_type("INT"), "integer");
1494        assert_eq!(normalize_type("int4"), "integer");
1495        assert_eq!(normalize_type("integer"), "integer");
1496        assert_eq!(normalize_type("INTEGER"), "integer");
1497    }
1498
1499    #[mz_ore::test]
1500    fn test_normalize_type_bigint_aliases() {
1501        assert_eq!(normalize_type("INT8"), "bigint");
1502        assert_eq!(normalize_type("bigint"), "bigint");
1503        assert_eq!(normalize_type("BIGINT"), "bigint");
1504    }
1505
1506    #[mz_ore::test]
1507    fn test_normalize_type_smallint_aliases() {
1508        assert_eq!(normalize_type("INT2"), "smallint");
1509        assert_eq!(normalize_type("smallint"), "smallint");
1510        assert_eq!(normalize_type("SMALLINT"), "smallint");
1511    }
1512
1513    #[mz_ore::test]
1514    fn test_normalize_type_real_aliases() {
1515        assert_eq!(normalize_type("float4"), "real");
1516        assert_eq!(normalize_type("FLOAT4"), "real");
1517        assert_eq!(normalize_type("real"), "real");
1518        assert_eq!(normalize_type("REAL"), "real");
1519    }
1520
1521    #[mz_ore::test]
1522    fn test_normalize_type_double_precision_aliases() {
1523        assert_eq!(normalize_type("float"), "double precision");
1524        assert_eq!(normalize_type("FLOAT"), "double precision");
1525        assert_eq!(normalize_type("float8"), "double precision");
1526        assert_eq!(normalize_type("FLOAT8"), "double precision");
1527        assert_eq!(normalize_type("double"), "double precision");
1528        assert_eq!(normalize_type("DOUBLE"), "double precision");
1529        assert_eq!(normalize_type("double precision"), "double precision");
1530        assert_eq!(normalize_type("DOUBLE PRECISION"), "double precision");
1531    }
1532
1533    #[mz_ore::test]
1534    fn test_normalize_type_boolean_aliases() {
1535        assert_eq!(normalize_type("bool"), "boolean");
1536        assert_eq!(normalize_type("boolean"), "boolean");
1537        assert_eq!(normalize_type("BOOL"), "boolean");
1538        assert_eq!(normalize_type("BOOLEAN"), "boolean");
1539    }
1540
1541    #[mz_ore::test]
1542    fn test_normalize_type_text_aliases() {
1543        assert_eq!(normalize_type("text"), "text");
1544        assert_eq!(normalize_type("TEXT"), "text");
1545        assert_eq!(normalize_type("string"), "text");
1546        assert_eq!(normalize_type("STRING"), "text");
1547        assert_eq!(normalize_type("varchar"), "text");
1548        assert_eq!(normalize_type("VARCHAR"), "text");
1549        assert_eq!(normalize_type("varchar(255)"), "text");
1550        assert_eq!(normalize_type("character varying"), "text");
1551        assert_eq!(normalize_type("character varying(100)"), "text");
1552    }
1553
1554    #[mz_ore::test]
1555    fn test_normalize_type_numeric_aliases() {
1556        assert_eq!(normalize_type("numeric"), "numeric");
1557        assert_eq!(normalize_type("NUMERIC"), "numeric");
1558        assert_eq!(normalize_type("decimal"), "numeric");
1559        assert_eq!(normalize_type("DECIMAL"), "numeric");
1560        assert_eq!(normalize_type("numeric(10,2)"), "numeric");
1561        assert_eq!(normalize_type("decimal(18,4)"), "numeric");
1562    }
1563
1564    #[mz_ore::test]
1565    fn test_normalize_type_jsonb_aliases() {
1566        assert_eq!(normalize_type("json"), "jsonb");
1567        assert_eq!(normalize_type("JSON"), "jsonb");
1568        assert_eq!(normalize_type("jsonb"), "jsonb");
1569        assert_eq!(normalize_type("JSONB"), "jsonb");
1570    }
1571
1572    #[mz_ore::test]
1573    fn test_normalize_type_timestamptz_aliases() {
1574        assert_eq!(normalize_type("timestamptz"), "timestamp with time zone");
1575        assert_eq!(normalize_type("TIMESTAMPTZ"), "timestamp with time zone");
1576        assert_eq!(
1577            normalize_type("timestamp with time zone"),
1578            "timestamp with time zone"
1579        );
1580        assert_eq!(
1581            normalize_type("TIMESTAMP WITH TIME ZONE"),
1582            "timestamp with time zone"
1583        );
1584    }
1585
1586    #[mz_ore::test]
1587    fn test_normalize_type_preserves_other_types() {
1588        assert_eq!(normalize_type("timestamp"), "timestamp without time zone");
1589        assert_eq!(normalize_type("TIMESTAMP"), "timestamp without time zone");
1590        assert_eq!(normalize_type("date"), "date");
1591        assert_eq!(normalize_type("time"), "time");
1592        assert_eq!(normalize_type("interval"), "interval");
1593        assert_eq!(normalize_type("uuid"), "uuid");
1594        assert_eq!(normalize_type("bytea"), "bytea");
1595        assert_eq!(normalize_type("oid"), "oid");
1596        assert_eq!(normalize_type("uint2"), "uint2");
1597        assert_eq!(normalize_type("uint4"), "uint4");
1598        assert_eq!(normalize_type("uint8"), "uint8");
1599    }
1600
1601    #[mz_ore::test]
1602    fn test_normalize_type_handles_whitespace() {
1603        assert_eq!(normalize_type("  INT  "), "integer");
1604        assert_eq!(normalize_type("\ttext\n"), "text");
1605        assert_eq!(normalize_type("  double precision  "), "double precision");
1606    }
1607
1608    #[mz_ore::test]
1609    fn test_normalize_type_case_insensitive() {
1610        assert_eq!(normalize_type("integer"), normalize_type("INTEGER"));
1611        assert_eq!(normalize_type("integer"), normalize_type("Integer"));
1612        assert_eq!(normalize_type("integer"), normalize_type("iNtEgEr"));
1613        assert_eq!(normalize_type("int"), normalize_type("INT"));
1614        assert_eq!(normalize_type("int"), normalize_type("Int"));
1615
1616        assert_eq!(normalize_type("bigint"), normalize_type("BIGINT"));
1617        assert_eq!(normalize_type("bigint"), normalize_type("BigInt"));
1618        assert_eq!(normalize_type("int8"), normalize_type("INT8"));
1619
1620        assert_eq!(normalize_type("text"), normalize_type("TEXT"));
1621        assert_eq!(normalize_type("text"), normalize_type("Text"));
1622        assert_eq!(normalize_type("string"), normalize_type("STRING"));
1623        assert_eq!(normalize_type("string"), normalize_type("String"));
1624
1625        assert_eq!(normalize_type("boolean"), normalize_type("BOOLEAN"));
1626        assert_eq!(normalize_type("boolean"), normalize_type("Boolean"));
1627        assert_eq!(normalize_type("bool"), normalize_type("BOOL"));
1628        assert_eq!(normalize_type("bool"), normalize_type("Bool"));
1629
1630        assert_eq!(normalize_type("numeric"), normalize_type("NUMERIC"));
1631        assert_eq!(normalize_type("numeric"), normalize_type("Numeric"));
1632        assert_eq!(normalize_type("decimal"), normalize_type("DECIMAL"));
1633
1634        assert_eq!(
1635            normalize_type("double precision"),
1636            normalize_type("DOUBLE PRECISION")
1637        );
1638        assert_eq!(
1639            normalize_type("double precision"),
1640            normalize_type("Double Precision")
1641        );
1642
1643        assert_eq!(
1644            normalize_type("timestamp with time zone"),
1645            normalize_type("TIMESTAMP WITH TIME ZONE")
1646        );
1647        assert_eq!(normalize_type("timestamptz"), normalize_type("TIMESTAMPTZ"));
1648        assert_eq!(normalize_type("timestamptz"), normalize_type("TimestampTZ"));
1649
1650        assert_eq!(normalize_type("jsonb"), normalize_type("JSONB"));
1651        assert_eq!(normalize_type("jsonb"), normalize_type("JsonB"));
1652        assert_eq!(normalize_type("json"), normalize_type("JSON"));
1653    }
1654
1655    #[mz_ore::test]
1656    fn test_compare_columns_exact_match() {
1657        let test_columns = vec![
1658            ("id".to_string(), "bigint".to_string()),
1659            ("name".to_string(), "text".to_string()),
1660        ];
1661
1662        let mut actual_columns = BTreeMap::new();
1663        actual_columns.insert(
1664            "id".to_string(),
1665            ColumnType {
1666                r#type: "bigint".to_string(),
1667                nullable: false,
1668                position: 0,
1669                comment: None,
1670            },
1671        );
1672        actual_columns.insert(
1673            "name".to_string(),
1674            ColumnType {
1675                r#type: "text".to_string(),
1676                nullable: true,
1677                position: 0,
1678                comment: None,
1679            },
1680        );
1681
1682        let (extra, missing, type_mismatches) = compare_columns(&test_columns, &actual_columns);
1683        assert!(extra.is_empty());
1684        assert!(missing.is_empty());
1685        assert!(type_mismatches.is_empty());
1686    }
1687
1688    #[mz_ore::test]
1689    fn test_compare_columns_with_type_aliases() {
1690        let test_columns = vec![
1691            ("id".to_string(), "INT".to_string()),
1692            ("count".to_string(), "INT8".to_string()),
1693        ];
1694
1695        let mut actual_columns = BTreeMap::new();
1696        actual_columns.insert(
1697            "id".to_string(),
1698            ColumnType {
1699                r#type: "integer".to_string(),
1700                nullable: false,
1701                position: 0,
1702                comment: None,
1703            },
1704        );
1705        actual_columns.insert(
1706            "count".to_string(),
1707            ColumnType {
1708                r#type: "bigint".to_string(),
1709                nullable: false,
1710                position: 0,
1711                comment: None,
1712            },
1713        );
1714
1715        let (extra, missing, type_mismatches) = compare_columns(&test_columns, &actual_columns);
1716        assert!(extra.is_empty());
1717        assert!(missing.is_empty());
1718        assert!(type_mismatches.is_empty());
1719    }
1720
1721    #[mz_ore::test]
1722    fn test_compare_columns_detects_extra() {
1723        let test_columns = vec![
1724            ("id".to_string(), "bigint".to_string()),
1725            ("extra".to_string(), "text".to_string()),
1726        ];
1727
1728        let mut actual_columns = BTreeMap::new();
1729        actual_columns.insert(
1730            "id".to_string(),
1731            ColumnType {
1732                r#type: "bigint".to_string(),
1733                nullable: false,
1734                position: 0,
1735                comment: None,
1736            },
1737        );
1738
1739        let (extra, missing, _) = compare_columns(&test_columns, &actual_columns);
1740        assert_eq!(extra, vec!["extra".to_string()]);
1741        assert!(missing.is_empty());
1742    }
1743
1744    #[mz_ore::test]
1745    fn test_compare_columns_detects_missing() {
1746        let test_columns = vec![("id".to_string(), "bigint".to_string())];
1747
1748        let mut actual_columns = BTreeMap::new();
1749        actual_columns.insert(
1750            "id".to_string(),
1751            ColumnType {
1752                r#type: "bigint".to_string(),
1753                nullable: false,
1754                position: 0,
1755                comment: None,
1756            },
1757        );
1758        actual_columns.insert(
1759            "name".to_string(),
1760            ColumnType {
1761                r#type: "text".to_string(),
1762                nullable: true,
1763                position: 0,
1764                comment: None,
1765            },
1766        );
1767
1768        let (extra, missing, _) = compare_columns(&test_columns, &actual_columns);
1769        assert!(extra.is_empty());
1770        assert_eq!(missing, vec![("name".to_string(), "text".to_string())]);
1771    }
1772
1773    #[mz_ore::test]
1774    fn test_compare_columns_detects_type_mismatch() {
1775        let test_columns = vec![("id".to_string(), "text".to_string())];
1776
1777        let mut actual_columns = BTreeMap::new();
1778        actual_columns.insert(
1779            "id".to_string(),
1780            ColumnType {
1781                r#type: "bigint".to_string(),
1782                nullable: false,
1783                position: 0,
1784                comment: None,
1785            },
1786        );
1787
1788        let (_, _, type_mismatches) = compare_columns(&test_columns, &actual_columns);
1789        assert_eq!(type_mismatches.len(), 1);
1790        assert_eq!(type_mismatches[0].0, "id");
1791        assert_eq!(type_mismatches[0].1, "text");
1792        assert_eq!(type_mismatches[0].2, "bigint");
1793    }
1794
1795    #[mz_ore::test]
1796    fn test_validate_with_unqualified_mock_name() {
1797        let test = UnitTest {
1798            name: "test_partial_fqn".to_string(),
1799            target_view: "materialize.public.user_order_summary".to_string(),
1800            at_time: None,
1801            mocks: vec![
1802                MockView {
1803                    fqn: "users".to_string(),
1804                    columns: vec![
1805                        ("id".to_string(), "bigint".to_string()),
1806                        ("name".to_string(), "text".to_string()),
1807                        ("email".to_string(), "text".to_string()),
1808                    ],
1809                    query: "SELECT * FROM VALUES (1, 'alice', 'alice@example.com')".to_string(),
1810                },
1811                MockView {
1812                    fqn: "public.orders".to_string(),
1813                    columns: vec![
1814                        ("id".to_string(), "bigint".to_string()),
1815                        ("user_id".to_string(), "bigint".to_string()),
1816                        ("amount".to_string(), "numeric".to_string()),
1817                    ],
1818                    query: "SELECT * FROM VALUES (1, 1, 100.00)".to_string(),
1819                },
1820            ],
1821            expected: ExpectedResult {
1822                columns: vec![
1823                    ("user_id".to_string(), "bigint".to_string()),
1824                    ("user_name".to_string(), "text".to_string()),
1825                    ("total_orders".to_string(), "bigint".to_string()),
1826                ],
1827                query: "SELECT * FROM VALUES (1, 'alice', 1)".to_string(),
1828            },
1829        };
1830
1831        let types = make_test_types();
1832        let target_id = make_target_id();
1833        let dependencies = make_dependencies();
1834
1835        let result = validate_unit_test(
1836            &test,
1837            &target_id,
1838            &|fqn| types.get_table(fqn).cloned(),
1839            &dependencies,
1840        );
1841        assert!(result.is_ok(), "Expected validation to pass: {:?}", result);
1842    }
1843
1844    #[mz_ore::test]
1845    fn test_validate_passes_with_no_dependencies() {
1846        let test = UnitTest {
1847            name: "test_no_deps".to_string(),
1848            target_view: "materialize.public.my_view".to_string(),
1849            at_time: None,
1850            mocks: vec![],
1851            expected: ExpectedResult {
1852                columns: vec![("result".to_string(), "integer".to_string())],
1853                query: "SELECT * FROM VALUES (42)".to_string(),
1854            },
1855        };
1856
1857        let types = Types::default();
1858        let target_id = ObjectId::new(
1859            "materialize".to_string(),
1860            "public".to_string(),
1861            "my_view".to_string(),
1862        );
1863        let dependencies = BTreeSet::new();
1864
1865        let result = validate_unit_test(
1866            &test,
1867            &target_id,
1868            &|fqn| types.get_table(fqn).cloned(),
1869            &dependencies,
1870        );
1871        assert!(result.is_ok());
1872    }
1873
1874    #[mz_ore::test]
1875    fn test_validate_skips_unknown_mock() {
1876        let test = UnitTest {
1877            name: "test_unknown_mock".to_string(),
1878            target_view: "materialize.public.my_view".to_string(),
1879            at_time: None,
1880            mocks: vec![MockView {
1881                fqn: "materialize.public.unknown_table".to_string(),
1882                columns: vec![("id".to_string(), "bigint".to_string())],
1883                query: "SELECT * FROM VALUES (1)".to_string(),
1884            }],
1885            expected: ExpectedResult {
1886                columns: vec![("result".to_string(), "integer".to_string())],
1887                query: "SELECT * FROM VALUES (42)".to_string(),
1888            },
1889        };
1890
1891        let types = Types::default();
1892        let target_id = ObjectId::new(
1893            "materialize".to_string(),
1894            "public".to_string(),
1895            "my_view".to_string(),
1896        );
1897
1898        let mut dependencies = BTreeSet::new();
1899        dependencies.insert(ObjectId::new(
1900            "materialize".to_string(),
1901            "public".to_string(),
1902            "unknown_table".to_string(),
1903        ));
1904
1905        let result = validate_unit_test(
1906            &test,
1907            &target_id,
1908            &|fqn| types.get_table(fqn).cloned(),
1909            &dependencies,
1910        );
1911        assert!(result.is_ok());
1912    }
1913
1914    #[mz_ore::test]
1915    fn test_normalize_type_list() {
1916        assert_eq!(normalize_type("int8 list"), "bigint list");
1917        assert_eq!(normalize_type("INT LIST"), "integer list");
1918        assert_eq!(normalize_type("text list"), "text list");
1919        assert_eq!(normalize_type("INT8 LIST"), "bigint list");
1920    }
1921
1922    #[mz_ore::test]
1923    fn test_normalize_type_array() {
1924        assert_eq!(normalize_type("int8[]"), "bigint[]");
1925        assert_eq!(normalize_type("INT[]"), "integer[]");
1926        assert_eq!(normalize_type("text[]"), "text[]");
1927    }
1928
1929    #[mz_ore::test]
1930    fn test_normalize_type_map() {
1931        assert_eq!(normalize_type("map[text=>int8]"), "map[text=>bigint]");
1932        assert_eq!(normalize_type("map[STRING=>BOOL]"), "map[text=>boolean]");
1933    }
1934
1935    #[mz_ore::test]
1936    fn test_normalize_type_bare_list() {
1937        assert_eq!(normalize_type("list"), "list");
1938        assert_eq!(normalize_type("LIST"), "list");
1939    }
1940
1941    #[mz_ore::test]
1942    fn test_compare_columns_list_matches_bare() {
1943        let test_columns = vec![("ids".to_string(), "int8 list".to_string())];
1944
1945        let mut actual_columns = BTreeMap::new();
1946        actual_columns.insert(
1947            "ids".to_string(),
1948            ColumnType {
1949                r#type: "list".to_string(),
1950                nullable: true,
1951                position: 0,
1952                comment: None,
1953            },
1954        );
1955
1956        let (extra, missing, type_mismatches) = compare_columns(&test_columns, &actual_columns);
1957        assert!(extra.is_empty());
1958        assert!(missing.is_empty());
1959        assert!(
1960            type_mismatches.is_empty(),
1961            "Expected no type mismatches for 'int8 list' vs bare 'list', got: {:?}",
1962            type_mismatches
1963        );
1964    }
1965
1966    #[mz_ore::test]
1967    fn test_compare_columns_map_matches_bare() {
1968        let test_columns = vec![("data".to_string(), "map[text=>int8]".to_string())];
1969
1970        let mut actual_columns = BTreeMap::new();
1971        actual_columns.insert(
1972            "data".to_string(),
1973            ColumnType {
1974                r#type: "map".to_string(),
1975                nullable: true,
1976                position: 0,
1977                comment: None,
1978            },
1979        );
1980
1981        let (_, _, type_mismatches) = compare_columns(&test_columns, &actual_columns);
1982        assert!(
1983            type_mismatches.is_empty(),
1984            "Expected no type mismatches for 'map[text=>int8]' vs bare 'map', got: {:?}",
1985            type_mismatches
1986        );
1987    }
1988}