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