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}