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.to_string()))
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.to_string()))
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.to_string())
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_type = actual.r#type.to_string();
580                let actual_normalized = normalize_type(&actual_type);
581
582                if test_normalized != actual_normalized {
583                    // A test file may spell a container bare, and so may a
584                    // lock file captured before element types were recorded.
585                    if types_match_with_bare_containers(&test_normalized, &actual_normalized) {
586                        None
587                    } else {
588                        Some((name.clone(), test_type.clone(), actual_type))
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/// A bare container (`list` rather than `int8 list`) names the container
603/// without its element type, so it matches any parameterized variant.
604fn types_match_with_bare_containers(a: &str, b: &str) -> bool {
605    if a == "list" && b.ends_with(" list") || b == "list" && a.ends_with(" list") {
606        return true;
607    }
608    if a == "[]" && b.ends_with("[]") || b == "[]" && a.ends_with("[]") {
609        return true;
610    }
611    if a == "map" && b.starts_with("map[") || b == "map" && a.starts_with("map[") {
612        return true;
613    }
614    false
615}
616
617/// Normalize a SQL type for comparison.
618///
619/// Handles Materialize type aliases so that equivalent types compare equal.
620/// See: <https://materialize.com/docs/sql/types/>
621fn normalize_type(t: &str) -> String {
622    let normalized = t.trim().to_lowercase();
623
624    if let Some(element) = normalized.strip_suffix(" list") {
625        if !element.is_empty() {
626            return format!("{} list", normalize_type(element));
627        }
628    }
629
630    if let Some(element) = normalized.strip_suffix("[]") {
631        if !element.is_empty() {
632            return format!("{}[]", normalize_type(element));
633        }
634    }
635
636    if let Some(inner) = normalized
637        .strip_prefix("map[")
638        .and_then(|s| s.strip_suffix(']'))
639    {
640        if let Some((key, value)) = inner.split_once("=>") {
641            return format!("map[{}=>{}]", normalize_type(key), normalize_type(value));
642        }
643    }
644
645    match normalized.as_str() {
646        "int" | "int4" | "integer" => "integer".to_string(),
647        "int8" | "bigint" => "bigint".to_string(),
648        "int2" | "smallint" => "smallint".to_string(),
649
650        "float4" | "real" => "real".to_string(),
651        "float" | "float8" | "double" | "double precision" => "double precision".to_string(),
652
653        "bool" | "boolean" => "boolean".to_string(),
654
655        "string" | "text" => "text".to_string(),
656        "varchar" | "character varying" => "text".to_string(),
657
658        "decimal" | "numeric" => "numeric".to_string(),
659
660        "json" | "jsonb" => "jsonb".to_string(),
661
662        "timestamp" | "timestamp without time zone" => "timestamp without time zone".to_string(),
663        "timestamptz" | "timestamp with time zone" => "timestamp with time zone".to_string(),
664
665        _ => {
666            if normalized.starts_with("varchar") || normalized.starts_with("character varying") {
667                "text".to_string()
668            } else if normalized.starts_with("numeric") || normalized.starts_with("decimal") {
669                "numeric".to_string()
670            } else if normalized.starts_with("timestamp with time zone")
671                || normalized.starts_with("timestamptz")
672            {
673                "timestamp with time zone".to_string()
674            } else if normalized.starts_with("timestamp without time zone")
675                || normalized == "timestamp"
676            {
677                "timestamp without time zone".to_string()
678            } else {
679                normalized
680            }
681        }
682    }
683}
684
685/// Lower a unit test into executable SQL statements.
686///
687/// Returns a vector of SQL strings in order:
688/// 1. CREATE TEMPORARY VIEW for each mock
689/// 2. CREATE TEMPORARY VIEW for expected
690/// 3. CREATE TEMPORARY VIEW for the target (flattened)
691/// 4. Test query with status column
692pub(super) fn lower_unit_test(
693    test: &UnitTest,
694    target_stmt: &Statement,
695    target_fqn: &FullyQualifiedName,
696) -> Result<Vec<String>, String> {
697    let mut statements = Vec::new();
698
699    for mock in &test.mocks {
700        let qualified_mock = qualify_mock_name(mock, target_fqn);
701        statements.push(create_mock_view_sql(&qualified_mock));
702    }
703
704    statements.push(create_expected_view_sql(&test.expected));
705
706    statements.push(create_target_view_sql(target_stmt, target_fqn)?);
707
708    let target_fqn_str = format!(
709        "{}.{}.{}",
710        target_fqn.database(),
711        target_fqn.schema(),
712        target_fqn.object()
713    );
714    let flattened_target_name = flatten_fqn(&target_fqn_str);
715    statements.push(create_test_query_sql(
716        &flattened_target_name,
717        test.at_time.as_deref(),
718    ));
719
720    Ok(statements)
721}
722
723/// Quote a fully qualified name as a single identifier with dots.
724fn flatten_fqn(fqn: &str) -> String {
725    format!("\"{}\"", fqn)
726}
727
728/// Qualify a mock name with the target's FQN context if it's not already qualified.
729fn qualify_mock_name(mock: &MockView, target_fqn: &FullyQualifiedName) -> MockView {
730    let parts = mock.fqn.matches('.').count() + 1;
731
732    let qualified_fqn = match parts {
733        1 => format!(
734            "{}.{}.{}",
735            target_fqn.database(),
736            target_fqn.schema(),
737            mock.fqn
738        ),
739        2 => format!("{}.{}", target_fqn.database(), mock.fqn),
740        _ => mock.fqn.clone(),
741    };
742
743    MockView {
744        fqn: qualified_fqn,
745        columns: mock.columns.clone(),
746        query: mock.query.clone(),
747    }
748}
749
750fn create_mock_view_sql(mock: &MockView) -> String {
751    let flattened_name = flatten_fqn(&mock.fqn);
752    let columns_def = mock
753        .columns
754        .iter()
755        .map(|(name, typ)| format!("{} {}", name, typ))
756        .collect::<Vec<_>>()
757        .join(", ");
758
759    format!(
760        "CREATE TEMPORARY VIEW {} AS\nWITH MUTUALLY RECURSIVE data({}) AS (\n  {}\n)\nSELECT * FROM data;",
761        flattened_name, columns_def, mock.query
762    )
763}
764
765fn create_expected_view_sql(expected: &ExpectedResult) -> String {
766    let columns_def = expected
767        .columns
768        .iter()
769        .map(|(name, typ)| format!("{} {}", name, typ))
770        .collect::<Vec<_>>()
771        .join(", ");
772
773    format!(
774        "CREATE TEMPORARY VIEW expected AS\nWITH MUTUALLY RECURSIVE data({}) AS (\n  {}\n)\nSELECT * FROM data;",
775        columns_def, expected.query
776    )
777}
778
779/// Create SQL for the target view as a temporary view with flattened naming.
780///
781/// Returns an error if the target statement is not a `CREATE VIEW` or
782/// `CREATE MATERIALIZED VIEW` — unit tests only apply to those object types.
783fn create_target_view_sql(stmt: &Statement, fqn: &FullyQualifiedName) -> Result<String, String> {
784    let mut visitor = NormalizingVisitor::flattening(fqn);
785    let transformed_stmt = stmt
786        .clone()
787        .normalize_name_with(&visitor, &fqn.to_item_name())
788        .normalize_dependencies_with(&mut visitor);
789
790    let view_stmt = match transformed_stmt {
791        Statement::CreateView(view) => CreateViewStatement {
792            if_exists: IfExistsBehavior::Error,
793            temporary: true,
794            definition: view.definition.clone(),
795        },
796        Statement::CreateMaterializedView(mv) => CreateViewStatement {
797            if_exists: IfExistsBehavior::Error,
798            temporary: true,
799            definition: ViewDefinition {
800                name: mv.name,
801                columns: mv.columns,
802                query: mv.query,
803            },
804        },
805        other => {
806            return Err(format!(
807                "unit tests are only supported on views and materialized views; \
808                 target '{}.{}.{}' is a {}",
809                fqn.database(),
810                fqn.schema(),
811                fqn.object(),
812                other.kind(),
813            ));
814        }
815    };
816    Ok(view_stmt.to_string())
817}
818
819/// Create the test assertion query that returns failures.
820///
821/// Returns rows with a 'status' column indicating the failure mode:
822/// - 'MISSING': Expected rows not found in actual results
823/// - 'UNEXPECTED': Actual rows not found in expected results
824///
825/// Empty result means the test passed.
826///
827/// If `at_time` is provided, the query includes an `AS OF` clause to set
828/// the value of `mz_now()` during test execution.
829fn create_test_query_sql(flattened_target_name: &str, at_time: Option<&str>) -> String {
830    let as_of_clause = at_time
831        .map(|t| format!(" AS OF {}::mz_timestamp", t))
832        .unwrap_or_default();
833    format!(
834        r#"SELECT 'MISSING' as status, * FROM expected
835EXCEPT
836SELECT 'MISSING', * FROM {}
837
838UNION ALL
839
840SELECT 'UNEXPECTED' as status, * FROM {}
841EXCEPT
842SELECT 'UNEXPECTED', * FROM expected{}"#,
843        flattened_target_name, flattened_target_name, as_of_clause
844    )
845}
846
847#[cfg(test)]
848mod tests {
849    use super::*;
850    use crate::types::{ColumnType, DataType};
851    use std::collections::BTreeMap;
852
853    #[mz_ore::test]
854    fn test_flatten_fqn() {
855        assert_eq!(
856            flatten_fqn("materialize.public.flippers"),
857            "\"materialize.public.flippers\""
858        );
859        assert_eq!(flatten_fqn("a.b.c"), "\"a.b.c\"");
860        assert_eq!(flatten_fqn("single"), "\"single\"");
861    }
862
863    #[mz_ore::test]
864    fn test_create_mock_view_sql() {
865        let mock = MockView {
866            fqn: "materialize.public.users".to_string(),
867            columns: vec![
868                ("id".to_string(), "BIGINT".to_string()),
869                ("name".to_string(), "TEXT".to_string()),
870            ],
871            query: "SELECT * FROM VALUES ((1, 'alice'))".to_string(),
872        };
873
874        let sql = create_mock_view_sql(&mock);
875
876        assert!(sql.contains("CREATE TEMPORARY VIEW \"materialize.public.users\""));
877        assert!(sql.contains("WITH MUTUALLY RECURSIVE data(id BIGINT, name TEXT)"));
878        assert!(sql.contains("SELECT * FROM VALUES ((1, 'alice'))"));
879        assert!(sql.contains("SELECT * FROM data"));
880    }
881
882    #[mz_ore::test]
883    fn test_create_expected_view_sql() {
884        let expected = ExpectedResult {
885            columns: vec![
886                ("id".to_string(), "BIGINT".to_string()),
887                ("count".to_string(), "INT".to_string()),
888            ],
889            query: "SELECT * FROM VALUES ((1, 10))".to_string(),
890        };
891
892        let sql = create_expected_view_sql(&expected);
893
894        assert!(sql.contains("CREATE TEMPORARY VIEW expected"));
895        assert!(sql.contains("WITH MUTUALLY RECURSIVE data(id BIGINT, count INT)"));
896        assert!(sql.contains("SELECT * FROM VALUES ((1, 10))"));
897        assert!(sql.contains("SELECT * FROM data"));
898    }
899
900    #[mz_ore::test]
901    fn test_create_test_query_sql() {
902        let sql = create_test_query_sql("materialize_public_my_view", None);
903
904        assert!(sql.contains("SELECT 'MISSING' as status, * FROM expected"));
905        assert!(sql.contains("SELECT 'MISSING', * FROM materialize_public_my_view"));
906        assert!(sql.contains("SELECT 'UNEXPECTED' as status, * FROM materialize_public_my_view"));
907        assert!(sql.contains("SELECT 'UNEXPECTED', * FROM expected"));
908        assert!(sql.contains("UNION ALL"));
909        assert!(sql.contains("EXCEPT"));
910        assert!(!sql.contains("AS OF"));
911    }
912
913    #[mz_ore::test]
914    fn test_create_test_query_sql_with_at_time() {
915        let sql =
916            create_test_query_sql("materialize_public_my_view", Some("'2024-01-15 10:00:00'"));
917
918        assert!(sql.contains("SELECT 'MISSING' as status, * FROM expected"));
919        assert!(sql.contains("AS OF '2024-01-15 10:00:00'::mz_timestamp"));
920    }
921
922    fn make_test_types() -> Types {
923        let mut objects = BTreeMap::new();
924
925        let mut users_cols = BTreeMap::new();
926        users_cols.insert(
927            "id".to_string(),
928            ColumnType {
929                r#type: DataType::named("bigint"),
930                nullable: false,
931                position: 0,
932                comment: None,
933            },
934        );
935        users_cols.insert(
936            "name".to_string(),
937            ColumnType {
938                r#type: DataType::named("text"),
939                nullable: true,
940                position: 1,
941                comment: None,
942            },
943        );
944        users_cols.insert(
945            "email".to_string(),
946            ColumnType {
947                r#type: DataType::named("text"),
948                nullable: true,
949                position: 2,
950                comment: None,
951            },
952        );
953        objects.insert(
954            "materialize.public.users".parse::<ObjectId>().unwrap(),
955            users_cols,
956        );
957
958        let mut orders_cols = BTreeMap::new();
959        orders_cols.insert(
960            "id".to_string(),
961            ColumnType {
962                r#type: DataType::named("bigint"),
963                nullable: false,
964                position: 0,
965                comment: None,
966            },
967        );
968        orders_cols.insert(
969            "user_id".to_string(),
970            ColumnType {
971                r#type: DataType::named("bigint"),
972                nullable: false,
973                position: 1,
974                comment: None,
975            },
976        );
977        orders_cols.insert(
978            "amount".to_string(),
979            ColumnType {
980                r#type: DataType::named("numeric"),
981                nullable: true,
982                position: 2,
983                comment: None,
984            },
985        );
986        objects.insert(
987            "materialize.public.orders".parse::<ObjectId>().unwrap(),
988            orders_cols,
989        );
990
991        let mut summary_cols = BTreeMap::new();
992        summary_cols.insert(
993            "user_id".to_string(),
994            ColumnType {
995                r#type: DataType::named("bigint"),
996                nullable: false,
997                position: 0,
998                comment: None,
999            },
1000        );
1001        summary_cols.insert(
1002            "user_name".to_string(),
1003            ColumnType {
1004                r#type: DataType::named("text"),
1005                nullable: true,
1006                position: 1,
1007                comment: None,
1008            },
1009        );
1010        summary_cols.insert(
1011            "total_orders".to_string(),
1012            ColumnType {
1013                r#type: DataType::named("bigint"),
1014                nullable: true,
1015                position: 2,
1016                comment: None,
1017            },
1018        );
1019        objects.insert(
1020            "materialize.public.user_order_summary"
1021                .parse::<ObjectId>()
1022                .unwrap(),
1023            summary_cols,
1024        );
1025
1026        Types {
1027            tables: objects,
1028            kinds: BTreeMap::new(),
1029            comments: BTreeMap::new(),
1030        }
1031    }
1032
1033    fn make_target_id() -> ObjectId {
1034        ObjectId::new(
1035            "materialize".to_string(),
1036            "public".to_string(),
1037            "user_order_summary".to_string(),
1038        )
1039    }
1040
1041    fn make_dependencies() -> BTreeSet<ObjectId> {
1042        let mut deps = BTreeSet::new();
1043        deps.insert(ObjectId::new(
1044            "materialize".to_string(),
1045            "public".to_string(),
1046            "users".to_string(),
1047        ));
1048        deps.insert(ObjectId::new(
1049            "materialize".to_string(),
1050            "public".to_string(),
1051            "orders".to_string(),
1052        ));
1053        deps
1054    }
1055
1056    #[mz_ore::test]
1057    fn test_validate_unit_test_passes_with_correct_mocks() {
1058        let test = UnitTest {
1059            name: "test_user_summary".to_string(),
1060            target_view: "materialize.public.user_order_summary".to_string(),
1061            at_time: None,
1062            mocks: vec![
1063                MockView {
1064                    fqn: "materialize.public.users".to_string(),
1065                    columns: vec![
1066                        ("id".to_string(), "bigint".to_string()),
1067                        ("name".to_string(), "text".to_string()),
1068                        ("email".to_string(), "text".to_string()),
1069                    ],
1070                    query: "SELECT * FROM VALUES (1, 'alice', 'alice@example.com')".to_string(),
1071                },
1072                MockView {
1073                    fqn: "materialize.public.orders".to_string(),
1074                    columns: vec![
1075                        ("id".to_string(), "bigint".to_string()),
1076                        ("user_id".to_string(), "bigint".to_string()),
1077                        ("amount".to_string(), "numeric".to_string()),
1078                    ],
1079                    query: "SELECT * FROM VALUES (1, 1, 100.00)".to_string(),
1080                },
1081            ],
1082            expected: ExpectedResult {
1083                columns: vec![
1084                    ("user_id".to_string(), "bigint".to_string()),
1085                    ("user_name".to_string(), "text".to_string()),
1086                    ("total_orders".to_string(), "bigint".to_string()),
1087                ],
1088                query: "SELECT * FROM VALUES (1, 'alice', 1)".to_string(),
1089            },
1090        };
1091
1092        let types = make_test_types();
1093        let target_id = make_target_id();
1094        let dependencies = make_dependencies();
1095
1096        let result = validate_unit_test(
1097            &test,
1098            &target_id,
1099            &|fqn| types.get_table(fqn).cloned(),
1100            &dependencies,
1101        );
1102        assert!(result.is_ok(), "Expected validation to pass: {:?}", result);
1103    }
1104
1105    #[mz_ore::test]
1106    fn test_validate_unit_test_fails_with_unmocked_dependency() {
1107        let test = UnitTest {
1108            name: "test_user_summary".to_string(),
1109            target_view: "materialize.public.user_order_summary".to_string(),
1110            at_time: None,
1111            mocks: vec![MockView {
1112                fqn: "materialize.public.users".to_string(),
1113                columns: vec![
1114                    ("id".to_string(), "bigint".to_string()),
1115                    ("name".to_string(), "text".to_string()),
1116                    ("email".to_string(), "text".to_string()),
1117                ],
1118                query: "SELECT * FROM VALUES (1, 'alice', 'alice@example.com')".to_string(),
1119            }],
1120            expected: ExpectedResult {
1121                columns: vec![
1122                    ("user_id".to_string(), "bigint".to_string()),
1123                    ("user_name".to_string(), "text".to_string()),
1124                    ("total_orders".to_string(), "bigint".to_string()),
1125                ],
1126                query: "SELECT * FROM VALUES (1, 'alice', 1)".to_string(),
1127            },
1128        };
1129
1130        let types = make_test_types();
1131        let target_id = make_target_id();
1132        let dependencies = make_dependencies();
1133
1134        let result = validate_unit_test(
1135            &test,
1136            &target_id,
1137            &|fqn| types.get_table(fqn).cloned(),
1138            &dependencies,
1139        );
1140        assert!(result.is_err());
1141
1142        match result.unwrap_err() {
1143            TestValidationError::UnmockedDependency(err) => {
1144                assert_eq!(err.test_name, "test_user_summary");
1145                assert!(
1146                    err.missing_mocks
1147                        .contains(&"materialize.public.orders".to_string())
1148                );
1149            }
1150            other => panic!("Expected UnmockedDependency error, got: {:?}", other),
1151        }
1152    }
1153
1154    #[mz_ore::test]
1155    fn test_validate_unit_test_fails_with_missing_mock_column() {
1156        let test = UnitTest {
1157            name: "test_user_summary".to_string(),
1158            target_view: "materialize.public.user_order_summary".to_string(),
1159            at_time: None,
1160            mocks: vec![
1161                MockView {
1162                    fqn: "materialize.public.users".to_string(),
1163                    columns: vec![
1164                        ("id".to_string(), "bigint".to_string()),
1165                        ("name".to_string(), "text".to_string()),
1166                    ],
1167                    query: "SELECT * FROM VALUES (1, 'alice')".to_string(),
1168                },
1169                MockView {
1170                    fqn: "materialize.public.orders".to_string(),
1171                    columns: vec![
1172                        ("id".to_string(), "bigint".to_string()),
1173                        ("user_id".to_string(), "bigint".to_string()),
1174                        ("amount".to_string(), "numeric".to_string()),
1175                    ],
1176                    query: "SELECT * FROM VALUES (1, 1, 100.00)".to_string(),
1177                },
1178            ],
1179            expected: ExpectedResult {
1180                columns: vec![
1181                    ("user_id".to_string(), "bigint".to_string()),
1182                    ("user_name".to_string(), "text".to_string()),
1183                    ("total_orders".to_string(), "bigint".to_string()),
1184                ],
1185                query: "SELECT * FROM VALUES (1, 'alice', 1)".to_string(),
1186            },
1187        };
1188
1189        let types = make_test_types();
1190        let target_id = make_target_id();
1191        let dependencies = make_dependencies();
1192
1193        let result = validate_unit_test(
1194            &test,
1195            &target_id,
1196            &|fqn| types.get_table(fqn).cloned(),
1197            &dependencies,
1198        );
1199        assert!(result.is_err());
1200
1201        match result.unwrap_err() {
1202            TestValidationError::MockSchemaMismatch(err) => {
1203                assert_eq!(err.test_name, "test_user_summary");
1204                assert_eq!(err.mock_fqn, "materialize.public.users");
1205                assert!(err.missing_columns.iter().any(|(name, _)| name == "email"));
1206                assert!(err.extra_columns.is_empty());
1207            }
1208            other => panic!("Expected MockSchemaMismatch error, got: {:?}", other),
1209        }
1210    }
1211
1212    #[mz_ore::test]
1213    fn test_validate_unit_test_fails_with_extra_mock_column() {
1214        let test = UnitTest {
1215            name: "test_user_summary".to_string(),
1216            target_view: "materialize.public.user_order_summary".to_string(),
1217            at_time: None,
1218            mocks: vec![
1219                MockView {
1220                    fqn: "materialize.public.users".to_string(),
1221                    columns: vec![
1222                        ("id".to_string(), "bigint".to_string()),
1223                        ("name".to_string(), "text".to_string()),
1224                        ("email".to_string(), "text".to_string()),
1225                        ("extra_column".to_string(), "int".to_string()),
1226                    ],
1227                    query: "SELECT * FROM VALUES (1, 'alice', 'alice@example.com', 42)".to_string(),
1228                },
1229                MockView {
1230                    fqn: "materialize.public.orders".to_string(),
1231                    columns: vec![
1232                        ("id".to_string(), "bigint".to_string()),
1233                        ("user_id".to_string(), "bigint".to_string()),
1234                        ("amount".to_string(), "numeric".to_string()),
1235                    ],
1236                    query: "SELECT * FROM VALUES (1, 1, 100.00)".to_string(),
1237                },
1238            ],
1239            expected: ExpectedResult {
1240                columns: vec![
1241                    ("user_id".to_string(), "bigint".to_string()),
1242                    ("user_name".to_string(), "text".to_string()),
1243                    ("total_orders".to_string(), "bigint".to_string()),
1244                ],
1245                query: "SELECT * FROM VALUES (1, 'alice', 1)".to_string(),
1246            },
1247        };
1248
1249        let types = make_test_types();
1250        let target_id = make_target_id();
1251        let dependencies = make_dependencies();
1252
1253        let result = validate_unit_test(
1254            &test,
1255            &target_id,
1256            &|fqn| types.get_table(fqn).cloned(),
1257            &dependencies,
1258        );
1259        assert!(result.is_err());
1260
1261        match result.unwrap_err() {
1262            TestValidationError::MockSchemaMismatch(err) => {
1263                assert_eq!(err.mock_fqn, "materialize.public.users");
1264                assert!(err.extra_columns.contains(&"extra_column".to_string()));
1265                assert!(err.missing_columns.is_empty());
1266            }
1267            other => panic!("Expected MockSchemaMismatch error, got: {:?}", other),
1268        }
1269    }
1270
1271    #[mz_ore::test]
1272    fn test_validate_unit_test_fails_with_type_mismatch() {
1273        let test = UnitTest {
1274            name: "test_user_summary".to_string(),
1275            target_view: "materialize.public.user_order_summary".to_string(),
1276            at_time: None,
1277            mocks: vec![
1278                MockView {
1279                    fqn: "materialize.public.users".to_string(),
1280                    columns: vec![
1281                        ("id".to_string(), "text".to_string()),
1282                        ("name".to_string(), "text".to_string()),
1283                        ("email".to_string(), "text".to_string()),
1284                    ],
1285                    query: "SELECT * FROM VALUES ('1', 'alice', 'alice@example.com')".to_string(),
1286                },
1287                MockView {
1288                    fqn: "materialize.public.orders".to_string(),
1289                    columns: vec![
1290                        ("id".to_string(), "bigint".to_string()),
1291                        ("user_id".to_string(), "bigint".to_string()),
1292                        ("amount".to_string(), "numeric".to_string()),
1293                    ],
1294                    query: "SELECT * FROM VALUES (1, 1, 100.00)".to_string(),
1295                },
1296            ],
1297            expected: ExpectedResult {
1298                columns: vec![
1299                    ("user_id".to_string(), "bigint".to_string()),
1300                    ("user_name".to_string(), "text".to_string()),
1301                    ("total_orders".to_string(), "bigint".to_string()),
1302                ],
1303                query: "SELECT * FROM VALUES (1, 'alice', 1)".to_string(),
1304            },
1305        };
1306
1307        let types = make_test_types();
1308        let target_id = make_target_id();
1309        let dependencies = make_dependencies();
1310
1311        let result = validate_unit_test(
1312            &test,
1313            &target_id,
1314            &|fqn| types.get_table(fqn).cloned(),
1315            &dependencies,
1316        );
1317        assert!(result.is_err());
1318
1319        match result.unwrap_err() {
1320            TestValidationError::MockSchemaMismatch(err) => {
1321                assert_eq!(err.mock_fqn, "materialize.public.users");
1322                assert!(
1323                    err.type_mismatches
1324                        .iter()
1325                        .any(|(col, mock_t, _)| { col == "id" && mock_t == "text" })
1326                );
1327            }
1328            other => panic!("Expected MockSchemaMismatch error, got: {:?}", other),
1329        }
1330    }
1331
1332    #[mz_ore::test]
1333    fn test_validate_unit_test_fails_with_expected_schema_mismatch() {
1334        let test = UnitTest {
1335            name: "test_user_summary".to_string(),
1336            target_view: "materialize.public.user_order_summary".to_string(),
1337            at_time: None,
1338            mocks: vec![
1339                MockView {
1340                    fqn: "materialize.public.users".to_string(),
1341                    columns: vec![
1342                        ("id".to_string(), "bigint".to_string()),
1343                        ("name".to_string(), "text".to_string()),
1344                        ("email".to_string(), "text".to_string()),
1345                    ],
1346                    query: "SELECT * FROM VALUES (1, 'alice', 'alice@example.com')".to_string(),
1347                },
1348                MockView {
1349                    fqn: "materialize.public.orders".to_string(),
1350                    columns: vec![
1351                        ("id".to_string(), "bigint".to_string()),
1352                        ("user_id".to_string(), "bigint".to_string()),
1353                        ("amount".to_string(), "numeric".to_string()),
1354                    ],
1355                    query: "SELECT * FROM VALUES (1, 1, 100.00)".to_string(),
1356                },
1357            ],
1358            expected: ExpectedResult {
1359                columns: vec![
1360                    ("user_id".to_string(), "bigint".to_string()),
1361                    ("total_orders".to_string(), "bigint".to_string()),
1362                ],
1363                query: "SELECT * FROM VALUES (1, 1)".to_string(),
1364            },
1365        };
1366
1367        let types = make_test_types();
1368        let target_id = make_target_id();
1369        let dependencies = make_dependencies();
1370
1371        let result = validate_unit_test(
1372            &test,
1373            &target_id,
1374            &|fqn| types.get_table(fqn).cloned(),
1375            &dependencies,
1376        );
1377        assert!(result.is_err());
1378
1379        match result.unwrap_err() {
1380            TestValidationError::ExpectedSchemaMismatch(err) => {
1381                assert_eq!(err.test_name, "test_user_summary");
1382                assert_eq!(err.target_view, "materialize.public.user_order_summary");
1383                assert!(
1384                    err.missing_columns
1385                        .iter()
1386                        .any(|(name, _)| name == "user_name")
1387                );
1388            }
1389            other => panic!("Expected ExpectedSchemaMismatch error, got: {:?}", other),
1390        }
1391    }
1392
1393    #[mz_ore::test]
1394    fn test_validate_unit_test_fails_with_expected_type_mismatch() {
1395        let test = UnitTest {
1396            name: "test_user_summary".to_string(),
1397            target_view: "materialize.public.user_order_summary".to_string(),
1398            at_time: None,
1399            mocks: vec![
1400                MockView {
1401                    fqn: "materialize.public.users".to_string(),
1402                    columns: vec![
1403                        ("id".to_string(), "bigint".to_string()),
1404                        ("name".to_string(), "text".to_string()),
1405                        ("email".to_string(), "text".to_string()),
1406                    ],
1407                    query: "SELECT * FROM VALUES (1, 'alice', 'alice@example.com')".to_string(),
1408                },
1409                MockView {
1410                    fqn: "materialize.public.orders".to_string(),
1411                    columns: vec![
1412                        ("id".to_string(), "bigint".to_string()),
1413                        ("user_id".to_string(), "bigint".to_string()),
1414                        ("amount".to_string(), "numeric".to_string()),
1415                    ],
1416                    query: "SELECT * FROM VALUES (1, 1, 100.00)".to_string(),
1417                },
1418            ],
1419            expected: ExpectedResult {
1420                columns: vec![
1421                    ("user_id".to_string(), "bigint".to_string()),
1422                    ("user_name".to_string(), "bigint".to_string()),
1423                    ("total_orders".to_string(), "bigint".to_string()),
1424                ],
1425                query: "SELECT * FROM VALUES (1, 1, 1)".to_string(),
1426            },
1427        };
1428
1429        let types = make_test_types();
1430        let target_id = make_target_id();
1431        let dependencies = make_dependencies();
1432
1433        let result = validate_unit_test(
1434            &test,
1435            &target_id,
1436            &|fqn| types.get_table(fqn).cloned(),
1437            &dependencies,
1438        );
1439        assert!(result.is_err());
1440
1441        match result.unwrap_err() {
1442            TestValidationError::ExpectedSchemaMismatch(err) => {
1443                assert!(
1444                    err.type_mismatches
1445                        .iter()
1446                        .any(|(col, exp_t, _)| { col == "user_name" && exp_t == "bigint" })
1447                );
1448            }
1449            other => panic!("Expected ExpectedSchemaMismatch error, got: {:?}", other),
1450        }
1451    }
1452
1453    #[mz_ore::test]
1454    fn test_normalize_fqn_unqualified() {
1455        let target_id = ObjectId::new(
1456            "mydb".to_string(),
1457            "myschema".to_string(),
1458            "myview".to_string(),
1459        );
1460
1461        let normalized = normalize_fqn("users", &target_id);
1462        assert_eq!(normalized.to_string(), "mydb.myschema.users");
1463    }
1464
1465    #[mz_ore::test]
1466    fn test_normalize_fqn_schema_qualified() {
1467        let target_id = ObjectId::new(
1468            "mydb".to_string(),
1469            "myschema".to_string(),
1470            "myview".to_string(),
1471        );
1472
1473        let normalized = normalize_fqn("other_schema.users", &target_id);
1474        assert_eq!(normalized.to_string(), "mydb.other_schema.users");
1475    }
1476
1477    #[mz_ore::test]
1478    fn test_normalize_fqn_fully_qualified() {
1479        let target_id = ObjectId::new(
1480            "mydb".to_string(),
1481            "myschema".to_string(),
1482            "myview".to_string(),
1483        );
1484
1485        let normalized = normalize_fqn("other_db.other_schema.users", &target_id);
1486        assert_eq!(normalized.to_string(), "other_db.other_schema.users");
1487    }
1488
1489    #[mz_ore::test]
1490    fn test_normalize_type_integer_aliases() {
1491        assert_eq!(normalize_type("INT"), "integer");
1492        assert_eq!(normalize_type("int4"), "integer");
1493        assert_eq!(normalize_type("integer"), "integer");
1494        assert_eq!(normalize_type("INTEGER"), "integer");
1495    }
1496
1497    #[mz_ore::test]
1498    fn test_normalize_type_bigint_aliases() {
1499        assert_eq!(normalize_type("INT8"), "bigint");
1500        assert_eq!(normalize_type("bigint"), "bigint");
1501        assert_eq!(normalize_type("BIGINT"), "bigint");
1502    }
1503
1504    #[mz_ore::test]
1505    fn test_normalize_type_smallint_aliases() {
1506        assert_eq!(normalize_type("INT2"), "smallint");
1507        assert_eq!(normalize_type("smallint"), "smallint");
1508        assert_eq!(normalize_type("SMALLINT"), "smallint");
1509    }
1510
1511    #[mz_ore::test]
1512    fn test_normalize_type_real_aliases() {
1513        assert_eq!(normalize_type("float4"), "real");
1514        assert_eq!(normalize_type("FLOAT4"), "real");
1515        assert_eq!(normalize_type("real"), "real");
1516        assert_eq!(normalize_type("REAL"), "real");
1517    }
1518
1519    #[mz_ore::test]
1520    fn test_normalize_type_double_precision_aliases() {
1521        assert_eq!(normalize_type("float"), "double precision");
1522        assert_eq!(normalize_type("FLOAT"), "double precision");
1523        assert_eq!(normalize_type("float8"), "double precision");
1524        assert_eq!(normalize_type("FLOAT8"), "double precision");
1525        assert_eq!(normalize_type("double"), "double precision");
1526        assert_eq!(normalize_type("DOUBLE"), "double precision");
1527        assert_eq!(normalize_type("double precision"), "double precision");
1528        assert_eq!(normalize_type("DOUBLE PRECISION"), "double precision");
1529    }
1530
1531    #[mz_ore::test]
1532    fn test_normalize_type_boolean_aliases() {
1533        assert_eq!(normalize_type("bool"), "boolean");
1534        assert_eq!(normalize_type("boolean"), "boolean");
1535        assert_eq!(normalize_type("BOOL"), "boolean");
1536        assert_eq!(normalize_type("BOOLEAN"), "boolean");
1537    }
1538
1539    #[mz_ore::test]
1540    fn test_normalize_type_text_aliases() {
1541        assert_eq!(normalize_type("text"), "text");
1542        assert_eq!(normalize_type("TEXT"), "text");
1543        assert_eq!(normalize_type("string"), "text");
1544        assert_eq!(normalize_type("STRING"), "text");
1545        assert_eq!(normalize_type("varchar"), "text");
1546        assert_eq!(normalize_type("VARCHAR"), "text");
1547        assert_eq!(normalize_type("varchar(255)"), "text");
1548        assert_eq!(normalize_type("character varying"), "text");
1549        assert_eq!(normalize_type("character varying(100)"), "text");
1550    }
1551
1552    #[mz_ore::test]
1553    fn test_normalize_type_numeric_aliases() {
1554        assert_eq!(normalize_type("numeric"), "numeric");
1555        assert_eq!(normalize_type("NUMERIC"), "numeric");
1556        assert_eq!(normalize_type("decimal"), "numeric");
1557        assert_eq!(normalize_type("DECIMAL"), "numeric");
1558        assert_eq!(normalize_type("numeric(10,2)"), "numeric");
1559        assert_eq!(normalize_type("decimal(18,4)"), "numeric");
1560    }
1561
1562    #[mz_ore::test]
1563    fn test_normalize_type_jsonb_aliases() {
1564        assert_eq!(normalize_type("json"), "jsonb");
1565        assert_eq!(normalize_type("JSON"), "jsonb");
1566        assert_eq!(normalize_type("jsonb"), "jsonb");
1567        assert_eq!(normalize_type("JSONB"), "jsonb");
1568    }
1569
1570    #[mz_ore::test]
1571    fn test_normalize_type_timestamptz_aliases() {
1572        assert_eq!(normalize_type("timestamptz"), "timestamp with time zone");
1573        assert_eq!(normalize_type("TIMESTAMPTZ"), "timestamp with time zone");
1574        assert_eq!(
1575            normalize_type("timestamp with time zone"),
1576            "timestamp with time zone"
1577        );
1578        assert_eq!(
1579            normalize_type("TIMESTAMP WITH TIME ZONE"),
1580            "timestamp with time zone"
1581        );
1582    }
1583
1584    #[mz_ore::test]
1585    fn test_normalize_type_preserves_other_types() {
1586        assert_eq!(normalize_type("timestamp"), "timestamp without time zone");
1587        assert_eq!(normalize_type("TIMESTAMP"), "timestamp without time zone");
1588        assert_eq!(normalize_type("date"), "date");
1589        assert_eq!(normalize_type("time"), "time");
1590        assert_eq!(normalize_type("interval"), "interval");
1591        assert_eq!(normalize_type("uuid"), "uuid");
1592        assert_eq!(normalize_type("bytea"), "bytea");
1593        assert_eq!(normalize_type("oid"), "oid");
1594        assert_eq!(normalize_type("uint2"), "uint2");
1595        assert_eq!(normalize_type("uint4"), "uint4");
1596        assert_eq!(normalize_type("uint8"), "uint8");
1597    }
1598
1599    #[mz_ore::test]
1600    fn test_normalize_type_handles_whitespace() {
1601        assert_eq!(normalize_type("  INT  "), "integer");
1602        assert_eq!(normalize_type("\ttext\n"), "text");
1603        assert_eq!(normalize_type("  double precision  "), "double precision");
1604    }
1605
1606    #[mz_ore::test]
1607    fn test_normalize_type_case_insensitive() {
1608        assert_eq!(normalize_type("integer"), normalize_type("INTEGER"));
1609        assert_eq!(normalize_type("integer"), normalize_type("Integer"));
1610        assert_eq!(normalize_type("integer"), normalize_type("iNtEgEr"));
1611        assert_eq!(normalize_type("int"), normalize_type("INT"));
1612        assert_eq!(normalize_type("int"), normalize_type("Int"));
1613
1614        assert_eq!(normalize_type("bigint"), normalize_type("BIGINT"));
1615        assert_eq!(normalize_type("bigint"), normalize_type("BigInt"));
1616        assert_eq!(normalize_type("int8"), normalize_type("INT8"));
1617
1618        assert_eq!(normalize_type("text"), normalize_type("TEXT"));
1619        assert_eq!(normalize_type("text"), normalize_type("Text"));
1620        assert_eq!(normalize_type("string"), normalize_type("STRING"));
1621        assert_eq!(normalize_type("string"), normalize_type("String"));
1622
1623        assert_eq!(normalize_type("boolean"), normalize_type("BOOLEAN"));
1624        assert_eq!(normalize_type("boolean"), normalize_type("Boolean"));
1625        assert_eq!(normalize_type("bool"), normalize_type("BOOL"));
1626        assert_eq!(normalize_type("bool"), normalize_type("Bool"));
1627
1628        assert_eq!(normalize_type("numeric"), normalize_type("NUMERIC"));
1629        assert_eq!(normalize_type("numeric"), normalize_type("Numeric"));
1630        assert_eq!(normalize_type("decimal"), normalize_type("DECIMAL"));
1631
1632        assert_eq!(
1633            normalize_type("double precision"),
1634            normalize_type("DOUBLE PRECISION")
1635        );
1636        assert_eq!(
1637            normalize_type("double precision"),
1638            normalize_type("Double Precision")
1639        );
1640
1641        assert_eq!(
1642            normalize_type("timestamp with time zone"),
1643            normalize_type("TIMESTAMP WITH TIME ZONE")
1644        );
1645        assert_eq!(normalize_type("timestamptz"), normalize_type("TIMESTAMPTZ"));
1646        assert_eq!(normalize_type("timestamptz"), normalize_type("TimestampTZ"));
1647
1648        assert_eq!(normalize_type("jsonb"), normalize_type("JSONB"));
1649        assert_eq!(normalize_type("jsonb"), normalize_type("JsonB"));
1650        assert_eq!(normalize_type("json"), normalize_type("JSON"));
1651    }
1652
1653    #[mz_ore::test]
1654    fn test_compare_columns_exact_match() {
1655        let test_columns = vec![
1656            ("id".to_string(), "bigint".to_string()),
1657            ("name".to_string(), "text".to_string()),
1658        ];
1659
1660        let mut actual_columns = BTreeMap::new();
1661        actual_columns.insert(
1662            "id".to_string(),
1663            ColumnType {
1664                r#type: DataType::named("bigint"),
1665                nullable: false,
1666                position: 0,
1667                comment: None,
1668            },
1669        );
1670        actual_columns.insert(
1671            "name".to_string(),
1672            ColumnType {
1673                r#type: DataType::named("text"),
1674                nullable: true,
1675                position: 0,
1676                comment: None,
1677            },
1678        );
1679
1680        let (extra, missing, type_mismatches) = compare_columns(&test_columns, &actual_columns);
1681        assert!(extra.is_empty());
1682        assert!(missing.is_empty());
1683        assert!(type_mismatches.is_empty());
1684    }
1685
1686    #[mz_ore::test]
1687    fn test_compare_columns_with_type_aliases() {
1688        let test_columns = vec![
1689            ("id".to_string(), "INT".to_string()),
1690            ("count".to_string(), "INT8".to_string()),
1691        ];
1692
1693        let mut actual_columns = BTreeMap::new();
1694        actual_columns.insert(
1695            "id".to_string(),
1696            ColumnType {
1697                r#type: DataType::named("integer"),
1698                nullable: false,
1699                position: 0,
1700                comment: None,
1701            },
1702        );
1703        actual_columns.insert(
1704            "count".to_string(),
1705            ColumnType {
1706                r#type: DataType::named("bigint"),
1707                nullable: false,
1708                position: 0,
1709                comment: None,
1710            },
1711        );
1712
1713        let (extra, missing, type_mismatches) = compare_columns(&test_columns, &actual_columns);
1714        assert!(extra.is_empty());
1715        assert!(missing.is_empty());
1716        assert!(type_mismatches.is_empty());
1717    }
1718
1719    #[mz_ore::test]
1720    fn test_compare_columns_detects_extra() {
1721        let test_columns = vec![
1722            ("id".to_string(), "bigint".to_string()),
1723            ("extra".to_string(), "text".to_string()),
1724        ];
1725
1726        let mut actual_columns = BTreeMap::new();
1727        actual_columns.insert(
1728            "id".to_string(),
1729            ColumnType {
1730                r#type: DataType::named("bigint"),
1731                nullable: false,
1732                position: 0,
1733                comment: None,
1734            },
1735        );
1736
1737        let (extra, missing, _) = compare_columns(&test_columns, &actual_columns);
1738        assert_eq!(extra, vec!["extra".to_string()]);
1739        assert!(missing.is_empty());
1740    }
1741
1742    #[mz_ore::test]
1743    fn test_compare_columns_detects_missing() {
1744        let test_columns = vec![("id".to_string(), "bigint".to_string())];
1745
1746        let mut actual_columns = BTreeMap::new();
1747        actual_columns.insert(
1748            "id".to_string(),
1749            ColumnType {
1750                r#type: DataType::named("bigint"),
1751                nullable: false,
1752                position: 0,
1753                comment: None,
1754            },
1755        );
1756        actual_columns.insert(
1757            "name".to_string(),
1758            ColumnType {
1759                r#type: DataType::named("text"),
1760                nullable: true,
1761                position: 0,
1762                comment: None,
1763            },
1764        );
1765
1766        let (extra, missing, _) = compare_columns(&test_columns, &actual_columns);
1767        assert!(extra.is_empty());
1768        assert_eq!(missing, vec![("name".to_string(), "text".to_string())]);
1769    }
1770
1771    #[mz_ore::test]
1772    fn test_compare_columns_detects_type_mismatch() {
1773        let test_columns = vec![("id".to_string(), "text".to_string())];
1774
1775        let mut actual_columns = BTreeMap::new();
1776        actual_columns.insert(
1777            "id".to_string(),
1778            ColumnType {
1779                r#type: DataType::named("bigint"),
1780                nullable: false,
1781                position: 0,
1782                comment: None,
1783            },
1784        );
1785
1786        let (_, _, type_mismatches) = compare_columns(&test_columns, &actual_columns);
1787        assert_eq!(type_mismatches.len(), 1);
1788        assert_eq!(type_mismatches[0].0, "id");
1789        assert_eq!(type_mismatches[0].1, "text");
1790        assert_eq!(type_mismatches[0].2, "bigint");
1791    }
1792
1793    #[mz_ore::test]
1794    fn test_validate_with_unqualified_mock_name() {
1795        let test = UnitTest {
1796            name: "test_partial_fqn".to_string(),
1797            target_view: "materialize.public.user_order_summary".to_string(),
1798            at_time: None,
1799            mocks: vec![
1800                MockView {
1801                    fqn: "users".to_string(),
1802                    columns: vec![
1803                        ("id".to_string(), "bigint".to_string()),
1804                        ("name".to_string(), "text".to_string()),
1805                        ("email".to_string(), "text".to_string()),
1806                    ],
1807                    query: "SELECT * FROM VALUES (1, 'alice', 'alice@example.com')".to_string(),
1808                },
1809                MockView {
1810                    fqn: "public.orders".to_string(),
1811                    columns: vec![
1812                        ("id".to_string(), "bigint".to_string()),
1813                        ("user_id".to_string(), "bigint".to_string()),
1814                        ("amount".to_string(), "numeric".to_string()),
1815                    ],
1816                    query: "SELECT * FROM VALUES (1, 1, 100.00)".to_string(),
1817                },
1818            ],
1819            expected: ExpectedResult {
1820                columns: vec![
1821                    ("user_id".to_string(), "bigint".to_string()),
1822                    ("user_name".to_string(), "text".to_string()),
1823                    ("total_orders".to_string(), "bigint".to_string()),
1824                ],
1825                query: "SELECT * FROM VALUES (1, 'alice', 1)".to_string(),
1826            },
1827        };
1828
1829        let types = make_test_types();
1830        let target_id = make_target_id();
1831        let dependencies = make_dependencies();
1832
1833        let result = validate_unit_test(
1834            &test,
1835            &target_id,
1836            &|fqn| types.get_table(fqn).cloned(),
1837            &dependencies,
1838        );
1839        assert!(result.is_ok(), "Expected validation to pass: {:?}", result);
1840    }
1841
1842    #[mz_ore::test]
1843    fn test_validate_passes_with_no_dependencies() {
1844        let test = UnitTest {
1845            name: "test_no_deps".to_string(),
1846            target_view: "materialize.public.my_view".to_string(),
1847            at_time: None,
1848            mocks: vec![],
1849            expected: ExpectedResult {
1850                columns: vec![("result".to_string(), "integer".to_string())],
1851                query: "SELECT * FROM VALUES (42)".to_string(),
1852            },
1853        };
1854
1855        let types = Types::default();
1856        let target_id = ObjectId::new(
1857            "materialize".to_string(),
1858            "public".to_string(),
1859            "my_view".to_string(),
1860        );
1861        let dependencies = BTreeSet::new();
1862
1863        let result = validate_unit_test(
1864            &test,
1865            &target_id,
1866            &|fqn| types.get_table(fqn).cloned(),
1867            &dependencies,
1868        );
1869        assert!(result.is_ok());
1870    }
1871
1872    #[mz_ore::test]
1873    fn test_validate_skips_unknown_mock() {
1874        let test = UnitTest {
1875            name: "test_unknown_mock".to_string(),
1876            target_view: "materialize.public.my_view".to_string(),
1877            at_time: None,
1878            mocks: vec![MockView {
1879                fqn: "materialize.public.unknown_table".to_string(),
1880                columns: vec![("id".to_string(), "bigint".to_string())],
1881                query: "SELECT * FROM VALUES (1)".to_string(),
1882            }],
1883            expected: ExpectedResult {
1884                columns: vec![("result".to_string(), "integer".to_string())],
1885                query: "SELECT * FROM VALUES (42)".to_string(),
1886            },
1887        };
1888
1889        let types = Types::default();
1890        let target_id = ObjectId::new(
1891            "materialize".to_string(),
1892            "public".to_string(),
1893            "my_view".to_string(),
1894        );
1895
1896        let mut dependencies = BTreeSet::new();
1897        dependencies.insert(ObjectId::new(
1898            "materialize".to_string(),
1899            "public".to_string(),
1900            "unknown_table".to_string(),
1901        ));
1902
1903        let result = validate_unit_test(
1904            &test,
1905            &target_id,
1906            &|fqn| types.get_table(fqn).cloned(),
1907            &dependencies,
1908        );
1909        assert!(result.is_ok());
1910    }
1911
1912    #[mz_ore::test]
1913    fn test_normalize_type_list() {
1914        assert_eq!(normalize_type("int8 list"), "bigint list");
1915        assert_eq!(normalize_type("INT LIST"), "integer list");
1916        assert_eq!(normalize_type("text list"), "text list");
1917        assert_eq!(normalize_type("INT8 LIST"), "bigint list");
1918    }
1919
1920    #[mz_ore::test]
1921    fn test_normalize_type_array() {
1922        assert_eq!(normalize_type("int8[]"), "bigint[]");
1923        assert_eq!(normalize_type("INT[]"), "integer[]");
1924        assert_eq!(normalize_type("text[]"), "text[]");
1925    }
1926
1927    #[mz_ore::test]
1928    fn test_normalize_type_map() {
1929        assert_eq!(normalize_type("map[text=>int8]"), "map[text=>bigint]");
1930        assert_eq!(normalize_type("map[STRING=>BOOL]"), "map[text=>boolean]");
1931    }
1932
1933    #[mz_ore::test]
1934    fn test_normalize_type_bare_list() {
1935        assert_eq!(normalize_type("list"), "list");
1936        assert_eq!(normalize_type("LIST"), "list");
1937    }
1938
1939    #[mz_ore::test]
1940    fn test_compare_columns_list_matches_bare() {
1941        let test_columns = vec![("ids".to_string(), "int8 list".to_string())];
1942
1943        let mut actual_columns = BTreeMap::new();
1944        actual_columns.insert(
1945            "ids".to_string(),
1946            ColumnType {
1947                r#type: DataType::named("list"),
1948                nullable: true,
1949                position: 0,
1950                comment: None,
1951            },
1952        );
1953
1954        let (extra, missing, type_mismatches) = compare_columns(&test_columns, &actual_columns);
1955        assert!(extra.is_empty());
1956        assert!(missing.is_empty());
1957        assert!(
1958            type_mismatches.is_empty(),
1959            "Expected no type mismatches for 'int8 list' vs bare 'list', got: {:?}",
1960            type_mismatches
1961        );
1962    }
1963
1964    #[mz_ore::test]
1965    fn test_compare_columns_map_matches_bare() {
1966        let test_columns = vec![("data".to_string(), "map[text=>int8]".to_string())];
1967
1968        let mut actual_columns = BTreeMap::new();
1969        actual_columns.insert(
1970            "data".to_string(),
1971            ColumnType {
1972                r#type: DataType::named("map"),
1973                nullable: true,
1974                position: 0,
1975                comment: None,
1976            },
1977        );
1978
1979        let (_, _, type_mismatches) = compare_columns(&test_columns, &actual_columns);
1980        assert!(
1981            type_mismatches.is_empty(),
1982            "Expected no type mismatches for 'map[text=>int8]' vs bare 'map', got: {:?}",
1983            type_mismatches
1984        );
1985    }
1986}