Skip to main content

mz_deploy/project/compiler/
mod_statements.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 for database and schema mod file statements.
11//!
12//! Validates that mod files (database-level and schema-level) contain only
13//! permitted statement types: comments, grants, and alter default privileges
14//! targeting the correct database or schema.
15
16use crate::project::error::{ValidationError, ValidationErrorKind};
17use mz_sql_parser::ast::*;
18
19/// Get a human-readable name for a CommentObjectType variant.
20fn comment_object_type_name(obj: &CommentObjectType<Raw>) -> &'static str {
21    match obj {
22        CommentObjectType::Table { .. } => "TABLE",
23        CommentObjectType::View { .. } => "VIEW",
24        CommentObjectType::MaterializedView { .. } => "MATERIALIZED VIEW",
25        CommentObjectType::Source { .. } => "SOURCE",
26        CommentObjectType::Sink { .. } => "SINK",
27        CommentObjectType::Connection { .. } => "CONNECTION",
28        CommentObjectType::Secret { .. } => "SECRET",
29        CommentObjectType::Schema { .. } => "SCHEMA",
30        CommentObjectType::Database { .. } => "DATABASE",
31        CommentObjectType::Column { .. } => "COLUMN",
32        CommentObjectType::Index { .. } => "INDEX",
33        CommentObjectType::Func { .. } => "FUNCTION",
34        CommentObjectType::Type { .. } => "TYPE",
35        CommentObjectType::Role { .. } => "ROLE",
36        CommentObjectType::Cluster { .. } => "CLUSTER",
37        CommentObjectType::ClusterReplica { .. } => "CLUSTER REPLICA",
38        CommentObjectType::NetworkPolicy { .. } => "NETWORK POLICY",
39    }
40}
41
42/// Validate database mod file statements.
43///
44/// Database mod files can only contain:
45/// - COMMENT ON DATABASE (targeting the database itself)
46/// - GRANT ON DATABASE (targeting the database itself)
47/// - ALTER DEFAULT PRIVILEGES
48pub(crate) fn validate_database_mod_statements(
49    database_name: &str,
50    database_path: &std::path::Path,
51    statements: &[Statement<Raw>],
52    errors: &mut Vec<ValidationError>,
53) {
54    use mz_sql_parser::ast::Statement as MzStatement;
55
56    for stmt in statements {
57        let stmt_sql = format!("{};", stmt);
58
59        match stmt {
60            MzStatement::Comment(comment_stmt) => {
61                // Must be COMMENT ON DATABASE targeting this database
62                match &comment_stmt.object {
63                    CommentObjectType::Database { name } => {
64                        // Check if it targets this database
65                        let target_db = name.to_string();
66                        if target_db != database_name {
67                            errors.push(ValidationError::with_file_and_sql(
68                                ValidationErrorKind::DatabaseModCommentTargetMismatch {
69                                    target: format!("DATABASE {}", target_db),
70                                    database_name: database_name.to_string(),
71                                },
72                                database_path.to_path_buf(),
73                                stmt_sql,
74                            ));
75                        }
76                    }
77                    _ => {
78                        errors.push(ValidationError::with_file_and_sql(
79                            ValidationErrorKind::DatabaseModCommentTargetMismatch {
80                                target: comment_object_type_name(&comment_stmt.object).to_string(),
81                                database_name: database_name.to_string(),
82                            },
83                            database_path.to_path_buf(),
84                            stmt_sql,
85                        ));
86                    }
87                }
88            }
89            MzStatement::GrantPrivileges(grant_stmt) => {
90                // Must be GRANT ON DATABASE targeting this database
91                match &grant_stmt.target {
92                    GrantTargetSpecification::Object {
93                        object_type,
94                        object_spec_inner,
95                        ..
96                    } => {
97                        if object_type != &ObjectType::Database {
98                            errors.push(ValidationError::with_file_and_sql(
99                                ValidationErrorKind::DatabaseModGrantTargetMismatch {
100                                    target: format!("{}", object_type),
101                                    database_name: database_name.to_string(),
102                                },
103                                database_path.to_path_buf(),
104                                stmt_sql.clone(),
105                            ));
106                        }
107
108                        // Check that it targets this specific database
109                        if let GrantTargetSpecificationInner::Objects { names } = object_spec_inner
110                        {
111                            for name in names {
112                                if let UnresolvedObjectName::Item(item_name) = name {
113                                    let target_db = item_name.to_string();
114                                    if target_db != database_name {
115                                        errors.push(ValidationError::with_file_and_sql(
116                                            ValidationErrorKind::DatabaseModGrantTargetMismatch {
117                                                target: format!("DATABASE {}", target_db),
118                                                database_name: database_name.to_string(),
119                                            },
120                                            database_path.to_path_buf(),
121                                            stmt_sql.clone(),
122                                        ));
123                                    }
124                                }
125                            }
126                        }
127                    }
128                    GrantTargetSpecification::System => {
129                        errors.push(ValidationError::with_file_and_sql(
130                            ValidationErrorKind::DatabaseModGrantTargetMismatch {
131                                target: "SYSTEM or other".to_string(),
132                                database_name: database_name.to_string(),
133                            },
134                            database_path.to_path_buf(),
135                            stmt_sql,
136                        ));
137                    }
138                }
139            }
140            MzStatement::AlterDefaultPrivileges(alter_stmt) => {
141                // Must specify IN DATABASE targeting this database
142                match &alter_stmt.target_objects {
143                    GrantTargetAllSpecification::AllDatabases { databases } => {
144                        // Validate all databases reference the current database
145                        for db_name in databases {
146                            let db_str = db_name.to_string();
147                            if db_str != database_name {
148                                errors.push(ValidationError::with_file_and_sql(
149                                    ValidationErrorKind::AlterDefaultPrivilegesDatabaseMismatch {
150                                        referenced: db_str,
151                                        expected: database_name.to_string(),
152                                    },
153                                    database_path.to_path_buf(),
154                                    stmt_sql.clone(),
155                                ));
156                            }
157                        }
158                    }
159                    GrantTargetAllSpecification::AllSchemas { .. } => {
160                        // Reject: IN SCHEMA not allowed in database mod files
161                        errors.push(ValidationError::with_file_and_sql(
162                            ValidationErrorKind::AlterDefaultPrivilegesSchemaNotAllowed {
163                                database_name: database_name.to_string(),
164                            },
165                            database_path.to_path_buf(),
166                            stmt_sql.clone(),
167                        ));
168                    }
169                    GrantTargetAllSpecification::All => {
170                        // Reject: Must specify IN DATABASE
171                        errors.push(ValidationError::with_file_and_sql(
172                            ValidationErrorKind::AlterDefaultPrivilegesRequiresDatabaseScope {
173                                database_name: database_name.to_string(),
174                            },
175                            database_path.to_path_buf(),
176                            stmt_sql.clone(),
177                        ));
178                    }
179                }
180            }
181            _ => {
182                // Reject all other statement types
183                errors.push(ValidationError::with_file_and_sql(
184                    ValidationErrorKind::InvalidDatabaseModStatement {
185                        statement_type: format!("{:?}", stmt)
186                            .split('(')
187                            .next()
188                            .unwrap_or("unknown")
189                            .to_string(),
190                        database_name: database_name.to_string(),
191                    },
192                    database_path.to_path_buf(),
193                    stmt_sql,
194                ));
195            }
196        }
197    }
198}
199
200/// Validate schema mod file statements and normalize names.
201///
202/// Schema mod files can only contain:
203/// - COMMENT ON SCHEMA (targeting the schema itself)
204/// - GRANT ON SCHEMA (targeting the schema itself)
205/// - ALTER DEFAULT PRIVILEGES
206///
207/// Names are normalized to include the database qualifier.
208pub(crate) fn validate_schema_mod_statements(
209    database_name: &str,
210    schema_name: &str,
211    schema_path: &std::path::Path,
212    statements: &mut [Statement<Raw>],
213    errors: &mut Vec<ValidationError>,
214) {
215    use mz_sql_parser::ast::Statement as MzStatement;
216
217    // Helper function to normalize unqualified schema names
218    let normalize_schema_name = |name: &mut UnresolvedSchemaName| {
219        if name.0.len() == 1 {
220            // Unqualified: prepend database to make database.schema
221            let schema = name.0[0].clone();
222            let database = Ident::new(database_name).expect("valid database identifier");
223            name.0 = vec![database, schema];
224        }
225        // Already qualified or invalid - leave as-is
226    };
227
228    for stmt in statements.iter_mut() {
229        let stmt_sql = format!("{};", stmt);
230
231        match stmt {
232            MzStatement::Comment(comment_stmt) => {
233                // Must be COMMENT ON SCHEMA targeting this schema
234                match &mut comment_stmt.object {
235                    CommentObjectType::Schema { name } => {
236                        // Check if it targets this schema (can be qualified or unqualified)
237                        let target_schema = name.to_string();
238                        let is_match =
239                            schema_name_matches(&target_schema, database_name, schema_name);
240
241                        if !is_match {
242                            errors.push(ValidationError::with_file_and_sql(
243                                ValidationErrorKind::SchemaModCommentTargetMismatch {
244                                    target: format!("SCHEMA {}", target_schema),
245                                    schema_name: format!("{}.{}", database_name, schema_name),
246                                },
247                                schema_path.to_path_buf(),
248                                stmt_sql,
249                            ));
250                        } else {
251                            // Normalize the schema name to be fully qualified
252                            normalize_schema_name(name);
253                        }
254                    }
255                    _ => {
256                        errors.push(ValidationError::with_file_and_sql(
257                            ValidationErrorKind::SchemaModCommentTargetMismatch {
258                                target: comment_object_type_name(&comment_stmt.object).to_string(),
259                                schema_name: format!("{}.{}", database_name, schema_name),
260                            },
261                            schema_path.to_path_buf(),
262                            stmt_sql,
263                        ));
264                    }
265                }
266            }
267            MzStatement::GrantPrivileges(grant_stmt) => {
268                // Must be GRANT ON SCHEMA targeting this schema
269                match &mut grant_stmt.target {
270                    GrantTargetSpecification::Object {
271                        object_type,
272                        object_spec_inner,
273                        ..
274                    } => {
275                        if object_type != &ObjectType::Schema {
276                            errors.push(ValidationError::with_file_and_sql(
277                                ValidationErrorKind::SchemaModGrantTargetMismatch {
278                                    target: format!("{}", object_type),
279                                    schema_name: format!("{}.{}", database_name, schema_name),
280                                },
281                                schema_path.to_path_buf(),
282                                stmt_sql.clone(),
283                            ));
284                        }
285
286                        // Check that it targets this specific schema
287                        if let GrantTargetSpecificationInner::Objects { names } = object_spec_inner
288                        {
289                            for name in names {
290                                if let UnresolvedObjectName::Schema(schema_name_obj) = name {
291                                    let target_schema = schema_name_obj.to_string();
292                                    let is_match = schema_name_matches(
293                                        &target_schema,
294                                        database_name,
295                                        schema_name,
296                                    );
297
298                                    if !is_match {
299                                        errors.push(ValidationError::with_file_and_sql(
300                                            ValidationErrorKind::SchemaModGrantTargetMismatch {
301                                                target: format!("SCHEMA {}", target_schema),
302                                                schema_name: format!(
303                                                    "{}.{}",
304                                                    database_name, schema_name
305                                                ),
306                                            },
307                                            schema_path.to_path_buf(),
308                                            stmt_sql.clone(),
309                                        ));
310                                    } else {
311                                        // Normalize the schema name to be fully qualified
312                                        normalize_schema_name(schema_name_obj);
313                                    }
314                                }
315                            }
316                        }
317                    }
318                    GrantTargetSpecification::System => {
319                        errors.push(ValidationError::with_file_and_sql(
320                            ValidationErrorKind::SchemaModGrantTargetMismatch {
321                                target: "SYSTEM or other".to_string(),
322                                schema_name: format!("{}.{}", database_name, schema_name),
323                            },
324                            schema_path.to_path_buf(),
325                            stmt_sql,
326                        ));
327                    }
328                }
329            }
330            MzStatement::SetVariable(set_stmt) => {
331                if set_stmt.variable.as_str().eq_ignore_ascii_case("api") {
332                    // SET api = stable is a valid schema mod directive
333                    let is_valid = match &set_stmt.to {
334                        SetVariableTo::Values(values) => {
335                            values.len() == 1
336                                && match &values[0] {
337                                    SetVariableValue::Ident(ident) => {
338                                        ident.as_str().eq_ignore_ascii_case("stable")
339                                    }
340                                    SetVariableValue::Literal(Value::String(s)) => {
341                                        s.eq_ignore_ascii_case("stable")
342                                    }
343                                    SetVariableValue::Literal(_) => false,
344                                }
345                        }
346                        SetVariableTo::Default => false,
347                    };
348
349                    if !is_valid {
350                        errors.push(ValidationError::with_file_and_sql(
351                            ValidationErrorKind::InvalidSetVariable {
352                                variable: set_stmt.variable.as_str().to_string(),
353                                value: set_stmt.to.to_string(),
354                            },
355                            schema_path.to_path_buf(),
356                            stmt_sql,
357                        ));
358                    }
359                } else {
360                    errors.push(ValidationError::with_file_and_sql(
361                        ValidationErrorKind::InvalidSchemaModStatement {
362                            statement_type: "SET".to_string(),
363                            schema_name: format!("{}.{}", database_name, schema_name),
364                        },
365                        schema_path.to_path_buf(),
366                        stmt_sql,
367                    ));
368                }
369            }
370            MzStatement::AlterDefaultPrivileges(alter_stmt) => {
371                // Must specify IN SCHEMA targeting this schema
372                match &mut alter_stmt.target_objects {
373                    GrantTargetAllSpecification::AllSchemas { schemas } => {
374                        // Validate each schema reference
375                        for schema_name_obj in schemas {
376                            let schema_str = schema_name_obj.to_string();
377
378                            // Check if it matches the current schema (qualified or unqualified)
379                            let is_match =
380                                schema_name_matches(&schema_str, database_name, schema_name);
381
382                            if !is_match {
383                                errors.push(ValidationError::with_file_and_sql(
384                                    ValidationErrorKind::AlterDefaultPrivilegesSchemaMismatch {
385                                        referenced: schema_str,
386                                        expected: format!("{}.{}", database_name, schema_name),
387                                    },
388                                    schema_path.to_path_buf(),
389                                    stmt_sql.clone(),
390                                ));
391                            } else {
392                                // Normalize the schema name to be fully qualified
393                                normalize_schema_name(schema_name_obj);
394                            }
395                        }
396                    }
397                    GrantTargetAllSpecification::AllDatabases { .. } => {
398                        // Reject: IN DATABASE not allowed in schema mod files
399                        errors.push(ValidationError::with_file_and_sql(
400                            ValidationErrorKind::AlterDefaultPrivilegesDatabaseNotAllowed {
401                                schema_name: format!("{}.{}", database_name, schema_name),
402                            },
403                            schema_path.to_path_buf(),
404                            stmt_sql.clone(),
405                        ));
406                    }
407                    GrantTargetAllSpecification::All => {
408                        // Reject: Must specify IN SCHEMA
409                        errors.push(ValidationError::with_file_and_sql(
410                            ValidationErrorKind::AlterDefaultPrivilegesRequiresSchemaScope {
411                                schema_name: format!("{}.{}", database_name, schema_name),
412                            },
413                            schema_path.to_path_buf(),
414                            stmt_sql.clone(),
415                        ));
416                    }
417                }
418            }
419            _ => {
420                // Reject all other statement types
421                errors.push(ValidationError::with_file_and_sql(
422                    ValidationErrorKind::InvalidSchemaModStatement {
423                        statement_type: format!("{:?}", stmt)
424                            .split('(')
425                            .next()
426                            .unwrap_or("unknown")
427                            .to_string(),
428                        schema_name: format!("{}.{}", database_name, schema_name),
429                    },
430                    schema_path.to_path_buf(),
431                    stmt_sql,
432                ));
433            }
434        }
435    }
436}
437
438/// Returns true if `target` matches `schema_name` either unqualified or as `database.schema`.
439fn schema_name_matches(target: &str, database_name: &str, schema_name: &str) -> bool {
440    target == schema_name || target == format!("{}.{}", database_name, schema_name)
441}