Skip to main content

mz_deploy/project/error/
validation.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//! Validation errors for semantic validation of project definitions.
11//!
12//! This module defines errors that occur during semantic validation of SQL
13//! statements, including object name mismatches, unsupported statement types,
14//! and constraint violations. Errors carry rich contextual information
15//! (file path, SQL statement) for user-friendly diagnostics.
16
17use crate::types::ObjectKind;
18use std::fmt;
19use std::path::PathBuf;
20
21/// Contextual information about where an error occurred.
22///
23/// This struct wraps error variants with additional context about the file
24/// and SQL statement that caused the error.
25#[derive(Debug, Clone)]
26pub struct ErrorContext {
27    /// The file where the error occurred
28    pub file: PathBuf,
29    /// The SQL statement that caused the error, if available
30    pub sql_statement: Option<String>,
31    /// Byte offset of the offending statement within the file, if available.
32    ///
33    /// Used by the LSP to position diagnostics at the correct line/column
34    /// instead of defaulting to `(0, 0)`. `None` for file-level errors
35    /// (e.g., missing CREATE statement) where no single statement is at fault.
36    pub byte_offset: Option<usize>,
37}
38
39/// A validation error with contextual information.
40///
41/// This struct wraps a `ValidationErrorKind` with context about where
42/// the error occurred (file path, SQL statement).
43#[derive(Debug)]
44pub struct ValidationError {
45    /// The underlying error kind
46    pub kind: ValidationErrorKind,
47    /// Context about where the error occurred
48    pub context: ErrorContext,
49}
50
51impl fmt::Display for ValidationError {
52    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
53        write!(f, "{}", self.kind.message())
54    }
55}
56
57impl std::error::Error for ValidationError {
58    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
59        None
60    }
61}
62
63impl ValidationError {
64    /// Create a new validation error with context
65    pub fn with_context(kind: ValidationErrorKind, context: ErrorContext) -> Self {
66        Self { kind, context }
67    }
68
69    /// Create a new validation error with just a file path
70    pub fn with_file(kind: ValidationErrorKind, file: PathBuf) -> Self {
71        Self {
72            kind,
73            context: ErrorContext {
74                file,
75                sql_statement: None,
76                byte_offset: None,
77            },
78        }
79    }
80
81    /// Create a new validation error with file and SQL statement
82    pub fn with_file_and_sql(kind: ValidationErrorKind, file: PathBuf, sql: String) -> Self {
83        Self {
84            kind,
85            context: ErrorContext {
86                file,
87                sql_statement: Some(sql),
88                byte_offset: None,
89            },
90        }
91    }
92
93    /// Create a new validation error positioned at a specific byte offset.
94    ///
95    /// Used for errors that point to a specific statement. The byte offset
96    /// lets the LSP place the diagnostic on the correct line/column.
97    pub fn with_file_and_offset(
98        kind: ValidationErrorKind,
99        file: PathBuf,
100        byte_offset: usize,
101    ) -> Self {
102        Self {
103            kind,
104            context: ErrorContext {
105                file,
106                sql_statement: None,
107                byte_offset: Some(byte_offset),
108            },
109        }
110    }
111
112    /// Create a new validation error with file, SQL statement, and byte offset.
113    ///
114    /// The most precise constructor: the SQL is shown in CLI output, the byte
115    /// offset positions the diagnostic in the LSP.
116    pub fn with_file_sql_and_offset(
117        kind: ValidationErrorKind,
118        file: PathBuf,
119        sql: String,
120        byte_offset: usize,
121    ) -> Self {
122        Self {
123            kind,
124            context: ErrorContext {
125                file,
126                sql_statement: Some(sql),
127                byte_offset: Some(byte_offset),
128            },
129        }
130    }
131}
132
133/// The specific kind of validation error that occurred.
134///
135/// This enum contains the actual error variants without contextual information.
136/// Context (file path, SQL statement) is stored in the wrapping `ValidationError`.
137#[derive(Debug)]
138pub enum ValidationErrorKind {
139    /// A file contains multiple primary CREATE statements
140    MultipleMainStatements { object_name: String },
141    /// A file contains no primary CREATE statement
142    NoMainStatement { object_name: String },
143    /// Object name in statement doesn't match file name
144    ObjectNameMismatch { declared: String, expected: String },
145    /// Schema qualifier in statement doesn't match directory
146    SchemaMismatch { declared: String, expected: String },
147    /// Database qualifier in statement doesn't match directory
148    DatabaseMismatch { declared: String, expected: String },
149    /// An index references a different object
150    IndexReferenceMismatch {
151        referenced: String,
152        expected: String,
153    },
154    /// A grant references a different object
155    GrantReferenceMismatch {
156        referenced: String,
157        expected: String,
158    },
159    /// A comment references a different object
160    CommentReferenceMismatch {
161        referenced: String,
162        expected: String,
163    },
164    /// A column comment references a different table
165    ColumnCommentReferenceMismatch {
166        referenced: String,
167        expected: String,
168    },
169    /// Comment object type doesn't match actual object type
170    CommentTypeMismatch {
171        comment_type: String,
172        object_type: String,
173    },
174    /// Grant object type doesn't match actual object type
175    GrantTypeMismatch {
176        grant_type: String,
177        expected_type: String,
178    },
179    /// Unsupported statement type in object file
180    UnsupportedStatement {
181        object_name: String,
182        statement_type: String,
183    },
184    /// Unsupported grant type
185    ClusterGrantUnsupported,
186    /// Grant doesn't target specific object
187    GrantMustTargetObject,
188    /// System grant not supported
189    SystemGrantUnsupported,
190    /// Unsupported comment type
191    UnsupportedCommentType,
192    /// No object type could be determined
193    NoObjectType,
194    /// Failed to extract schema name from file path
195    SchemaExtractionFailed,
196    /// Failed to extract database name from file path
197    DatabaseExtractionFailed,
198    /// Invalid identifier name (contains invalid characters or format)
199    InvalidIdentifier { name: String, reason: String },
200    /// Index missing required IN CLUSTER clause
201    IndexMissingCluster { index_name: String },
202    /// Index defined on a storage object (table or source), which is unsupported.
203    IndexOnStorageObject {
204        object_type: String,
205        object_name: String,
206        index_name: String,
207    },
208    /// Materialized view missing required IN CLUSTER clause
209    MaterializedViewMissingCluster { view_name: String },
210    /// Sink missing required IN CLUSTER clause
211    SinkMissingCluster { sink_name: String },
212    /// Source missing required IN CLUSTER clause
213    SourceMissingCluster { source_name: String },
214    /// Source uses external references (FOR TABLES/FOR SCHEMAS/FOR ALL TABLES)
215    SourceExternalReferences { source_name: String },
216    /// Invalid statement type in database mod file
217    InvalidDatabaseModStatement {
218        statement_type: String,
219        database_name: String,
220    },
221    /// Comment in database mod file targets wrong object
222    DatabaseModCommentTargetMismatch {
223        target: String,
224        database_name: String,
225    },
226    /// Grant in database mod file targets wrong object
227    DatabaseModGrantTargetMismatch {
228        target: String,
229        database_name: String,
230    },
231    /// Invalid statement type in schema mod file
232    InvalidSchemaModStatement {
233        statement_type: String,
234        schema_name: String,
235    },
236    /// Comment in schema mod file targets wrong object
237    SchemaModCommentTargetMismatch { target: String, schema_name: String },
238    /// Grant in schema mod file targets wrong object
239    SchemaModGrantTargetMismatch { target: String, schema_name: String },
240    /// SET variable in schema mod file has invalid value
241    InvalidSetVariable { variable: String, value: String },
242    /// ALTER DEFAULT PRIVILEGES in database mod requires IN DATABASE scope
243    AlterDefaultPrivilegesRequiresDatabaseScope { database_name: String },
244    /// ALTER DEFAULT PRIVILEGES in schema mod requires IN SCHEMA scope
245    AlterDefaultPrivilegesRequiresSchemaScope { schema_name: String },
246    /// ALTER DEFAULT PRIVILEGES IN DATABASE references wrong database
247    AlterDefaultPrivilegesDatabaseMismatch {
248        referenced: String,
249        expected: String,
250    },
251    /// ALTER DEFAULT PRIVILEGES cannot use IN SCHEMA in database mod
252    AlterDefaultPrivilegesSchemaNotAllowed { database_name: String },
253    /// ALTER DEFAULT PRIVILEGES cannot use IN DATABASE in schema mod
254    AlterDefaultPrivilegesDatabaseNotAllowed { schema_name: String },
255    /// ALTER DEFAULT PRIVILEGES IN SCHEMA references wrong schema
256    AlterDefaultPrivilegesSchemaMismatch {
257        referenced: String,
258        expected: String,
259    },
260    /// Storage objects (tables/sinks) and computation objects (views/MVs) cannot share a schema
261    StorageAndComputationObjectsInSameSchema {
262        schema_name: String,
263        storage_objects: Vec<String>,
264        computation_objects: Vec<String>,
265    },
266    /// Replacement schema contains non-MV objects
267    ReplacementSchemaNonMvObject {
268        database: String,
269        schema: String,
270        object_name: String,
271        object_type: ObjectKind,
272    },
273    /// Invalid statement type in cluster definition file
274    InvalidClusterStatement {
275        statement_type: String,
276        cluster_name: String,
277    },
278    /// Cluster name in CREATE CLUSTER doesn't match filename
279    ClusterNameMismatch { declared: String, expected: String },
280    /// Cluster file missing required CREATE CLUSTER statement
281    ClusterMissingCreateStatement { cluster_name: String },
282    /// Cluster file contains multiple CREATE CLUSTER statements
283    ClusterMultipleCreateStatements { cluster_name: String },
284    /// GRANT in cluster file targets a different cluster
285    ClusterGrantTargetMismatch {
286        target: String,
287        cluster_name: String,
288    },
289    /// COMMENT in cluster file targets a different cluster
290    ClusterCommentTargetMismatch {
291        target: String,
292        cluster_name: String,
293    },
294    /// Invalid statement type in role definition file
295    InvalidRoleStatement {
296        statement_type: String,
297        role_name: String,
298    },
299    /// Role name in CREATE ROLE doesn't match filename
300    RoleNameMismatch { declared: String, expected: String },
301    /// Role file missing required CREATE ROLE statement
302    RoleMissingCreateStatement { role_name: String },
303    /// Role file contains multiple CREATE ROLE statements
304    RoleMultipleCreateStatements { role_name: String },
305    /// ALTER ROLE in role file targets a different role
306    RoleAlterTargetMismatch { target: String, role_name: String },
307    /// GRANT ROLE in role file targets a different role
308    RoleGrantTargetMismatch { target: String, role_name: String },
309    /// COMMENT in role file targets a different role
310    RoleCommentTargetMismatch { target: String, role_name: String },
311    /// Invalid statement type in network policy definition file
312    InvalidNetworkPolicyStatement {
313        statement_type: String,
314        policy_name: String,
315    },
316    /// Network policy name in CREATE NETWORK POLICY doesn't match filename
317    NetworkPolicyNameMismatch { declared: String, expected: String },
318    /// Network policy file missing required CREATE NETWORK POLICY statement
319    NetworkPolicyMissingCreateStatement { policy_name: String },
320    /// Network policy file contains multiple CREATE NETWORK POLICY statements
321    NetworkPolicyMultipleCreateStatements { policy_name: String },
322    /// GRANT in network policy file targets a different policy
323    NetworkPolicyGrantTargetMismatch { target: String, policy_name: String },
324    /// COMMENT in network policy file targets a different policy
325    NetworkPolicyCommentTargetMismatch { target: String, policy_name: String },
326    /// Profile variants of an object have different primary statement types
327    ProfileObjectTypeMismatch {
328        object_name: String,
329        default_type: String,
330        override_profile: String,
331        override_type: String,
332        default_path: PathBuf,
333        override_path: PathBuf,
334    },
335    /// Views and materialized views cannot have profile-specific overrides
336    ProfileOverrideNotAllowed {
337        object_name: String,
338        object_type: String,
339        override_profile: String,
340        override_path: PathBuf,
341    },
342}
343
344impl ValidationErrorKind {
345    /// Get the short error message for this error kind
346    pub(crate) fn message(&self) -> String {
347        match self {
348            Self::MultipleMainStatements { object_name } => {
349                format!(
350                    "multiple main CREATE statements found for object '{}'",
351                    object_name
352                )
353            }
354            Self::NoMainStatement { object_name } => {
355                format!(
356                    "no main CREATE statement found for object '{}'",
357                    object_name
358                )
359            }
360            Self::ObjectNameMismatch { declared, expected } => {
361                format!(
362                    "object name mismatch: declared '{}', expected '{}'",
363                    declared, expected
364                )
365            }
366            Self::SchemaMismatch { declared, expected } => {
367                format!(
368                    "schema qualifier mismatch: declared '{}', expected '{}'",
369                    declared, expected
370                )
371            }
372            Self::DatabaseMismatch { declared, expected } => {
373                format!(
374                    "database qualifier mismatch: declared '{}', expected '{}'",
375                    declared, expected
376                )
377            }
378            Self::IndexReferenceMismatch {
379                referenced,
380                expected,
381            } => {
382                format!(
383                    "INDEX references wrong object: '{}' instead of '{}'",
384                    referenced, expected
385                )
386            }
387            Self::GrantReferenceMismatch {
388                referenced,
389                expected,
390            } => {
391                format!(
392                    "GRANT references wrong object: '{}' instead of '{}'",
393                    referenced, expected
394                )
395            }
396            Self::CommentReferenceMismatch {
397                referenced,
398                expected,
399            } => {
400                format!(
401                    "COMMENT references wrong object: '{}' instead of '{}'",
402                    referenced, expected
403                )
404            }
405            Self::ColumnCommentReferenceMismatch {
406                referenced,
407                expected,
408            } => {
409                format!(
410                    "column COMMENT references wrong table: '{}' instead of '{}'",
411                    referenced, expected
412                )
413            }
414            Self::CommentTypeMismatch {
415                comment_type,
416                object_type,
417            } => {
418                format!(
419                    "COMMENT uses wrong object type: {} instead of {}",
420                    comment_type, object_type
421                )
422            }
423            Self::GrantTypeMismatch {
424                grant_type,
425                expected_type,
426            } => {
427                format!(
428                    "GRANT uses incorrect object type: GRANT ON {} instead of GRANT ON {}",
429                    grant_type, expected_type
430                )
431            }
432            Self::UnsupportedStatement {
433                object_name,
434                statement_type,
435            } => {
436                format!(
437                    "unsupported statement type in object '{}': {}",
438                    object_name, statement_type
439                )
440            }
441            Self::ClusterGrantUnsupported => "CLUSTER grants are not supported".to_string(),
442            Self::GrantMustTargetObject => "GRANT must target a specific object".to_string(),
443            Self::SystemGrantUnsupported => "SYSTEM grants are not supported".to_string(),
444            Self::UnsupportedCommentType => "unsupported COMMENT object type".to_string(),
445            Self::NoObjectType => "could not determine object type".to_string(),
446            Self::SchemaExtractionFailed => {
447                "failed to extract schema name from file path".to_string()
448            }
449            Self::DatabaseExtractionFailed => {
450                "failed to extract database name from file path".to_string()
451            }
452            Self::InvalidIdentifier { name, reason } => {
453                format!("invalid identifier '{}': {}", name, reason)
454            }
455            Self::IndexMissingCluster { index_name } => {
456                format!(
457                    "index '{}' is missing required IN CLUSTER clause",
458                    index_name
459                )
460            }
461            Self::IndexOnStorageObject {
462                object_type,
463                object_name,
464                index_name,
465            } => {
466                format!(
467                    "index '{}' is not supported on {} '{}'",
468                    index_name, object_type, object_name
469                )
470            }
471            Self::MaterializedViewMissingCluster { view_name } => {
472                format!(
473                    "materialized view '{}' is missing required IN CLUSTER clause",
474                    view_name
475                )
476            }
477            Self::SinkMissingCluster { sink_name } => {
478                format!("sink '{}' is missing required IN CLUSTER clause", sink_name)
479            }
480            Self::SourceMissingCluster { source_name } => {
481                format!(
482                    "source '{}' is missing required IN CLUSTER clause",
483                    source_name
484                )
485            }
486            Self::SourceExternalReferences { source_name } => {
487                format!(
488                    "source '{}' uses FOR TABLES, FOR SCHEMAS, or FOR ALL TABLES which is not supported",
489                    source_name
490                )
491            }
492            Self::InvalidDatabaseModStatement {
493                statement_type,
494                database_name,
495            } => {
496                format!(
497                    "invalid statement type in database mod file '{}': {}. Only COMMENT ON DATABASE, GRANT ON DATABASE, and ALTER DEFAULT PRIVILEGES are allowed",
498                    database_name, statement_type
499                )
500            }
501            Self::DatabaseModCommentTargetMismatch {
502                target,
503                database_name,
504            } => {
505                format!(
506                    "comment in database mod file must target the database itself. Expected COMMENT ON DATABASE '{}', but found COMMENT ON {}",
507                    database_name, target
508                )
509            }
510            Self::DatabaseModGrantTargetMismatch {
511                target,
512                database_name,
513            } => {
514                format!(
515                    "grant in database mod file must target the database itself. Expected GRANT ON DATABASE '{}', but found GRANT ON {}",
516                    database_name, target
517                )
518            }
519            Self::InvalidSchemaModStatement {
520                statement_type,
521                schema_name,
522            } => {
523                format!(
524                    "invalid statement type in schema mod file '{}': {}. Only COMMENT ON SCHEMA, GRANT ON SCHEMA, ALTER DEFAULT PRIVILEGES, and SET api = stable are allowed",
525                    schema_name, statement_type
526                )
527            }
528            Self::SchemaModCommentTargetMismatch {
529                target,
530                schema_name,
531            } => {
532                format!(
533                    "comment in schema mod file must target the schema itself. Expected COMMENT ON SCHEMA '{}', but found COMMENT ON {}",
534                    schema_name, target
535                )
536            }
537            Self::SchemaModGrantTargetMismatch {
538                target,
539                schema_name,
540            } => {
541                format!(
542                    "grant in schema mod file must target the schema itself. Expected GRANT ON SCHEMA '{}', but found GRANT ON {}",
543                    schema_name, target
544                )
545            }
546            Self::InvalidSetVariable { variable, value } => {
547                format!("invalid value for SET {}: {}", variable, value)
548            }
549            Self::AlterDefaultPrivilegesRequiresDatabaseScope { database_name } => {
550                format!(
551                    "ALTER DEFAULT PRIVILEGES in database mod file '{}' must specify IN DATABASE",
552                    database_name
553                )
554            }
555            Self::AlterDefaultPrivilegesRequiresSchemaScope { schema_name } => {
556                format!(
557                    "ALTER DEFAULT PRIVILEGES in schema mod file '{}' must specify IN SCHEMA",
558                    schema_name
559                )
560            }
561            Self::AlterDefaultPrivilegesDatabaseMismatch {
562                referenced,
563                expected,
564            } => {
565                format!(
566                    "ALTER DEFAULT PRIVILEGES IN DATABASE references wrong database: '{}' instead of '{}'",
567                    referenced, expected
568                )
569            }
570            Self::AlterDefaultPrivilegesSchemaNotAllowed { database_name } => {
571                format!(
572                    "ALTER DEFAULT PRIVILEGES in database mod file '{}' cannot use IN SCHEMA",
573                    database_name
574                )
575            }
576            Self::AlterDefaultPrivilegesDatabaseNotAllowed { schema_name } => {
577                format!(
578                    "ALTER DEFAULT PRIVILEGES in schema mod file '{}' cannot use IN DATABASE",
579                    schema_name
580                )
581            }
582            Self::AlterDefaultPrivilegesSchemaMismatch {
583                referenced,
584                expected,
585            } => {
586                format!(
587                    "ALTER DEFAULT PRIVILEGES IN SCHEMA references wrong schema: '{}' instead of '{}'",
588                    referenced, expected
589                )
590            }
591            Self::StorageAndComputationObjectsInSameSchema {
592                schema_name,
593                storage_objects,
594                computation_objects,
595            } => {
596                format!(
597                    "schema '{}' contains both storage objects (tables/sinks) and computation objects (views/materialized views)\n  \
598                     Storage objects (tables/sinks): [{}]\n  \
599                     Computation objects (views/MVs): [{}]",
600                    schema_name,
601                    storage_objects.join(", "),
602                    computation_objects.join(", ")
603                )
604            }
605            Self::ReplacementSchemaNonMvObject {
606                database,
607                schema,
608                object_name,
609                object_type: kind,
610            } => {
611                format!(
612                    "replacement schema '{}.{}' contains non-materialized-view object '{}' (kind: {})",
613                    database, schema, object_name, kind
614                )
615            }
616            Self::InvalidClusterStatement {
617                statement_type,
618                cluster_name,
619            } => {
620                format!(
621                    "invalid statement type in cluster file '{}': {}. Only CREATE CLUSTER, GRANT ON CLUSTER, and COMMENT ON CLUSTER are allowed",
622                    cluster_name, statement_type
623                )
624            }
625            Self::ClusterNameMismatch { declared, expected } => {
626                format!(
627                    "cluster name mismatch: declared '{}', expected '{}'",
628                    declared, expected
629                )
630            }
631            Self::ClusterMissingCreateStatement { cluster_name } => {
632                format!(
633                    "no CREATE CLUSTER statement found in cluster file '{}'",
634                    cluster_name
635                )
636            }
637            Self::ClusterMultipleCreateStatements { cluster_name } => {
638                format!(
639                    "multiple CREATE CLUSTER statements found in cluster file '{}'",
640                    cluster_name
641                )
642            }
643            Self::ClusterGrantTargetMismatch {
644                target,
645                cluster_name,
646            } => {
647                format!(
648                    "GRANT in cluster file '{}' targets wrong cluster: '{}'",
649                    cluster_name, target
650                )
651            }
652            Self::ClusterCommentTargetMismatch {
653                target,
654                cluster_name,
655            } => {
656                format!(
657                    "COMMENT in cluster file '{}' targets wrong cluster: '{}'",
658                    cluster_name, target
659                )
660            }
661            Self::InvalidRoleStatement {
662                statement_type,
663                role_name,
664            } => {
665                format!(
666                    "invalid statement type in role file '{}': {}. Only CREATE ROLE, ALTER ROLE, GRANT ROLE, and COMMENT ON ROLE are allowed",
667                    role_name, statement_type
668                )
669            }
670            Self::RoleNameMismatch { declared, expected } => {
671                format!(
672                    "role name mismatch: declared '{}', expected '{}'",
673                    declared, expected
674                )
675            }
676            Self::RoleMissingCreateStatement { role_name } => {
677                format!(
678                    "no CREATE ROLE statement found in role file '{}'",
679                    role_name
680                )
681            }
682            Self::RoleMultipleCreateStatements { role_name } => {
683                format!(
684                    "multiple CREATE ROLE statements found in role file '{}'",
685                    role_name
686                )
687            }
688            Self::RoleAlterTargetMismatch { target, role_name } => {
689                format!(
690                    "ALTER ROLE in role file '{}' targets wrong role: '{}'",
691                    role_name, target
692                )
693            }
694            Self::RoleGrantTargetMismatch { target, role_name } => {
695                format!(
696                    "GRANT ROLE in role file '{}' targets wrong role: '{}'",
697                    role_name, target
698                )
699            }
700            Self::RoleCommentTargetMismatch { target, role_name } => {
701                format!(
702                    "COMMENT in role file '{}' targets wrong role: '{}'",
703                    role_name, target
704                )
705            }
706            Self::InvalidNetworkPolicyStatement {
707                statement_type,
708                policy_name,
709            } => {
710                format!(
711                    "invalid statement type in network policy file '{}': {}. Only CREATE NETWORK POLICY, GRANT ON NETWORK POLICY, and COMMENT ON NETWORK POLICY are allowed",
712                    policy_name, statement_type
713                )
714            }
715            Self::NetworkPolicyNameMismatch { declared, expected } => {
716                format!(
717                    "network policy name mismatch: declared '{}', expected '{}'",
718                    declared, expected
719                )
720            }
721            Self::NetworkPolicyMissingCreateStatement { policy_name } => {
722                format!(
723                    "no CREATE NETWORK POLICY statement found in network policy file '{}'",
724                    policy_name
725                )
726            }
727            Self::NetworkPolicyMultipleCreateStatements { policy_name } => {
728                format!(
729                    "multiple CREATE NETWORK POLICY statements found in network policy file '{}'",
730                    policy_name
731                )
732            }
733            Self::NetworkPolicyGrantTargetMismatch {
734                target,
735                policy_name,
736            } => {
737                format!(
738                    "GRANT in network policy file '{}' targets wrong policy: '{}'",
739                    policy_name, target
740                )
741            }
742            Self::NetworkPolicyCommentTargetMismatch {
743                target,
744                policy_name,
745            } => {
746                format!(
747                    "COMMENT in network policy file '{}' targets wrong policy: '{}'",
748                    policy_name, target
749                )
750            }
751            Self::ProfileObjectTypeMismatch {
752                object_name,
753                default_type,
754                override_profile,
755                override_type,
756                ..
757            } => {
758                format!(
759                    "profile variant type mismatch for object '{}': default is '{}' but '{}' override is '{}'",
760                    object_name, default_type, override_profile, override_type
761                )
762            }
763            Self::ProfileOverrideNotAllowed {
764                object_name,
765                object_type,
766                override_profile,
767                ..
768            } => {
769                format!(
770                    "{} '{}' cannot have profile-specific overrides (found '{}' override)",
771                    object_type, object_name, override_profile
772                )
773            }
774        }
775    }
776
777    /// Get the help text for this error kind
778    pub(crate) fn help(&self) -> Option<String> {
779        match self {
780            Self::MultipleMainStatements { .. } => {
781                Some("each file must contain exactly one primary CREATE statement (TABLE, VIEW, SOURCE, etc.)".to_string())
782            }
783            Self::NoMainStatement { .. } => {
784                Some("each file must contain exactly one primary CREATE statement (CREATE TABLE, CREATE VIEW, etc.)".to_string())
785            }
786            Self::ObjectNameMismatch { .. } => {
787                Some("the object name in your CREATE statement must match the .sql file name".to_string())
788            }
789            Self::SchemaMismatch { .. } => {
790                Some("the schema in your qualified object name must match the directory name".to_string())
791            }
792            Self::DatabaseMismatch { .. } => {
793                Some("the database in your qualified object name must match the directory name".to_string())
794            }
795            Self::IndexReferenceMismatch { .. } => {
796                Some("indexes must be defined in the same file as the object they're created on".to_string())
797            }
798            Self::GrantReferenceMismatch { .. } => {
799                Some("grants must be defined in the same file as the object they apply to".to_string())
800            }
801            Self::CommentReferenceMismatch { .. } => {
802                Some("comments must be defined in the same file as the object they describe".to_string())
803            }
804            Self::ColumnCommentReferenceMismatch { .. } => {
805                Some("column comments must reference columns in the object defined in the file".to_string())
806            }
807            Self::CommentTypeMismatch { .. } => {
808                Some("the COMMENT statement must use the correct object type (TABLE, VIEW, etc.)".to_string())
809            }
810            Self::GrantTypeMismatch { .. } => {
811                Some("the GRANT statement must use the correct object type that matches the object defined in the file".to_string())
812            }
813            Self::UnsupportedStatement { .. } => {
814                Some("only CREATE, INDEX, GRANT, and COMMENT statements are supported in object files".to_string())
815            }
816            Self::ClusterGrantUnsupported => {
817                Some("use GRANT ON specific objects instead of CLUSTER".to_string())
818            }
819            Self::GrantMustTargetObject => {
820                Some("use GRANT ON objectname instead of GRANT ON ALL TABLES or similar".to_string())
821            }
822            Self::SystemGrantUnsupported => {
823                Some("use GRANT ON specific objects instead of SYSTEM".to_string())
824            }
825            Self::UnsupportedCommentType => {
826                Some("only comments on tables, views, sources, sinks, connections, secrets, and columns are supported".to_string())
827            }
828            Self::NoObjectType | Self::SchemaExtractionFailed | Self::DatabaseExtractionFailed => {
829                Some("this is an internal error, please report this issue".to_string())
830            }
831            Self::InvalidIdentifier { .. } => {
832                Some("identifiers must follow SQL naming rules (alphanumeric and underscores, must not start with a digit)".to_string())
833            }
834            Self::IndexMissingCluster { .. } => {
835                Some("add 'IN CLUSTER <cluster_name>' to your CREATE INDEX statement (e.g., CREATE INDEX idx ON table (col) IN CLUSTER quickstart)".to_string())
836            }
837            Self::IndexOnStorageObject { .. } => {
838                Some("indexes are only supported on views and materialized views; to index data from a source or table, create a view that selects from it and index the view".to_string())
839            }
840            Self::MaterializedViewMissingCluster { .. } => {
841                Some("add 'IN CLUSTER <cluster_name>' to your CREATE MATERIALIZED VIEW statement (e.g., CREATE MATERIALIZED VIEW mv IN CLUSTER quickstart AS SELECT ...)".to_string())
842            }
843            Self::SinkMissingCluster { .. } => {
844                Some("add 'IN CLUSTER <cluster_name>' to your CREATE SINK statement (e.g., CREATE SINK sink IN CLUSTER quickstart FROM ...)".to_string())
845            }
846            Self::SourceMissingCluster { .. } => {
847                Some("add 'IN CLUSTER <cluster_name>' to your CREATE SOURCE statement (e.g., CREATE SOURCE src IN CLUSTER quickstart FROM ...)".to_string())
848            }
849            Self::SourceExternalReferences { .. } => {
850                Some("use CREATE TABLE FROM SOURCE to define tables individually so mz-deploy can manage their lifecycle, privileges, and comments".to_string())
851            }
852            Self::InvalidDatabaseModStatement { .. } => {
853                Some("database mod files (e.g., materialize.sql) can only contain COMMENT ON DATABASE, GRANT ON DATABASE, and ALTER DEFAULT PRIVILEGES statements".to_string())
854            }
855            Self::DatabaseModCommentTargetMismatch { .. } => {
856                Some("comments in database mod files must target the database itself using COMMENT ON DATABASE".to_string())
857            }
858            Self::DatabaseModGrantTargetMismatch { .. } => {
859                Some("grants in database mod files must target the database itself using GRANT ON DATABASE".to_string())
860            }
861            Self::InvalidSchemaModStatement { .. } => {
862                Some("schema mod files (e.g., materialize/public.sql) can only contain COMMENT ON SCHEMA, GRANT ON SCHEMA, ALTER DEFAULT PRIVILEGES, and SET api = stable statements".to_string())
863            }
864            Self::SchemaModCommentTargetMismatch { .. } => {
865                Some("comments in schema mod files must target the schema itself using COMMENT ON SCHEMA".to_string())
866            }
867            Self::SchemaModGrantTargetMismatch { .. } => {
868                Some("grants in schema mod files must target the schema itself using GRANT ON SCHEMA".to_string())
869            }
870            Self::InvalidSetVariable { .. } => {
871                Some("only 'SET api = stable' is supported in schema mod files".to_string())
872            }
873            Self::AlterDefaultPrivilegesRequiresDatabaseScope { .. } => {
874                Some("add 'IN DATABASE <database_name>' to your ALTER DEFAULT PRIVILEGES statement".to_string())
875            }
876            Self::AlterDefaultPrivilegesRequiresSchemaScope { .. } => {
877                Some("add 'IN SCHEMA <schema_name>' to your ALTER DEFAULT PRIVILEGES statement".to_string())
878            }
879            Self::AlterDefaultPrivilegesDatabaseMismatch { .. } => {
880                Some("ALTER DEFAULT PRIVILEGES in database mod files must target the database itself".to_string())
881            }
882            Self::AlterDefaultPrivilegesSchemaNotAllowed { .. } => {
883                Some("use IN DATABASE instead of IN SCHEMA in database mod files".to_string())
884            }
885            Self::AlterDefaultPrivilegesDatabaseNotAllowed { .. } => {
886                Some("use IN SCHEMA instead of IN DATABASE in schema mod files".to_string())
887            }
888            Self::AlterDefaultPrivilegesSchemaMismatch { .. } => {
889                Some("ALTER DEFAULT PRIVILEGES in schema mod files must target the schema itself".to_string())
890            }
891            Self::StorageAndComputationObjectsInSameSchema { .. } => {
892                Some("storage objects (tables, sinks) cannot share a schema with computation objects (views, materialized views) to prevent accidentally recreating tables or sinks when recreating views. Organize your schemas: use one schema for storage objects (e.g., 'tables') and another for computation objects (e.g., 'views' or 'public')".to_string())
893            }
894            Self::ReplacementSchemaNonMvObject { .. } => {
895                Some("schemas with SET api = stable can only contain CREATE MATERIALIZED VIEW statements".to_string())
896            }
897            Self::InvalidClusterStatement { .. } => {
898                Some("cluster files can only contain CREATE CLUSTER, GRANT ON CLUSTER, and COMMENT ON CLUSTER statements".to_string())
899            }
900            Self::ClusterNameMismatch { .. } => {
901                Some("the cluster name in your CREATE CLUSTER statement must match the .sql file name".to_string())
902            }
903            Self::ClusterMissingCreateStatement { .. } => {
904                Some("each cluster file must contain exactly one CREATE CLUSTER statement".to_string())
905            }
906            Self::ClusterMultipleCreateStatements { .. } => {
907                Some("each cluster file must contain exactly one CREATE CLUSTER statement".to_string())
908            }
909            Self::ClusterGrantTargetMismatch { .. } => {
910                Some("GRANT statements in a cluster file must target the cluster defined in that file".to_string())
911            }
912            Self::ClusterCommentTargetMismatch { .. } => {
913                Some("COMMENT statements in a cluster file must target the cluster defined in that file".to_string())
914            }
915            Self::InvalidRoleStatement { .. } => {
916                Some("role files can only contain CREATE ROLE, ALTER ROLE, GRANT ROLE, and COMMENT ON ROLE statements".to_string())
917            }
918            Self::RoleNameMismatch { .. } => {
919                Some("the role name in your CREATE ROLE statement must match the .sql file name".to_string())
920            }
921            Self::RoleMissingCreateStatement { .. } => {
922                Some("each role file must contain exactly one CREATE ROLE statement".to_string())
923            }
924            Self::RoleMultipleCreateStatements { .. } => {
925                Some("each role file must contain exactly one CREATE ROLE statement".to_string())
926            }
927            Self::RoleAlterTargetMismatch { .. } => {
928                Some("ALTER ROLE statements in a role file must target the role defined in that file".to_string())
929            }
930            Self::RoleGrantTargetMismatch { .. } => {
931                Some("GRANT ROLE statements in a role file must grant the role defined in that file".to_string())
932            }
933            Self::RoleCommentTargetMismatch { .. } => {
934                Some("COMMENT statements in a role file must target the role defined in that file".to_string())
935            }
936            Self::InvalidNetworkPolicyStatement { .. } => {
937                Some("network policy files can only contain CREATE NETWORK POLICY, GRANT ON NETWORK POLICY, and COMMENT ON NETWORK POLICY statements".to_string())
938            }
939            Self::NetworkPolicyNameMismatch { .. } => {
940                Some("the network policy name in your CREATE NETWORK POLICY statement must match the .sql file name".to_string())
941            }
942            Self::NetworkPolicyMissingCreateStatement { .. } => {
943                Some("each network policy file must contain exactly one CREATE NETWORK POLICY statement".to_string())
944            }
945            Self::NetworkPolicyMultipleCreateStatements { .. } => {
946                Some("each network policy file must contain exactly one CREATE NETWORK POLICY statement".to_string())
947            }
948            Self::NetworkPolicyGrantTargetMismatch { .. } => {
949                Some("GRANT statements in a network policy file must target the policy defined in that file".to_string())
950            }
951            Self::NetworkPolicyCommentTargetMismatch { .. } => {
952                Some("COMMENT statements in a network policy file must target the policy defined in that file".to_string())
953            }
954            Self::ProfileObjectTypeMismatch { .. } => {
955                Some("all profile variants of an object must have the same primary statement type (e.g., all CREATE SECRET or all CREATE TABLE)".to_string())
956            }
957            Self::ProfileOverrideNotAllowed { .. } => {
958                Some("views and materialized views cannot have profile-specific overrides because their definitions should be consistent across environments".to_string())
959            }
960        }
961    }
962}
963
964/// A collection of validation errors grouped by location.
965///
966/// This type holds multiple validation errors that occurred during project validation.
967/// It provides formatted output that groups errors by database, schema, and file for
968/// easier navigation and fixing.
969#[derive(Debug)]
970pub struct ValidationErrors {
971    pub errors: Vec<ValidationError>,
972}
973
974impl ValidationErrors {
975    /// Create a new collection from a vector of errors
976    pub fn new(errors: Vec<ValidationError>) -> Self {
977        Self { errors }
978    }
979
980    /// Check if there are any errors
981    pub fn is_empty(&self) -> bool {
982        self.errors.is_empty()
983    }
984
985    /// Get the number of errors
986    pub fn len(&self) -> usize {
987        self.errors.len()
988    }
989
990    /// Convert into a Result, returning Err if there are any errors
991    pub fn into_result(self) -> Result<(), Self> {
992        if self.is_empty() { Ok(()) } else { Err(self) }
993    }
994}
995
996impl fmt::Display for ValidationErrors {
997    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
998        for error in &self.errors {
999            writeln!(f, "{}", error)?;
1000        }
1001        if !self.errors.is_empty() {
1002            write!(
1003                f,
1004                "could not compile due to {} previous error{}",
1005                self.errors.len(),
1006                if self.errors.len() == 1 { "" } else { "s" }
1007            )?;
1008        }
1009        Ok(())
1010    }
1011}
1012
1013impl std::error::Error for ValidationErrors {
1014    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
1015        None
1016    }
1017}