1use std::collections::{BTreeMap, BTreeSet};
8use std::fmt::Write;
9
10use crate::diff::Change;
11use crate::manifest::{ObjectType, Privilege};
12use crate::model::{RoleAttribute, RoleState};
13
14pub fn quote_ident(identifier: &str) -> String {
27 format!("\"{}\"", identifier.replace('"', "\"\""))
28}
29
30#[derive(Debug, Clone)]
36pub struct SqlContext {
37 pub pg_major_version: i32,
40 pub relation_inventory: BTreeMap<(ObjectType, String), Vec<String>>,
43}
44
45impl SqlContext {
46 pub fn from_version_num(version_num: i32) -> Self {
48 Self {
49 pg_major_version: version_num / 10000,
50 relation_inventory: BTreeMap::new(),
51 }
52 }
53
54 pub fn with_relation_inventory(
56 mut self,
57 relation_inventory: BTreeMap<(ObjectType, String), Vec<String>>,
58 ) -> Self {
59 self.relation_inventory = relation_inventory;
60 self
61 }
62
63 pub fn supports_grant_with_options(&self) -> bool {
65 self.pg_major_version >= 16
66 }
67}
68
69impl Default for SqlContext {
70 fn default() -> Self {
71 Self {
72 pg_major_version: 16, relation_inventory: BTreeMap::new(),
74 }
75 }
76}
77
78pub fn render(change: &Change) -> String {
85 render_statements(change).join("\n")
86}
87
88pub fn render_statements(change: &Change) -> Vec<String> {
91 render_statements_with_context(change, &SqlContext::default())
92}
93
94pub fn render_statements_with_context(change: &Change, ctx: &SqlContext) -> Vec<String> {
97 match change {
98 Change::CreateRole { name, state } => render_create_role(name, state),
99 Change::CreateSchema { name, owner } => render_create_schema(name, owner.as_deref()),
100 Change::AlterSchemaOwner { name, owner } => render_alter_schema_owner(name, owner),
101 Change::EnsureSchemaOwnerPrivileges {
102 name,
103 owner,
104 privileges,
105 } => render_grant(
106 owner,
107 privileges,
108 ObjectType::Schema,
109 None,
110 Some(name.as_str()),
111 ctx,
112 ),
113 Change::AlterRole { name, attributes } => render_alter_role(name, attributes),
114 Change::SetComment { name, comment } => render_set_comment(name, comment),
115 Change::Grant {
116 role,
117 privileges,
118 object_type,
119 schema,
120 name,
121 } => render_grant(
122 role,
123 privileges,
124 *object_type,
125 schema.as_deref(),
126 name.as_deref(),
127 ctx,
128 ),
129 Change::Revoke {
130 role,
131 privileges,
132 object_type,
133 schema,
134 name,
135 } => render_revoke(
136 role,
137 privileges,
138 *object_type,
139 schema.as_deref(),
140 name.as_deref(),
141 ctx,
142 ),
143 Change::SetDefaultPrivilege {
144 owner,
145 schema,
146 on_type,
147 grantee,
148 privileges,
149 } => render_set_default_privilege(owner, schema, *on_type, grantee, privileges),
150 Change::RevokeDefaultPrivilege {
151 owner,
152 schema,
153 on_type,
154 grantee,
155 privileges,
156 } => render_revoke_default_privilege(owner, schema, *on_type, grantee, privileges),
157 Change::AddMember {
158 role,
159 member,
160 inherit,
161 admin,
162 } => render_add_member(role, member, *inherit, *admin, ctx),
163 Change::RemoveMember { role, member } => render_remove_member(role, member),
164 Change::ReassignOwned { from_role, to_role } => render_reassign_owned(from_role, to_role),
165 Change::DropOwned { role } => render_drop_owned(role),
166 Change::TerminateSessions { role } => render_terminate_sessions(role),
167 Change::SetPassword { name, password } => render_set_password(name, password),
168 Change::DropRole { name } => vec![format!("DROP ROLE IF EXISTS {};", quote_ident(name))],
169 }
170}
171
172pub fn render_all(changes: &[Change]) -> String {
174 render_all_with_context(changes, &SqlContext::default())
175}
176
177pub fn render_all_with_context(changes: &[Change], ctx: &SqlContext) -> String {
179 changes
180 .iter()
181 .flat_map(|c| render_statements_with_context(c, ctx))
182 .collect::<Vec<_>>()
183 .join("\n")
184}
185
186fn render_create_schema(name: &str, owner: Option<&str>) -> Vec<String> {
191 let sql = match owner {
192 Some(owner) => format!(
193 "CREATE SCHEMA {} AUTHORIZATION {};",
194 quote_ident(name),
195 quote_ident(owner)
196 ),
197 None => format!("CREATE SCHEMA {};", quote_ident(name)),
198 };
199 vec![sql]
200}
201
202fn render_alter_schema_owner(name: &str, owner: &str) -> Vec<String> {
203 vec![format!(
204 "ALTER SCHEMA {} OWNER TO {};",
205 quote_ident(name),
206 quote_ident(owner)
207 )]
208}
209
210fn render_create_role(name: &str, state: &RoleState) -> Vec<String> {
223 let mut sql = format!("CREATE ROLE {}", quote_ident(name));
224 let mut options = vec![
225 bool_option("LOGIN", "NOLOGIN", state.login),
226 bool_option("SUPERUSER", "NOSUPERUSER", state.superuser),
227 bool_option("CREATEDB", "NOCREATEDB", state.createdb),
228 bool_option("CREATEROLE", "NOCREATEROLE", state.createrole),
229 bool_option("INHERIT", "NOINHERIT", state.inherit),
230 bool_option("REPLICATION", "NOREPLICATION", state.replication),
231 bool_option("BYPASSRLS", "NOBYPASSRLS", state.bypassrls),
232 ];
233
234 if state.connection_limit != -1 {
235 options.push(format!("CONNECTION LIMIT {}", state.connection_limit));
236 }
237
238 if let Some(valid_until) = &state.password_valid_until {
239 options.push(format!("VALID UNTIL {}", quote_literal(valid_until)));
240 }
241
242 let _ = write!(sql, " {}", options.join(" "));
243 sql.push(';');
244
245 let mut statements = vec![sql];
246 if let Some(comment) = &state.comment {
247 statements.push(format!(
248 "COMMENT ON ROLE {} IS {};",
249 quote_ident(name),
250 quote_literal(comment)
251 ));
252 }
253
254 statements
255}
256
257fn bool_option(positive: &str, negative: &str, value: bool) -> String {
258 if value {
259 positive.to_string()
260 } else {
261 negative.to_string()
262 }
263}
264
265fn render_alter_role(name: &str, attributes: &[RoleAttribute]) -> Vec<String> {
270 let mut options = Vec::new();
271 for attr in attributes {
272 match attr {
273 RoleAttribute::Login(v) => options.push(bool_option("LOGIN", "NOLOGIN", *v)),
274 RoleAttribute::Superuser(v) => {
275 options.push(bool_option("SUPERUSER", "NOSUPERUSER", *v));
276 }
277 RoleAttribute::Createdb(v) => {
278 options.push(bool_option("CREATEDB", "NOCREATEDB", *v));
279 }
280 RoleAttribute::Createrole(v) => {
281 options.push(bool_option("CREATEROLE", "NOCREATEROLE", *v));
282 }
283 RoleAttribute::Inherit(v) => options.push(bool_option("INHERIT", "NOINHERIT", *v)),
284 RoleAttribute::Replication(v) => {
285 options.push(bool_option("REPLICATION", "NOREPLICATION", *v));
286 }
287 RoleAttribute::Bypassrls(v) => {
288 options.push(bool_option("BYPASSRLS", "NOBYPASSRLS", *v));
289 }
290 RoleAttribute::ConnectionLimit(v) => {
291 options.push(format!("CONNECTION LIMIT {v}"));
292 }
293 RoleAttribute::ValidUntil(v) => match v {
294 Some(ts) => options.push(format!("VALID UNTIL {}", quote_literal(ts))),
295 None => options.push("VALID UNTIL 'infinity'".to_string()),
296 },
297 }
298 }
299 vec![format!(
300 "ALTER ROLE {} {};",
301 quote_ident(name),
302 options.join(" ")
303 )]
304}
305
306fn render_set_comment(name: &str, comment: &Option<String>) -> Vec<String> {
311 vec![match comment {
312 Some(text) => format!(
313 "COMMENT ON ROLE {} IS {};",
314 quote_ident(name),
315 quote_literal(text)
316 ),
317 None => format!("COMMENT ON ROLE {} IS NULL;", quote_ident(name)),
318 }]
319}
320
321fn render_grant(
326 role: &str,
327 privileges: &BTreeSet<Privilege>,
328 object_type: ObjectType,
329 schema: Option<&str>,
330 name: Option<&str>,
331 ctx: &SqlContext,
332) -> Vec<String> {
333 let privilege_list = format_privileges(privileges);
334 render_privilege_statements(
335 "GRANT",
336 role,
337 &privilege_list,
338 object_type,
339 schema,
340 name,
341 ctx,
342 )
343}
344
345fn render_revoke(
346 role: &str,
347 privileges: &BTreeSet<Privilege>,
348 object_type: ObjectType,
349 schema: Option<&str>,
350 name: Option<&str>,
351 ctx: &SqlContext,
352) -> Vec<String> {
353 let privilege_list = format_privileges(privileges);
354 render_privilege_statements(
355 "REVOKE",
356 role,
357 &privilege_list,
358 object_type,
359 schema,
360 name,
361 ctx,
362 )
363}
364
365fn render_privilege_statements(
366 action: &str,
367 role: &str,
368 privilege_list: &str,
369 object_type: ObjectType,
370 schema: Option<&str>,
371 name: Option<&str>,
372 ctx: &SqlContext,
373) -> Vec<String> {
374 let subject_preposition = if action == "GRANT" { "TO" } else { "FROM" };
375 if matches!(
376 object_type,
377 ObjectType::Table | ObjectType::View | ObjectType::MaterializedView
378 ) && name == Some("*")
379 {
380 return render_relation_wildcard(
381 action,
382 subject_preposition,
383 role,
384 privilege_list,
385 object_type,
386 schema,
387 ctx,
388 );
389 }
390
391 let target = format_object_target(object_type, schema, name);
392 vec![format!(
393 "{action} {privilege_list} ON {target} {subject_preposition} {};",
394 quote_ident(role)
395 )]
396}
397
398fn render_relation_wildcard(
399 action: &str,
400 subject_preposition: &str,
401 role: &str,
402 privilege_list: &str,
403 object_type: ObjectType,
404 schema: Option<&str>,
405 ctx: &SqlContext,
406) -> Vec<String> {
407 let schema_name = schema.unwrap_or("public");
408
409 if let Some(object_names) = ctx
410 .relation_inventory
411 .get(&(object_type, schema_name.to_string()))
412 {
413 return object_names
414 .iter()
415 .map(|object_name| {
416 format!(
417 "{action} {privilege_list} ON TABLE {}.{} {subject_preposition} {};",
418 quote_ident(schema_name),
419 quote_ident(object_name),
420 quote_ident(role),
421 )
422 })
423 .collect();
424 }
425
426 vec![format!(
427 "DO $pgroles$\nDECLARE obj record;\nBEGIN\n FOR obj IN\n SELECT n.nspname AS schema_name, c.relname AS object_name\n FROM pg_class c\n JOIN pg_namespace n ON n.oid = c.relnamespace\n WHERE c.relkind IN ({})\n AND n.nspname = {}\n ORDER BY c.relname\n LOOP\n EXECUTE format('{} {} ON TABLE %I.%I {} %I;', obj.schema_name, obj.object_name, {});\n END LOOP;\nEND\n$pgroles$;",
428 relation_relkinds_sql(object_type),
429 quote_literal(schema_name),
430 action,
431 privilege_list,
432 subject_preposition,
433 quote_literal(role),
434 )]
435}
436
437fn relation_relkinds_sql(object_type: ObjectType) -> &'static str {
438 match object_type {
439 ObjectType::Table => "'r', 'p'",
440 ObjectType::View => "'v'",
441 ObjectType::MaterializedView => "'m'",
442 _ => unreachable!("relation_relkinds_literal only supports relation object types"),
443 }
444}
445
446fn format_object_target(
453 object_type: ObjectType,
454 schema: Option<&str>,
455 name: Option<&str>,
456) -> String {
457 let type_keyword = sql_object_type_keyword(object_type);
458
459 match object_type {
460 ObjectType::Schema => {
461 let schema_name = name.unwrap_or("public");
463 format!("{type_keyword} {}", quote_ident(schema_name))
464 }
465 ObjectType::Database => {
466 let db_name = name.unwrap_or("postgres");
467 format!("{type_keyword} {}", quote_ident(db_name))
468 }
469 ObjectType::Function => match name {
470 Some("*") => {
471 let schema_name = schema.unwrap_or("public");
472 format!("ALL FUNCTIONS IN SCHEMA {}", quote_ident(schema_name))
473 }
474 Some(function_name) => format_function_target(schema, function_name),
475 None => {
476 let schema_name = schema.unwrap_or("public");
477 format!("{type_keyword} {}", quote_ident(schema_name))
478 }
479 },
480 _ => {
481 match name {
482 Some("*") => {
483 let plural = sql_object_type_plural(object_type);
485 let schema_name = schema.unwrap_or("public");
486 format!("ALL {plural} IN SCHEMA {}", quote_ident(schema_name))
487 }
488 Some(obj_name) => {
489 let schema_name = schema.unwrap_or("public");
491 format!(
492 "{type_keyword} {}.{}",
493 quote_ident(schema_name),
494 quote_ident(obj_name)
495 )
496 }
497 None => {
498 let schema_name = schema.unwrap_or("public");
500 format!("{type_keyword} {}", quote_ident(schema_name))
501 }
502 }
503 }
504 }
505}
506
507fn format_function_target(schema: Option<&str>, function_name: &str) -> String {
508 let schema_name = schema.unwrap_or("public");
509
510 match function_name.rfind('(') {
511 Some(paren_idx) if function_name.ends_with(')') => {
512 let base_name = &function_name[..paren_idx];
513 let args = &function_name[paren_idx..];
514 format!(
515 "FUNCTION {}.{}{}",
516 quote_ident(schema_name),
517 quote_ident(base_name),
518 args
519 )
520 }
521 _ => format!(
522 "FUNCTION {}.{}",
523 quote_ident(schema_name),
524 quote_ident(function_name)
525 ),
526 }
527}
528
529fn sql_object_type_keyword(object_type: ObjectType) -> &'static str {
531 match object_type {
532 ObjectType::Table => "TABLE",
533 ObjectType::View => "TABLE", ObjectType::MaterializedView => "TABLE", ObjectType::Sequence => "SEQUENCE",
536 ObjectType::Function => "FUNCTION",
537 ObjectType::Schema => "SCHEMA",
538 ObjectType::Database => "DATABASE",
539 ObjectType::Type => "TYPE",
540 }
541}
542
543fn sql_object_type_plural(object_type: ObjectType) -> &'static str {
545 match object_type {
546 ObjectType::Table | ObjectType::View | ObjectType::MaterializedView => "TABLES",
547 ObjectType::Sequence => "SEQUENCES",
548 ObjectType::Function => "FUNCTIONS",
549 ObjectType::Type => "TABLES",
553 ObjectType::Schema | ObjectType::Database => "TABLES",
555 }
556}
557
558fn format_privileges(privileges: &BTreeSet<Privilege>) -> String {
560 privileges
561 .iter()
562 .map(|p| p.to_string())
563 .collect::<Vec<_>>()
564 .join(", ")
565}
566
567fn render_set_default_privilege(
572 owner: &str,
573 schema: &str,
574 on_type: ObjectType,
575 grantee: &str,
576 privileges: &BTreeSet<Privilege>,
577) -> Vec<String> {
578 let privilege_list = format_privileges(privileges);
579 let type_plural = sql_object_type_plural(on_type);
580 vec![format!(
581 "ALTER DEFAULT PRIVILEGES FOR ROLE {} IN SCHEMA {} GRANT {} ON {} TO {};",
582 quote_ident(owner),
583 quote_ident(schema),
584 privilege_list,
585 type_plural,
586 quote_ident(grantee)
587 )]
588}
589
590fn render_revoke_default_privilege(
591 owner: &str,
592 schema: &str,
593 on_type: ObjectType,
594 grantee: &str,
595 privileges: &BTreeSet<Privilege>,
596) -> Vec<String> {
597 let privilege_list = format_privileges(privileges);
598 let type_plural = sql_object_type_plural(on_type);
599 vec![format!(
600 "ALTER DEFAULT PRIVILEGES FOR ROLE {} IN SCHEMA {} REVOKE {} ON {} FROM {};",
601 quote_ident(owner),
602 quote_ident(schema),
603 privilege_list,
604 type_plural,
605 quote_ident(grantee)
606 )]
607}
608
609fn render_add_member(
614 role: &str,
615 member: &str,
616 inherit: bool,
617 admin: bool,
618 ctx: &SqlContext,
619) -> Vec<String> {
620 let mut sql = format!("GRANT {} TO {}", quote_ident(role), quote_ident(member));
621
622 if ctx.supports_grant_with_options() {
623 let mut options = Vec::new();
625 if inherit {
626 options.push("INHERIT TRUE");
627 } else {
628 options.push("INHERIT FALSE");
629 }
630 if admin {
631 options.push("ADMIN TRUE");
632 }
633 if !options.is_empty() {
634 let _ = write!(sql, " WITH {}", options.join(", "));
635 }
636 } else {
637 if admin {
640 sql.push_str(" WITH ADMIN OPTION");
641 }
642 }
643
644 sql.push(';');
645 vec![sql]
646}
647
648fn render_remove_member(role: &str, member: &str) -> Vec<String> {
649 vec![format!(
650 "REVOKE {} FROM {};",
651 quote_ident(role),
652 quote_ident(member)
653 )]
654}
655
656fn render_reassign_owned(from_role: &str, to_role: &str) -> Vec<String> {
657 vec![format!(
658 "REASSIGN OWNED BY {} TO {};",
659 quote_ident(from_role),
660 quote_ident(to_role)
661 )]
662}
663
664fn render_drop_owned(role: &str) -> Vec<String> {
665 vec![format!("DROP OWNED BY {};", quote_ident(role))]
666}
667
668fn render_terminate_sessions(role: &str) -> Vec<String> {
669 vec![format!(
670 "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = {} AND pid <> pg_backend_pid();",
671 quote_literal(role)
672 )]
673}
674
675fn render_set_password(name: &str, password: &str) -> Vec<String> {
681 vec![format!(
682 "ALTER ROLE {} PASSWORD {};",
683 quote_ident(name),
684 quote_literal(password)
685 )]
686}
687
688fn quote_literal(value: &str) -> String {
694 format!("'{}'", value.replace('\'', "''"))
695}
696
697#[cfg(test)]
702mod tests {
703 use super::*;
704
705 #[test]
706 fn quote_ident_simple() {
707 assert_eq!(quote_ident("simple"), "\"simple\"");
708 }
709
710 #[test]
711 fn quote_ident_with_hyphen() {
712 assert_eq!(quote_ident("inventory-editor"), "\"inventory-editor\"");
713 }
714
715 #[test]
716 fn quote_ident_with_email() {
717 assert_eq!(quote_ident("user@example.com"), "\"user@example.com\"");
718 }
719
720 #[test]
721 fn quote_ident_with_embedded_quotes() {
722 assert_eq!(quote_ident("has\"quote"), "\"has\"\"quote\"");
723 }
724
725 #[test]
726 fn quote_literal_simple() {
727 assert_eq!(quote_literal("hello"), "'hello'");
728 }
729
730 #[test]
731 fn quote_literal_with_embedded_quotes() {
732 assert_eq!(quote_literal("it's"), "'it''s'");
733 }
734
735 #[test]
736 fn render_create_role_basic() {
737 let change = Change::CreateRole {
738 name: "inventory-editor".to_string(),
739 state: RoleState::default(),
740 };
741 let sql = render(&change);
742 assert!(sql.starts_with("CREATE ROLE \"inventory-editor\""));
743 assert!(sql.contains("NOLOGIN"));
744 assert!(sql.contains("NOSUPERUSER"));
745 assert!(sql.contains("INHERIT")); assert!(sql.ends_with(';'));
747 }
748
749 #[test]
750 fn render_create_schema_with_owner() {
751 let change = Change::CreateSchema {
752 name: "inventory".to_string(),
753 owner: Some("inventory_owner".to_string()),
754 };
755 assert_eq!(
756 render(&change),
757 "CREATE SCHEMA \"inventory\" AUTHORIZATION \"inventory_owner\";"
758 );
759 }
760
761 #[test]
762 fn render_create_schema_without_owner() {
763 let change = Change::CreateSchema {
764 name: "inventory".to_string(),
765 owner: None,
766 };
767 assert_eq!(render(&change), "CREATE SCHEMA \"inventory\";");
768 }
769
770 #[test]
771 fn render_alter_schema_owner() {
772 let change = Change::AlterSchemaOwner {
773 name: "inventory".to_string(),
774 owner: "inventory_owner".to_string(),
775 };
776 assert_eq!(
777 render(&change),
778 "ALTER SCHEMA \"inventory\" OWNER TO \"inventory_owner\";"
779 );
780 }
781
782 #[test]
783 fn render_ensure_schema_owner_privileges() {
784 let change = Change::EnsureSchemaOwnerPrivileges {
785 name: "inventory".to_string(),
786 owner: "inventory_owner".to_string(),
787 privileges: BTreeSet::from([Privilege::Create, Privilege::Usage]),
788 };
789 assert_eq!(
790 render(&change),
791 "GRANT CREATE, USAGE ON SCHEMA \"inventory\" TO \"inventory_owner\";"
792 );
793 }
794
795 #[test]
796 fn render_create_role_with_login_and_comment() {
797 let change = Change::CreateRole {
798 name: "analytics".to_string(),
799 state: RoleState {
800 login: true,
801 comment: Some("Analytics readonly role".to_string()),
802 ..RoleState::default()
803 },
804 };
805 let sql = render(&change);
806 assert!(sql.contains("LOGIN"));
807 assert!(sql.contains("COMMENT ON ROLE \"analytics\" IS 'Analytics readonly role';"));
808 }
809
810 #[test]
811 fn render_alter_role() {
812 let change = Change::AlterRole {
813 name: "r1".to_string(),
814 attributes: vec![RoleAttribute::Login(true), RoleAttribute::Createdb(true)],
815 };
816 let sql = render(&change);
817 assert_eq!(sql, "ALTER ROLE \"r1\" LOGIN CREATEDB;");
818 }
819
820 #[test]
821 fn render_drop_role() {
822 let change = Change::DropRole {
823 name: "old-role".to_string(),
824 };
825 assert_eq!(render(&change), "DROP ROLE IF EXISTS \"old-role\";");
826 }
827
828 #[test]
829 fn render_grant_schema_usage() {
830 let change = Change::Grant {
831 role: "inventory-editor".to_string(),
832 privileges: BTreeSet::from([Privilege::Usage]),
833 object_type: ObjectType::Schema,
834 schema: None,
835 name: Some("inventory".to_string()),
836 };
837 let sql = render(&change);
838 assert_eq!(
839 sql,
840 "GRANT USAGE ON SCHEMA \"inventory\" TO \"inventory-editor\";"
841 );
842 }
843
844 #[test]
845 fn render_grant_all_tables() {
846 let change = Change::Grant {
847 role: "inventory-editor".to_string(),
848 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
849 object_type: ObjectType::Table,
850 schema: Some("inventory".to_string()),
851 name: Some("*".to_string()),
852 };
853 let sql = render_statements_with_context(
854 &change,
855 &SqlContext::default().with_relation_inventory(BTreeMap::from([(
856 (ObjectType::Table, "inventory".to_string()),
857 vec!["orders".to_string(), "widgets".to_string()],
858 )])),
859 )
860 .join("\n");
861 assert_eq!(
862 sql,
863 "GRANT INSERT, SELECT ON TABLE \"inventory\".\"orders\" TO \"inventory-editor\";\nGRANT INSERT, SELECT ON TABLE \"inventory\".\"widgets\" TO \"inventory-editor\";"
864 );
865 }
866
867 #[test]
868 fn render_grant_specific_table() {
869 let change = Change::Grant {
870 role: "r1".to_string(),
871 privileges: BTreeSet::from([Privilege::Select]),
872 object_type: ObjectType::Table,
873 schema: Some("public".to_string()),
874 name: Some("users".to_string()),
875 };
876 let sql = render(&change);
877 assert_eq!(sql, "GRANT SELECT ON TABLE \"public\".\"users\" TO \"r1\";");
878 }
879
880 #[test]
881 fn render_grant_specific_function() {
882 let change = Change::Grant {
883 role: "r1".to_string(),
884 privileges: BTreeSet::from([Privilege::Execute]),
885 object_type: ObjectType::Function,
886 schema: Some("public".to_string()),
887 name: Some("refresh_users(integer, text)".to_string()),
888 };
889 let sql = render(&change);
890 assert_eq!(
891 sql,
892 "GRANT EXECUTE ON FUNCTION \"public\".\"refresh_users\"(integer, text) TO \"r1\";"
893 );
894 }
895
896 #[test]
897 fn render_revoke_all_sequences() {
898 let change = Change::Revoke {
899 role: "inventory-editor".to_string(),
900 privileges: BTreeSet::from([Privilege::Usage, Privilege::Select]),
901 object_type: ObjectType::Sequence,
902 schema: Some("inventory".to_string()),
903 name: Some("*".to_string()),
904 };
905 let sql = render(&change);
906 assert_eq!(
907 sql,
908 "REVOKE SELECT, USAGE ON ALL SEQUENCES IN SCHEMA \"inventory\" FROM \"inventory-editor\";"
909 );
910 }
911
912 #[test]
913 fn render_set_default_privilege() {
914 let change = Change::SetDefaultPrivilege {
915 owner: "app_owner".to_string(),
916 schema: "inventory".to_string(),
917 on_type: ObjectType::Table,
918 grantee: "inventory-editor".to_string(),
919 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
920 };
921 let sql = render(&change);
922 assert_eq!(
923 sql,
924 "ALTER DEFAULT PRIVILEGES FOR ROLE \"app_owner\" IN SCHEMA \"inventory\" GRANT INSERT, SELECT ON TABLES TO \"inventory-editor\";"
925 );
926 }
927
928 #[test]
929 fn render_revoke_default_privilege() {
930 let change = Change::RevokeDefaultPrivilege {
931 owner: "app_owner".to_string(),
932 schema: "inventory".to_string(),
933 on_type: ObjectType::Function,
934 grantee: "inventory-editor".to_string(),
935 privileges: BTreeSet::from([Privilege::Execute]),
936 };
937 let sql = render(&change);
938 assert_eq!(
939 sql,
940 "ALTER DEFAULT PRIVILEGES FOR ROLE \"app_owner\" IN SCHEMA \"inventory\" REVOKE EXECUTE ON FUNCTIONS FROM \"inventory-editor\";"
941 );
942 }
943
944 #[test]
945 fn render_add_member_basic() {
946 let change = Change::AddMember {
947 role: "inventory-editor".to_string(),
948 member: "user@example.com".to_string(),
949 inherit: true,
950 admin: false,
951 };
952 let sql = render(&change);
953 assert_eq!(
954 sql,
955 "GRANT \"inventory-editor\" TO \"user@example.com\" WITH INHERIT TRUE;"
956 );
957 }
958
959 #[test]
960 fn render_add_member_with_admin() {
961 let change = Change::AddMember {
962 role: "inventory-editor".to_string(),
963 member: "admin@example.com".to_string(),
964 inherit: true,
965 admin: true,
966 };
967 let sql = render(&change);
968 assert_eq!(
969 sql,
970 "GRANT \"inventory-editor\" TO \"admin@example.com\" WITH INHERIT TRUE, ADMIN TRUE;"
971 );
972 }
973
974 #[test]
975 fn render_add_member_no_inherit() {
976 let change = Change::AddMember {
977 role: "inventory-editor".to_string(),
978 member: "noinherit@example.com".to_string(),
979 inherit: false,
980 admin: false,
981 };
982 let sql = render(&change);
983 assert_eq!(
984 sql,
985 "GRANT \"inventory-editor\" TO \"noinherit@example.com\" WITH INHERIT FALSE;"
986 );
987 }
988
989 #[test]
990 fn render_remove_member() {
991 let change = Change::RemoveMember {
992 role: "inventory-editor".to_string(),
993 member: "user@example.com".to_string(),
994 };
995 let sql = render(&change);
996 assert_eq!(
997 sql,
998 "REVOKE \"inventory-editor\" FROM \"user@example.com\";"
999 );
1000 }
1001
1002 #[test]
1003 fn render_reassign_owned() {
1004 let change = Change::ReassignOwned {
1005 from_role: "legacy-owner".to_string(),
1006 to_role: "app-owner".to_string(),
1007 };
1008 assert_eq!(
1009 render(&change),
1010 "REASSIGN OWNED BY \"legacy-owner\" TO \"app-owner\";"
1011 );
1012 }
1013
1014 #[test]
1015 fn render_drop_owned() {
1016 let change = Change::DropOwned {
1017 role: "legacy-owner".to_string(),
1018 };
1019 assert_eq!(render(&change), "DROP OWNED BY \"legacy-owner\";");
1020 }
1021
1022 #[test]
1023 fn render_terminate_sessions() {
1024 let change = Change::TerminateSessions {
1025 role: "legacy-owner".to_string(),
1026 };
1027 assert_eq!(
1028 render(&change),
1029 "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = 'legacy-owner' AND pid <> pg_backend_pid();"
1030 );
1031 }
1032
1033 #[test]
1034 fn render_set_comment_some() {
1035 let change = Change::SetComment {
1036 name: "r1".to_string(),
1037 comment: Some("A test role".to_string()),
1038 };
1039 assert_eq!(render(&change), "COMMENT ON ROLE \"r1\" IS 'A test role';");
1040 }
1041
1042 #[test]
1043 fn render_set_comment_none() {
1044 let change = Change::SetComment {
1045 name: "r1".to_string(),
1046 comment: None,
1047 };
1048 assert_eq!(render(&change), "COMMENT ON ROLE \"r1\" IS NULL;");
1049 }
1050
1051 #[test]
1056 fn render_add_member_pg15_legacy_syntax() {
1057 let ctx = SqlContext {
1058 pg_major_version: 15,
1059 ..Default::default()
1060 };
1061 let change = Change::AddMember {
1062 role: "editors".to_string(),
1063 member: "user@example.com".to_string(),
1064 inherit: true,
1065 admin: false,
1066 };
1067 let sql = render_statements_with_context(&change, &ctx).join("\n");
1068 assert_eq!(sql, "GRANT \"editors\" TO \"user@example.com\";");
1069 }
1070
1071 #[test]
1072 fn render_add_member_pg15_with_admin() {
1073 let ctx = SqlContext {
1074 pg_major_version: 15,
1075 ..Default::default()
1076 };
1077 let change = Change::AddMember {
1078 role: "editors".to_string(),
1079 member: "admin@example.com".to_string(),
1080 inherit: true,
1081 admin: true,
1082 };
1083 let sql = render_statements_with_context(&change, &ctx).join("\n");
1084 assert_eq!(
1085 sql,
1086 "GRANT \"editors\" TO \"admin@example.com\" WITH ADMIN OPTION;"
1087 );
1088 }
1089
1090 #[test]
1091 fn render_add_member_pg16_with_options() {
1092 let ctx = SqlContext {
1093 pg_major_version: 16,
1094 ..Default::default()
1095 };
1096 let change = Change::AddMember {
1097 role: "editors".to_string(),
1098 member: "user@example.com".to_string(),
1099 inherit: false,
1100 admin: true,
1101 };
1102 let sql = render_statements_with_context(&change, &ctx).join("\n");
1103 assert_eq!(
1104 sql,
1105 "GRANT \"editors\" TO \"user@example.com\" WITH INHERIT FALSE, ADMIN TRUE;"
1106 );
1107 }
1108
1109 #[test]
1110 fn render_materialized_view_wildcard_with_inventory_expands_per_object() {
1111 let ctx = SqlContext::default().with_relation_inventory(BTreeMap::from([(
1112 (ObjectType::MaterializedView, "reporting".to_string()),
1113 vec!["daily_sales".to_string(), "weekly_sales".to_string()],
1114 )]));
1115 let change = Change::Revoke {
1116 role: "analytics".to_string(),
1117 privileges: [Privilege::Select].into_iter().collect(),
1118 object_type: ObjectType::MaterializedView,
1119 schema: Some("reporting".to_string()),
1120 name: Some("*".to_string()),
1121 };
1122
1123 let sql = render_statements_with_context(&change, &ctx);
1124 assert_eq!(
1125 sql,
1126 vec![
1127 "REVOKE SELECT ON TABLE \"reporting\".\"daily_sales\" FROM \"analytics\";"
1128 .to_string(),
1129 "REVOKE SELECT ON TABLE \"reporting\".\"weekly_sales\" FROM \"analytics\";"
1130 .to_string(),
1131 ]
1132 );
1133 }
1134
1135 #[test]
1136 fn render_materialized_view_wildcard_without_inventory_uses_catalog_loop() {
1137 let change = Change::Revoke {
1138 role: "analytics".to_string(),
1139 privileges: [Privilege::Select].into_iter().collect(),
1140 object_type: ObjectType::MaterializedView,
1141 schema: Some("reporting".to_string()),
1142 name: Some("*".to_string()),
1143 };
1144
1145 let sql = render_statements_with_context(&change, &SqlContext::default());
1146 assert_eq!(sql.len(), 1);
1147 assert!(sql[0].contains("WHERE c.relkind IN ('m')"));
1148 assert!(sql[0].contains("REVOKE SELECT ON TABLE %I.%I FROM %I;"));
1149 }
1150
1151 #[test]
1156 fn change_serializes_to_json() {
1157 let change = Change::CreateRole {
1158 name: "test".to_string(),
1159 state: RoleState::default(),
1160 };
1161 let json = serde_json::to_string(&change).unwrap();
1162 assert!(json.contains("CreateRole"));
1163 assert!(json.contains("test"));
1164 }
1165
1166 #[test]
1168 fn full_pipeline_manifest_to_sql() {
1169 use crate::diff::diff;
1170 use crate::manifest::{expand_manifest, parse_manifest};
1171 use crate::model::RoleGraph;
1172
1173 let yaml = r#"
1174default_owner: app_owner
1175
1176profiles:
1177 editor:
1178 grants:
1179 - privileges: [USAGE]
1180 object: { type: schema }
1181 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1182 object: { type: table, name: "*" }
1183 default_privileges:
1184 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1185 on_type: table
1186
1187schemas:
1188 - name: inventory
1189 owner: inventory_owner
1190 profiles: [editor]
1191
1192memberships:
1193 - role: inventory-editor
1194 members:
1195 - name: "user@example.com"
1196"#;
1197 let manifest = parse_manifest(yaml).unwrap();
1198 let expanded = expand_manifest(&manifest).unwrap();
1199 let desired =
1200 RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
1201 let current = RoleGraph::default();
1202
1203 let changes = diff(¤t, &desired);
1204 let sql = render_all(&changes);
1205
1206 assert!(sql.contains("CREATE ROLE \"inventory-editor\""));
1208 assert!(sql.contains("CREATE SCHEMA \"inventory\" AUTHORIZATION \"inventory_owner\";"));
1209 assert!(sql.contains("GRANT USAGE ON SCHEMA \"inventory\" TO \"inventory-editor\""));
1210 assert!(sql.contains("GRANT DELETE, INSERT, SELECT, UPDATE ON TABLE"));
1211 assert!(sql.contains("ALTER DEFAULT PRIVILEGES"));
1212 assert!(sql.contains("GRANT \"inventory-editor\" TO \"user@example.com\""));
1213
1214 #[cfg(test)]
1216 {
1217 eprintln!("--- Generated SQL ---\n{sql}\n--- End ---");
1218 }
1219 }
1220
1221 #[test]
1222 fn render_set_password() {
1223 let change = Change::SetPassword {
1224 name: "app-service".to_string(),
1225 password: "s3cret!".to_string(),
1226 };
1227 let sql = render(&change);
1228 assert_eq!(sql, "ALTER ROLE \"app-service\" PASSWORD 's3cret!';");
1229 }
1230
1231 #[test]
1232 fn render_set_password_escapes_quotes() {
1233 let change = Change::SetPassword {
1234 name: "r1".to_string(),
1235 password: "pass'word".to_string(),
1236 };
1237 let sql = render(&change);
1238 assert_eq!(sql, "ALTER ROLE \"r1\" PASSWORD 'pass''word';");
1239 }
1240
1241 #[test]
1242 fn render_set_password_with_backslash() {
1243 let change = Change::SetPassword {
1244 name: "r1".to_string(),
1245 password: r"pass\word".to_string(),
1246 };
1247 let sql = render(&change);
1248 assert_eq!(sql, r#"ALTER ROLE "r1" PASSWORD 'pass\word';"#);
1249 }
1250
1251 #[test]
1252 fn render_set_password_with_dollar_signs() {
1253 let change = Change::SetPassword {
1254 name: "r1".to_string(),
1255 password: "pa$$word".to_string(),
1256 };
1257 let sql = render(&change);
1258 assert_eq!(sql, "ALTER ROLE \"r1\" PASSWORD 'pa$$word';");
1259 }
1260
1261 #[test]
1262 fn render_set_password_with_unicode() {
1263 let change = Change::SetPassword {
1264 name: "r1".to_string(),
1265 password: "pässwörd_日本語".to_string(),
1266 };
1267 let sql = render(&change);
1268 assert_eq!(sql, "ALTER ROLE \"r1\" PASSWORD 'pässwörd_日本語';");
1269 }
1270
1271 #[test]
1272 fn render_set_password_with_newline() {
1273 let change = Change::SetPassword {
1274 name: "r1".to_string(),
1275 password: "line1\nline2".to_string(),
1276 };
1277 let sql = render(&change);
1278 assert_eq!(sql, "ALTER ROLE \"r1\" PASSWORD 'line1\nline2';");
1280 }
1281
1282 #[test]
1283 fn quote_literal_with_backslash() {
1284 assert_eq!(quote_literal(r"back\slash"), r"'back\slash'");
1287 }
1288
1289 #[test]
1290 fn quote_literal_with_multiple_quotes() {
1291 assert_eq!(quote_literal("it's a 'test'"), "'it''s a ''test'''");
1292 }
1293
1294 #[test]
1295 fn render_create_role_with_valid_until() {
1296 let change = Change::CreateRole {
1297 name: "expiring-role".to_string(),
1298 state: RoleState {
1299 login: true,
1300 password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
1301 ..RoleState::default()
1302 },
1303 };
1304 let sql = render(&change);
1305 assert!(sql.contains("LOGIN"));
1306 assert!(sql.contains("VALID UNTIL '2025-12-31T00:00:00Z'"));
1307 }
1308
1309 #[test]
1310 fn render_alter_role_valid_until_set() {
1311 let change = Change::AlterRole {
1312 name: "r1".to_string(),
1313 attributes: vec![RoleAttribute::ValidUntil(Some(
1314 "2025-06-01T00:00:00Z".to_string(),
1315 ))],
1316 };
1317 let sql = render(&change);
1318 assert_eq!(sql, "ALTER ROLE \"r1\" VALID UNTIL '2025-06-01T00:00:00Z';");
1319 }
1320
1321 #[test]
1322 fn render_alter_role_valid_until_remove() {
1323 let change = Change::AlterRole {
1324 name: "r1".to_string(),
1325 attributes: vec![RoleAttribute::ValidUntil(None)],
1326 };
1327 let sql = render(&change);
1328 assert_eq!(sql, "ALTER ROLE \"r1\" VALID UNTIL 'infinity';");
1329 }
1330}