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