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