Skip to main content

mz_deploy/client/
errors.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//! Error types for the client module.
11//!
12//! Two top-level enums cover different failure modes:
13//!
14//! - [`ConnectionError`] — Transport and query failures: connection refused,
15//!   SQL errors, missing dependencies, configuration problems, and DDL
16//!   failures.
17//! - [`DatabaseValidationError`] — Semantic mismatches detected during
18//!   pre-deployment validation (e.g., schema conflicts, unexpected objects).
19
20use crate::config::ConfigError;
21use crate::project::SchemaQualifier;
22use crate::project::ir::object_id::ObjectId;
23use owo_colors::{OwoColorize, Stream, Style};
24use std::fmt;
25use std::path::PathBuf;
26use thiserror::Error;
27
28/// Errors that can occur during database operations.
29#[derive(Debug, Error)]
30pub enum ConnectionError {
31    #[error("configuration error: {0}")]
32    Config(#[from] ConfigError),
33
34    #[error("failed to connect to {host}:{port}: {source}")]
35    Connect {
36        host: String,
37        port: u16,
38        source: tokio_postgres::Error,
39    },
40
41    #[error(
42        "TLS required by profile but server at {host}:{port} does not support TLS\n\
43         \n\
44         help: The server did not offer TLS. To connect without encryption, set\n\
45         \x20     sslmode = \"disable\" on the profile. To use TLS if available\n\
46         \x20     but fall back to plaintext otherwise, set sslmode = \"prefer\"."
47    )]
48    TlsRequiredNotSupported {
49        host: String,
50        port: u16,
51        source: tokio_postgres::Error,
52    },
53
54    #[error(
55        "TLS certificate verification failed for {host}:{port}: {source}\n\
56         \n\
57         help: The server's certificate could not be verified against the trusted\n\
58         \x20     CA bundle{hostname_suffix}. To skip verification, set\n\
59         \x20     sslmode = \"require\" or sslmode = \"prefer\". To use a custom\n\
60         \x20     CA bundle, set sslrootcert = \"/path/to/ca.pem\" on the profile."
61    )]
62    TlsVerification {
63        host: String,
64        port: u16,
65        hostname_suffix: &'static str,
66        source: tokio_postgres::Error,
67    },
68
69    #[error(
70        "no CA bundle found for TLS verification\n\
71         \n\
72         help: Set sslrootcert = \"/path/to/ca.pem\" on the profile to point at\n\
73         \x20     a specific CA bundle, or install the system CA bundle at one\n\
74         \x20     of: /etc/ssl/cert.pem, /etc/ssl/certs/ca-certificates.crt, or\n\
75         \x20     the platform-appropriate equivalent."
76    )]
77    TlsCaNotFound,
78
79    #[error("{}", format_query_error(.0))]
80    Query(tokio_postgres::Error),
81
82    #[error("dependency error: {0}")]
83    Dependency(#[from] crate::project::error::DependencyError),
84
85    #[error("failed to create database '{database}': {source}")]
86    DatabaseCreationFailed {
87        database: String,
88        source: Box<dyn std::error::Error + Send + Sync>,
89    },
90
91    #[error("failed to create schema '{database}.{schema}': {source}")]
92    SchemaCreationFailed {
93        database: String,
94        schema: String,
95        source: Box<dyn std::error::Error + Send + Sync>,
96    },
97
98    #[error("failed to create cluster '{name}': {source}")]
99    ClusterCreationFailed {
100        name: String,
101        source: Box<dyn std::error::Error + Send + Sync>,
102    },
103
104    #[error("cluster '{name}' already exists")]
105    ClusterAlreadyExists { name: String },
106
107    #[error("introspection failed for {object_type}: {source}")]
108    IntrospectionFailed {
109        object_type: String,
110        source: Box<dyn std::error::Error + Send + Sync>,
111    },
112
113    #[error("cluster '{name}' not found")]
114    ClusterNotFound { name: String },
115
116    #[error("deployment '{deploy_id}' already exists")]
117    DeploymentAlreadyExists { deploy_id: String },
118
119    #[error("deployment '{deploy_id}' not found")]
120    DeploymentNotFound { deploy_id: String },
121
122    #[error("deployment '{deploy_id}' has already been promoted to production")]
123    DeploymentAlreadyPromoted { deploy_id: String },
124
125    #[error("unsupported statement type: {0}")]
126    UnsupportedStatementType(String),
127
128    #[error("{0}")]
129    Message(String),
130}
131
132fn format_query_error(error: &tokio_postgres::Error) -> String {
133    if let Some(db_error) = error.as_db_error() {
134        let mut parts = vec![format!("database error: {}", db_error.message())];
135
136        if let Some(detail) = db_error.detail() {
137            parts.push(format!("  Detail: {}", detail));
138        }
139
140        if let Some(hint) = db_error.hint() {
141            parts.push(format!("  Hint: {}", hint));
142        }
143
144        parts.push(format!("  Code: {:?}", db_error.code()));
145        parts.join("\n")
146    } else {
147        format!("query error: {}", error)
148    }
149}
150
151impl From<tokio_postgres::Error> for ConnectionError {
152    fn from(error: tokio_postgres::Error) -> Self {
153        ConnectionError::Query(error)
154    }
155}
156
157impl From<mz_postgres_util::PostgresError> for ConnectionError {
158    fn from(error: mz_postgres_util::PostgresError) -> Self {
159        match error {
160            mz_postgres_util::PostgresError::Postgres(error) => ConnectionError::Query(error),
161            other => ConnectionError::Message(other.to_string()),
162        }
163    }
164}
165
166/// One table whose reference its source does not expose.
167#[derive(Debug)]
168pub struct MissingSourceReference {
169    /// The table the project wants to create.
170    pub table: ObjectId,
171    /// The reference it asks for, as written in the project.
172    pub reference: String,
173    /// Exposed references spelled close enough to be the intended one, best
174    /// first. Empty when nothing came close.
175    pub suggestions: Vec<String>,
176}
177
178/// One source whose exposed references do not cover everything the project's
179/// tables ask of it.
180#[derive(Debug)]
181pub struct SourceReferenceMismatch {
182    /// The source the tables read from.
183    pub source: ObjectId,
184    /// The source's catalog ID, so the hint can name a query that lists every
185    /// reference it exposes.
186    pub source_id: String,
187    /// Tables asking for a reference the source does not expose.
188    pub tables: Vec<MissingSourceReference>,
189    /// How many references the source does expose. A count well below what the
190    /// upstream system holds points at the source's filters rather than a typo.
191    pub available_count: usize,
192    /// Why the source's references could not be refreshed, when they could not
193    /// be. The counts and suggestions then come from whatever the catalog last
194    /// recorded, which may be out of date.
195    pub unreadable: Option<String>,
196}
197
198/// Errors that can occur during project validation against the database.
199#[derive(Debug)]
200pub enum DatabaseValidationError {
201    /// One or more databases referenced by the project do not exist.
202    MissingDatabases(Vec<String>),
203    /// One or more schemas referenced by the project do not exist.
204    MissingSchemas(Vec<SchemaQualifier>),
205    /// One or more clusters referenced by the project do not exist.
206    MissingClusters(Vec<String>),
207    /// A single object failed to compile due to missing external dependencies.
208    CompilationFailed {
209        file_path: PathBuf,
210        object_name: ObjectId,
211        missing_dependencies: Vec<ObjectId>,
212    },
213    /// Aggregation of multiple validation failures detected in a single pass.
214    Multiple {
215        databases: Vec<String>,
216        schemas: Vec<SchemaQualifier>,
217        clusters: Vec<String>,
218        compilation_errors: Vec<DatabaseValidationError>,
219    },
220    /// A cluster contains both compute objects (indexes, materialized views) and
221    /// storage objects (sources, sinks), which is not supported.
222    ClusterConflict {
223        cluster_name: String,
224        compute_objects: Vec<String>,
225        storage_objects: Vec<String>,
226    },
227    /// The connected role lacks privileges required for deployment.
228    InsufficientPrivileges {
229        missing_database_usage: Vec<String>,
230        missing_createcluster: bool,
231    },
232    /// The connected role does not own one or more production schemas it needs to manage.
233    SchemaOwnershipMismatch {
234        unowned_schemas: Vec<SchemaQualifier>,
235        current_user: String,
236    },
237    /// The connected role does not own one or more production clusters it needs to manage.
238    ClusterOwnershipMismatch {
239        unowned_clusters: Vec<String>,
240        current_user: String,
241    },
242    /// Sources referenced by the project do not exist in the database.
243    MissingSources(Vec<ObjectId>),
244    /// Connections referenced by the project do not exist in the database.
245    MissingConnections(Vec<ObjectId>),
246    /// Tables reference upstream objects their source does not expose.
247    MissingSourceReferences(Vec<SourceReferenceMismatch>),
248    /// Objects depend on tables that have not yet been created.
249    MissingTableDependencies {
250        objects_needing_tables: Vec<(ObjectId, Vec<ObjectId>)>,
251    },
252    /// A database query failed during validation.
253    QueryError(ConnectionError),
254}
255
256impl fmt::Display for DatabaseValidationError {
257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258        match self {
259            DatabaseValidationError::MissingDatabases(dbs) => {
260                write!(f, "Missing databases: {}", dbs.join(", "))
261            }
262            DatabaseValidationError::MissingSchemas(schemas) => {
263                let schema_list: Vec<String> = schemas
264                    .iter()
265                    .map(|sq| format!("{}.{}", sq.database, sq.schema))
266                    .collect();
267                write!(f, "Missing schemas: {}", schema_list.join(", "))
268            }
269            DatabaseValidationError::MissingClusters(clusters) => {
270                write!(f, "Missing clusters: {}", clusters.join(", "))
271            }
272            DatabaseValidationError::CompilationFailed {
273                file_path,
274                object_name,
275                missing_dependencies,
276            } => {
277                let relative_path = format_relative_path(file_path);
278
279                let error_style = Style::new().bright_red().bold();
280                let arrow_style = Style::new().bright_blue().bold();
281                writeln!(
282                    f,
283                    "{}: failed to compile '{}': missing external dependencies",
284                    "error".if_supports_color(Stream::Stderr, |t| error_style.style(t)),
285                    object_name
286                )?;
287                writeln!(
288                    f,
289                    " {} {}",
290                    "-->".if_supports_color(Stream::Stderr, |t| arrow_style.style(t)),
291                    relative_path
292                )?;
293                writeln!(f)?;
294                writeln!(f, "  Missing dependencies:")?;
295                for dep in missing_dependencies {
296                    writeln!(f, "    - {}", dep)?;
297                }
298                Ok(())
299            }
300            DatabaseValidationError::Multiple {
301                databases,
302                schemas,
303                clusters,
304                compilation_errors,
305            } => {
306                let mut has_errors = false;
307
308                writeln!(f, "Missing dependencies")?;
309                if !databases.is_empty() {
310                    writeln!(f, "Missing databases: {}", databases.join(", "))?;
311                    has_errors = true;
312                }
313
314                if !schemas.is_empty() {
315                    let schema_list: Vec<String> = schemas
316                        .iter()
317                        .map(|sq| format!("{}.{}", sq.database, sq.schema))
318                        .collect();
319                    writeln!(f, "Missing schemas: {}", schema_list.join(", "))?;
320                    has_errors = true;
321                }
322
323                if !clusters.is_empty() {
324                    writeln!(f, "Missing clusters: {}", clusters.join(", "))?;
325                    has_errors = true;
326                }
327
328                if !compilation_errors.is_empty() {
329                    if has_errors {
330                        writeln!(f)?;
331                    }
332                    for (idx, err) in compilation_errors.iter().enumerate() {
333                        if idx > 0 {
334                            writeln!(f)?;
335                        }
336                        write!(f, "{}", err)?;
337                    }
338                }
339
340                Ok(())
341            }
342            DatabaseValidationError::ClusterConflict {
343                cluster_name,
344                compute_objects,
345                storage_objects,
346            } => {
347                let error_style = Style::new().bright_red().bold();
348                writeln!(
349                    f,
350                    "{}: cluster '{}' contains both storage and computation objects",
351                    "error".if_supports_color(Stream::Stderr, |t| error_style.style(t)),
352                    cluster_name
353                )?;
354                writeln!(f)?;
355                writeln!(f, "  Computation objects (indexes, materialized views):")?;
356                for obj in compute_objects {
357                    writeln!(f, "    - {}", obj)?;
358                }
359                writeln!(f)?;
360                writeln!(f, "  Storage objects (sources, sinks):")?;
361                for obj in storage_objects {
362                    writeln!(f, "    - {}", obj)?;
363                }
364                writeln!(f)?;
365                let help_style = Style::new().bright_cyan().bold();
366                writeln!(
367                    f,
368                    "  {} Move sources/sinks to a separate cluster to avoid accidental recreation",
369                    "help:".if_supports_color(Stream::Stderr, |t| help_style.style(t))
370                )?;
371                Ok(())
372            }
373            DatabaseValidationError::InsufficientPrivileges {
374                missing_database_usage,
375                missing_createcluster,
376            } => {
377                let error_style = Style::new().bright_red().bold();
378                let help_style = Style::new().bright_cyan().bold();
379                writeln!(
380                    f,
381                    "{}: insufficient privileges to deploy this project",
382                    "error".if_supports_color(Stream::Stderr, |t| error_style.style(t))
383                )?;
384                writeln!(f)?;
385
386                if !missing_database_usage.is_empty() {
387                    writeln!(f, "  Missing USAGE privilege on databases:")?;
388                    for db in missing_database_usage {
389                        writeln!(f, "    - {}", db)?;
390                    }
391                    writeln!(f)?;
392                }
393
394                if *missing_createcluster {
395                    writeln!(f, "  Missing CREATECLUSTER system privilege")?;
396                    writeln!(f)?;
397                }
398
399                writeln!(
400                    f,
401                    "  {} Ask your administrator to grant the required privileges:",
402                    "help:".if_supports_color(Stream::Stderr, |t| help_style.style(t))
403                )?;
404                writeln!(f)?;
405
406                if !missing_database_usage.is_empty() {
407                    for db in missing_database_usage {
408                        writeln!(f, "    GRANT USAGE ON DATABASE {} TO <user>;", db)?;
409                    }
410                }
411
412                if *missing_createcluster {
413                    writeln!(f, "    GRANT CREATECLUSTER ON SYSTEM TO <user>;")?;
414                }
415
416                Ok(())
417            }
418            DatabaseValidationError::SchemaOwnershipMismatch {
419                unowned_schemas,
420                current_user,
421            } => {
422                let error_style = Style::new().bright_red().bold();
423                let help_style = Style::new().bright_cyan().bold();
424                writeln!(
425                    f,
426                    "{}: current role '{}' does not own the following production schemas",
427                    "error".if_supports_color(Stream::Stderr, |t| error_style.style(t)),
428                    current_user
429                )?;
430                writeln!(f)?;
431                for sq in unowned_schemas {
432                    writeln!(f, "    - {}.{}", sq.database, sq.schema)?;
433                }
434                writeln!(f)?;
435                writeln!(
436                    f,
437                    "  {} Grant ownership of the schemas to the current role:",
438                    "help:".if_supports_color(Stream::Stderr, |t| help_style.style(t))
439                )?;
440                writeln!(f)?;
441                for sq in unowned_schemas {
442                    writeln!(
443                        f,
444                        "    ALTER SCHEMA {}.{} OWNER TO {};",
445                        sq.database, sq.schema, current_user
446                    )?;
447                }
448                Ok(())
449            }
450            DatabaseValidationError::ClusterOwnershipMismatch {
451                unowned_clusters,
452                current_user,
453            } => {
454                let error_style = Style::new().bright_red().bold();
455                let help_style = Style::new().bright_cyan().bold();
456                writeln!(
457                    f,
458                    "{}: current role '{}' does not own the following production clusters",
459                    "error".if_supports_color(Stream::Stderr, |t| error_style.style(t)),
460                    current_user
461                )?;
462                writeln!(f)?;
463                for cluster in unowned_clusters {
464                    writeln!(f, "    - {}", cluster)?;
465                }
466                writeln!(f)?;
467                writeln!(
468                    f,
469                    "  {} Grant ownership of the clusters to the current role:",
470                    "help:".if_supports_color(Stream::Stderr, |t| help_style.style(t))
471                )?;
472                writeln!(f)?;
473                for cluster in unowned_clusters {
474                    writeln!(
475                        f,
476                        "    ALTER CLUSTER {} OWNER TO {};",
477                        cluster, current_user
478                    )?;
479                }
480                Ok(())
481            }
482            DatabaseValidationError::MissingSources(sources) => {
483                let error_style = Style::new().bright_red().bold();
484                writeln!(
485                    f,
486                    "{}: The following sources are referenced but do not exist:",
487                    "error".if_supports_color(Stream::Stderr, |t| error_style.style(t))
488                )?;
489                for source in sources {
490                    writeln!(f, "  - {}", source)?;
491                }
492                writeln!(f)?;
493                writeln!(
494                    f,
495                    "Please ensure all sources are created before running this command."
496                )?;
497                Ok(())
498            }
499            DatabaseValidationError::MissingConnections(connections) => {
500                let error_style = Style::new().bright_red().bold();
501                let help_style = Style::new().bright_cyan().bold();
502                writeln!(
503                    f,
504                    "{}: The following connections are referenced but do not exist:",
505                    "error".if_supports_color(Stream::Stderr, |t| error_style.style(t))
506                )?;
507                for conn in connections {
508                    writeln!(f, "  - {}", conn)?;
509                }
510                writeln!(f)?;
511                writeln!(
512                    f,
513                    "{} Connections are not managed by mz-deploy and must be created separately.",
514                    "help:".if_supports_color(Stream::Stderr, |t| help_style.style(t))
515                )?;
516                Ok(())
517            }
518            DatabaseValidationError::MissingSourceReferences(mismatches) => {
519                let help_style = Style::new().bright_cyan().bold();
520                writeln!(
521                    f,
522                    "The following tables reference upstream objects their source does not expose:"
523                )?;
524                for mismatch in mismatches {
525                    writeln!(f)?;
526                    writeln!(f, "  from {}:", mismatch.source)?;
527                    for table in &mismatch.tables {
528                        writeln!(f, "    - {} ({})", table.table, table.reference)?;
529                        if !table.suggestions.is_empty() {
530                            writeln!(f, "      did you mean: {}?", table.suggestions.join(", "))?;
531                        }
532                    }
533                    if let Some(reason) = &mismatch.unreadable {
534                        writeln!(f)?;
535                        writeln!(
536                            f,
537                            "    could not read the references for {}: {}",
538                            mismatch.source, reason
539                        )?;
540                    }
541                    writeln!(f)?;
542                    writeln!(
543                        f,
544                        "    {} exposes {} references. To see them all:",
545                        mismatch.source, mismatch.available_count
546                    )?;
547                    writeln!(
548                        f,
549                        "      SELECT namespace, name FROM mz_internal.mz_source_references"
550                    )?;
551                    writeln!(f, "      WHERE source_id = '{}';", mismatch.source_id)?;
552                }
553                writeln!(f)?;
554                writeln!(
555                    f,
556                    "{} Confirm the object exists upstream and that the source's publication,",
557                    "help:".if_supports_color(Stream::Stderr, |t| help_style.style(t))
558                )?;
559                writeln!(f, "      schema filter, and credentials include it.")?;
560                Ok(())
561            }
562            DatabaseValidationError::MissingTableDependencies {
563                objects_needing_tables,
564            } => {
565                let help_style = Style::new().bright_cyan().bold();
566                writeln!(
567                    f,
568                    "Objects depend on tables that don't exist in the database",
569                )?;
570                writeln!(f)?;
571                for (object, missing_tables) in objects_needing_tables {
572                    writeln!(
573                        f,
574                        "  {} {} depends on:",
575                        "×".if_supports_color(Stream::Stderr, |t| t.bright_red()),
576                        object
577                    )?;
578                    for table in missing_tables {
579                        writeln!(f, "    - {}", table)?;
580                    }
581                }
582                writeln!(f)?;
583                writeln!(
584                    f,
585                    "{} Run 'mz-deploy apply' to create the required tables first",
586                    "help:".if_supports_color(Stream::Stderr, |t| help_style.style(t))
587                )?;
588                Ok(())
589            }
590            DatabaseValidationError::QueryError(e) => {
591                write!(f, "Database query failed: {}", e)
592            }
593        }
594    }
595}
596
597impl std::error::Error for DatabaseValidationError {}
598
599/// Extract last 3 path components for display (database/schema/file.sql).
600///
601/// This helper is used in error formatting to show relative paths
602/// that are more readable than full absolute paths.
603pub fn format_relative_path(path: &std::path::Path) -> String {
604    let path_components: Vec<_> = path.components().collect();
605    let len = path_components.len();
606    if len >= 3 {
607        format!(
608            "{}/{}/{}",
609            path_components[len - 3].as_os_str().to_string_lossy(),
610            path_components[len - 2].as_os_str().to_string_lossy(),
611            path_components[len - 1].as_os_str().to_string_lossy()
612        )
613    } else {
614        path.display().to_string()
615    }
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use std::path::PathBuf;
622
623    fn object(schema: &str, object: &str) -> ObjectId {
624        ObjectId::new("app".to_string(), schema.to_string(), object.to_string())
625    }
626
627    #[mz_ore::test]
628    fn test_missing_source_references_error_display() {
629        let error =
630            DatabaseValidationError::MissingSourceReferences(vec![SourceReferenceMismatch {
631                source: object("ingest", "pg_source"),
632                source_id: "u1043".to_string(),
633                tables: vec![MissingSourceReference {
634                    table: object("ingest", "widgets"),
635                    reference: "public.widgest".to_string(),
636                    suggestions: vec!["public.widgets".to_string()],
637                }],
638                available_count: 1284,
639                unreadable: None,
640            }]);
641        let output = error.to_string();
642
643        assert!(
644            output.contains("app.ingest.widgets (public.widgest)"),
645            "{output}"
646        );
647        assert!(output.contains("from app.ingest.pg_source:"), "{output}");
648        assert!(output.contains("did you mean: public.widgets?"), "{output}");
649        assert!(
650            output.contains("app.ingest.pg_source exposes 1284 references"),
651            "{output}"
652        );
653        assert!(output.contains("WHERE source_id = 'u1043';"), "{output}");
654        assert!(!output.contains("could not read"), "{output}");
655    }
656
657    #[mz_ore::test]
658    fn test_missing_source_references_error_display_multiple_suggestions() {
659        let error =
660            DatabaseValidationError::MissingSourceReferences(vec![SourceReferenceMismatch {
661                source: object("ingest", "pg_source"),
662                source_id: "u1043".to_string(),
663                tables: vec![MissingSourceReference {
664                    table: object("ingest", "widgets"),
665                    reference: "sales.widgets".to_string(),
666                    suggestions: vec!["public.widgets".to_string(), "staging.widgets".to_string()],
667                }],
668                available_count: 2,
669                unreadable: None,
670            }]);
671        let output = error.to_string();
672
673        assert!(
674            output.contains("did you mean: public.widgets, staging.widgets?"),
675            "{output}"
676        );
677    }
678
679    #[mz_ore::test]
680    fn test_missing_source_references_error_display_without_suggestions() {
681        let error =
682            DatabaseValidationError::MissingSourceReferences(vec![SourceReferenceMismatch {
683                source: object("ingest", "pg_source"),
684                source_id: "u1043".to_string(),
685                tables: vec![MissingSourceReference {
686                    table: object("ingest", "widgets"),
687                    reference: "public.widgets".to_string(),
688                    suggestions: Vec::new(),
689                }],
690                available_count: 3,
691                unreadable: None,
692            }]);
693        let output = error.to_string();
694
695        // Nothing came close, so the query and the upstream advice are all the
696        // error can offer.
697        assert!(!output.contains("did you mean"), "{output}");
698        assert!(
699            output.contains("app.ingest.pg_source exposes 3 references"),
700            "{output}"
701        );
702        assert!(
703            output.contains("Confirm the object exists upstream"),
704            "{output}"
705        );
706    }
707
708    #[mz_ore::test]
709    fn test_missing_source_references_error_display_unreadable() {
710        let error =
711            DatabaseValidationError::MissingSourceReferences(vec![SourceReferenceMismatch {
712                source: object("ingest", "pg_source"),
713                source_id: "u1043".to_string(),
714                tables: vec![MissingSourceReference {
715                    table: object("ingest", "widgets"),
716                    reference: "public.widgets".to_string(),
717                    suggestions: Vec::new(),
718                }],
719                available_count: 12,
720                unreadable: Some("permission denied".to_string()),
721            }]);
722        let output = error.to_string();
723
724        assert!(
725            output.contains(
726                "could not read the references for app.ingest.pg_source: permission denied"
727            ),
728            "{output}"
729        );
730    }
731
732    #[mz_ore::test]
733    fn test_missing_table_dependencies_error_display() {
734        let error = DatabaseValidationError::MissingTableDependencies {
735            objects_needing_tables: vec![
736                (
737                    ObjectId::new(
738                        "materialize".to_string(),
739                        "public".to_string(),
740                        "my_view".to_string(),
741                    ),
742                    vec![
743                        ObjectId::new(
744                            "materialize".to_string(),
745                            "tables".to_string(),
746                            "users".to_string(),
747                        ),
748                        ObjectId::new(
749                            "materialize".to_string(),
750                            "tables".to_string(),
751                            "orders".to_string(),
752                        ),
753                    ],
754                ),
755                (
756                    ObjectId::new(
757                        "materialize".to_string(),
758                        "public".to_string(),
759                        "another_view".to_string(),
760                    ),
761                    vec![ObjectId::new(
762                        "materialize".to_string(),
763                        "tables".to_string(),
764                        "products".to_string(),
765                    )],
766                ),
767            ],
768        };
769
770        let error_string = format!("{}", error);
771
772        // Check that error message contains key elements
773        assert!(error_string.contains("Objects depend on tables that don't exist"));
774        assert!(error_string.contains("materialize.public.my_view"));
775        assert!(error_string.contains("materialize.tables.users"));
776        assert!(error_string.contains("materialize.tables.orders"));
777        assert!(error_string.contains("materialize.public.another_view"));
778        assert!(error_string.contains("materialize.tables.products"));
779        assert!(error_string.contains("help"));
780        assert!(error_string.contains("mz-deploy apply"));
781    }
782
783    #[mz_ore::test]
784    fn test_format_relative_path() {
785        let path = PathBuf::from("/home/user/project/database/schema/file.sql");
786        assert_eq!(format_relative_path(&path), "database/schema/file.sql");
787
788        let short_path = PathBuf::from("file.sql");
789        assert_eq!(format_relative_path(&short_path), "file.sql");
790    }
791
792    #[mz_ore::test]
793    fn test_format_relative_path_exactly_three_components() {
794        let path = PathBuf::from("database/schema/file.sql");
795        assert_eq!(format_relative_path(&path), "database/schema/file.sql");
796    }
797
798    #[mz_ore::test]
799    fn test_format_relative_path_two_components() {
800        let path = PathBuf::from("schema/file.sql");
801        assert_eq!(format_relative_path(&path), "schema/file.sql");
802    }
803
804    #[mz_ore::test]
805    fn test_missing_databases_error_display() {
806        let error =
807            DatabaseValidationError::MissingDatabases(vec!["db1".to_string(), "db2".to_string()]);
808        let error_string = format!("{}", error);
809        assert!(error_string.contains("Missing databases"));
810        assert!(error_string.contains("db1"));
811        assert!(error_string.contains("db2"));
812    }
813
814    #[mz_ore::test]
815    fn test_missing_schemas_error_display() {
816        let error = DatabaseValidationError::MissingSchemas(vec![
817            SchemaQualifier::new("db1".to_string(), "schema1".to_string()),
818            SchemaQualifier::new("db2".to_string(), "schema2".to_string()),
819        ]);
820        let error_string = format!("{}", error);
821        assert!(error_string.contains("Missing schemas"));
822        assert!(error_string.contains("db1.schema1"));
823        assert!(error_string.contains("db2.schema2"));
824    }
825
826    #[mz_ore::test]
827    fn test_missing_clusters_error_display() {
828        let error = DatabaseValidationError::MissingClusters(vec![
829            "cluster1".to_string(),
830            "cluster2".to_string(),
831        ]);
832        let error_string = format!("{}", error);
833        assert!(error_string.contains("Missing clusters"));
834        assert!(error_string.contains("cluster1"));
835        assert!(error_string.contains("cluster2"));
836    }
837
838    #[mz_ore::test]
839    fn test_cluster_conflict_error_display() {
840        let error = DatabaseValidationError::ClusterConflict {
841            cluster_name: "shared_cluster".to_string(),
842            compute_objects: vec!["my_index".to_string(), "my_mv".to_string()],
843            storage_objects: vec!["my_source".to_string()],
844        };
845        let error_string = format!("{}", error);
846        assert!(error_string.contains("shared_cluster"));
847        assert!(error_string.contains("storage and computation objects"));
848        assert!(error_string.contains("my_index"));
849        assert!(error_string.contains("my_mv"));
850        assert!(error_string.contains("my_source"));
851        assert!(error_string.contains("help"));
852    }
853
854    #[mz_ore::test]
855    fn test_insufficient_privileges_error_display() {
856        let error = DatabaseValidationError::InsufficientPrivileges {
857            missing_database_usage: vec!["db1".to_string(), "db2".to_string()],
858            missing_createcluster: true,
859        };
860        let error_string = format!("{}", error);
861        assert!(error_string.contains("insufficient privileges"));
862        assert!(error_string.contains("db1"));
863        assert!(error_string.contains("db2"));
864        assert!(error_string.contains("CREATECLUSTER"));
865        assert!(error_string.contains("GRANT"));
866    }
867
868    #[mz_ore::test]
869    fn test_insufficient_privileges_only_database() {
870        let error = DatabaseValidationError::InsufficientPrivileges {
871            missing_database_usage: vec!["db1".to_string()],
872            missing_createcluster: false,
873        };
874        let error_string = format!("{}", error);
875        assert!(error_string.contains("db1"));
876        assert!(!error_string.contains("CREATECLUSTER ON SYSTEM"));
877    }
878
879    #[mz_ore::test]
880    fn test_missing_sources_error_display() {
881        let error = DatabaseValidationError::MissingSources(vec![ObjectId::new(
882            "materialize".to_string(),
883            "public".to_string(),
884            "kafka_source".to_string(),
885        )]);
886        let error_string = format!("{}", error);
887        assert!(error_string.contains("sources are referenced but do not exist"));
888        assert!(error_string.contains("materialize.public.kafka_source"));
889    }
890
891    #[mz_ore::test]
892    fn test_multiple_validation_errors_display() {
893        let error = DatabaseValidationError::Multiple {
894            databases: vec!["missing_db".to_string()],
895            schemas: vec![SchemaQualifier::new(
896                "db".to_string(),
897                "missing_schema".to_string(),
898            )],
899            clusters: vec!["missing_cluster".to_string()],
900            compilation_errors: vec![],
901        };
902        let error_string = format!("{}", error);
903        assert!(error_string.contains("missing_db"));
904        assert!(error_string.contains("db.missing_schema"));
905        assert!(error_string.contains("missing_cluster"));
906    }
907
908    #[mz_ore::test]
909    fn test_connection_error_display() {
910        let error = ConnectionError::Message("test error message".to_string());
911        let error_string = format!("{}", error);
912        assert_eq!(error_string, "test error message");
913    }
914
915    #[mz_ore::test]
916    fn test_connection_error_cluster_not_found() {
917        let error = ConnectionError::ClusterNotFound {
918            name: "missing_cluster".to_string(),
919        };
920        let error_string = format!("{}", error);
921        assert!(error_string.contains("missing_cluster"));
922        assert!(error_string.contains("not found"));
923    }
924
925    #[mz_ore::test]
926    fn test_connection_error_deployment_already_exists() {
927        let error = ConnectionError::DeploymentAlreadyExists {
928            deploy_id: "staging_123".to_string(),
929        };
930        let error_string = format!("{}", error);
931        assert!(error_string.contains("staging_123"));
932        assert!(error_string.contains("already exists"));
933    }
934
935    #[mz_ore::test]
936    fn test_connection_error_deployment_not_found() {
937        let error = ConnectionError::DeploymentNotFound {
938            deploy_id: "nonexistent".to_string(),
939        };
940        let error_string = format!("{}", error);
941        assert!(error_string.contains("nonexistent"));
942        assert!(error_string.contains("not found"));
943    }
944
945    #[mz_ore::test]
946    fn test_connection_error_deployment_already_promoted() {
947        let error = ConnectionError::DeploymentAlreadyPromoted {
948            deploy_id: "prod_deploy".to_string(),
949        };
950        let error_string = format!("{}", error);
951        assert!(error_string.contains("prod_deploy"));
952        assert!(error_string.contains("already been promoted"));
953    }
954
955    #[mz_ore::test]
956    fn test_database_validation_error_is_error_trait() {
957        // Verify that DatabaseValidationError implements std::error::Error
958        let error: Box<dyn std::error::Error> =
959            Box::new(DatabaseValidationError::MissingDatabases(vec![]));
960        assert!(error.to_string().contains("Missing databases"));
961    }
962}