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
260}
261
262fn bool_option(positive: &str, negative: &str, value: bool) -> String {
263 if value {
264 positive.to_string()
265 } else {
266 negative.to_string()
267 }
268}
269
270fn render_alter_role(name: &str, attributes: &[RoleAttribute]) -> Vec<String> {
275 let mut options = Vec::new();
276 for attr in attributes {
277 match attr {
278 RoleAttribute::Login(v) => options.push(bool_option("LOGIN", "NOLOGIN", *v)),
279 RoleAttribute::Superuser(v) => {
280 options.push(bool_option("SUPERUSER", "NOSUPERUSER", *v));
281 }
282 RoleAttribute::Createdb(v) => {
283 options.push(bool_option("CREATEDB", "NOCREATEDB", *v));
284 }
285 RoleAttribute::Createrole(v) => {
286 options.push(bool_option("CREATEROLE", "NOCREATEROLE", *v));
287 }
288 RoleAttribute::Inherit(v) => options.push(bool_option("INHERIT", "NOINHERIT", *v)),
289 RoleAttribute::Replication(v) => {
290 options.push(bool_option("REPLICATION", "NOREPLICATION", *v));
291 }
292 RoleAttribute::Bypassrls(v) => {
293 options.push(bool_option("BYPASSRLS", "NOBYPASSRLS", *v));
294 }
295 RoleAttribute::ConnectionLimit(v) => {
296 options.push(format!("CONNECTION LIMIT {v}"));
297 }
298 RoleAttribute::ValidUntil(v) => match v {
299 Some(ts) => options.push(format!("VALID UNTIL {}", quote_literal(ts))),
300 None => options.push("VALID UNTIL 'infinity'".to_string()),
301 },
302 RoleAttribute::SetConfig(..) | RoleAttribute::ResetConfig(..) => {}
305 }
306 }
307 let mut statements = Vec::new();
308 if !options.is_empty() {
309 statements.push(format!(
310 "ALTER ROLE {} {};",
311 quote_ident(name),
312 options.join(" ")
313 ));
314 }
315 for attr in attributes {
316 match attr {
317 RoleAttribute::SetConfig(parameter, value) => {
318 statements.push(render_set_config(name, parameter, value));
319 }
320 RoleAttribute::ResetConfig(parameter) => {
321 statements.push(format!(
322 "ALTER ROLE {} RESET {};",
323 quote_ident(name),
324 quote_ident(parameter)
325 ));
326 }
327 _ => {}
328 }
329 }
330 statements
331}
332
333fn render_set_config(name: &str, parameter: &str, value: &str) -> String {
343 let rendered_value = if crate::guc::is_list_quote_parameter(parameter) {
344 let elements = crate::guc::split_guc_list(value).unwrap_or_default();
345 if elements.is_empty() {
346 quote_literal("")
347 } else {
348 elements
349 .iter()
350 .map(|element| quote_literal(element))
351 .collect::<Vec<_>>()
352 .join(", ")
353 }
354 } else {
355 quote_literal(value)
356 };
357 format!(
358 "ALTER ROLE {} SET {} = {};",
359 quote_ident(name),
360 quote_ident(parameter),
361 rendered_value
362 )
363}
364
365fn render_set_comment(name: &str, comment: &Option<String>) -> Vec<String> {
370 vec![match comment {
371 Some(text) => format!(
372 "COMMENT ON ROLE {} IS {};",
373 quote_ident(name),
374 quote_literal(text)
375 ),
376 None => format!("COMMENT ON ROLE {} IS NULL;", quote_ident(name)),
377 }]
378}
379
380fn render_grant(
385 role: &str,
386 privileges: &BTreeSet<Privilege>,
387 object_type: ObjectType,
388 schema: Option<&str>,
389 name: Option<&str>,
390 ctx: &SqlContext,
391) -> Vec<String> {
392 let privilege_list = format_privileges(privileges);
393 render_privilege_statements(
394 "GRANT",
395 role,
396 &privilege_list,
397 object_type,
398 schema,
399 name,
400 ctx,
401 )
402}
403
404fn render_revoke(
405 role: &str,
406 privileges: &BTreeSet<Privilege>,
407 object_type: ObjectType,
408 schema: Option<&str>,
409 name: Option<&str>,
410 ctx: &SqlContext,
411) -> Vec<String> {
412 let privilege_list = format_privileges(privileges);
413 render_privilege_statements(
414 "REVOKE",
415 role,
416 &privilege_list,
417 object_type,
418 schema,
419 name,
420 ctx,
421 )
422}
423
424fn render_privilege_statements(
425 action: &str,
426 role: &str,
427 privilege_list: &str,
428 object_type: ObjectType,
429 schema: Option<&str>,
430 name: Option<&str>,
431 ctx: &SqlContext,
432) -> Vec<String> {
433 let subject_preposition = if action == "GRANT" { "TO" } else { "FROM" };
434 if matches!(
435 object_type,
436 ObjectType::Table | ObjectType::View | ObjectType::MaterializedView
437 ) && name == Some("*")
438 {
439 return render_relation_wildcard(
440 action,
441 subject_preposition,
442 role,
443 privilege_list,
444 object_type,
445 schema,
446 ctx,
447 );
448 }
449
450 let target = format_object_target(object_type, schema, name);
451 vec![format!(
452 "{action} {privilege_list} ON {target} {subject_preposition} {};",
453 quote_ident(role)
454 )]
455}
456
457fn render_relation_wildcard(
458 action: &str,
459 subject_preposition: &str,
460 role: &str,
461 privilege_list: &str,
462 object_type: ObjectType,
463 schema: Option<&str>,
464 ctx: &SqlContext,
465) -> Vec<String> {
466 let schema_name = schema.unwrap_or("public");
467
468 if let Some(object_names) = ctx
469 .relation_inventory
470 .get(&(object_type, schema_name.to_string()))
471 {
472 return object_names
473 .iter()
474 .map(|object_name| {
475 format!(
476 "{action} {privilege_list} ON TABLE {}.{} {subject_preposition} {};",
477 quote_ident(schema_name),
478 quote_ident(object_name),
479 quote_ident(role),
480 )
481 })
482 .collect();
483 }
484
485 vec![format!(
486 "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$;",
487 relation_relkinds_sql(object_type),
488 quote_literal(schema_name),
489 action,
490 privilege_list,
491 subject_preposition,
492 quote_literal(role),
493 )]
494}
495
496fn relation_relkinds_sql(object_type: ObjectType) -> &'static str {
497 match object_type {
498 ObjectType::Table => "'r', 'p'",
499 ObjectType::View => "'v'",
500 ObjectType::MaterializedView => "'m'",
501 _ => unreachable!("relation_relkinds_literal only supports relation object types"),
502 }
503}
504
505fn format_object_target(
512 object_type: ObjectType,
513 schema: Option<&str>,
514 name: Option<&str>,
515) -> String {
516 let type_keyword = sql_object_type_keyword(object_type);
517
518 match object_type {
519 ObjectType::Schema => {
520 let schema_name = name.unwrap_or("public");
522 format!("{type_keyword} {}", quote_ident(schema_name))
523 }
524 ObjectType::Database => {
525 let db_name = name.unwrap_or("postgres");
526 format!("{type_keyword} {}", quote_ident(db_name))
527 }
528 ObjectType::Function => match name {
529 Some("*") => {
530 let schema_name = schema.unwrap_or("public");
531 format!("ALL ROUTINES IN SCHEMA {}", quote_ident(schema_name))
532 }
533 Some(function_name) => format_function_target(schema, function_name),
534 None => {
535 let schema_name = schema.unwrap_or("public");
536 format!("{type_keyword} {}", quote_ident(schema_name))
537 }
538 },
539 _ => {
540 match name {
541 Some("*") => {
542 let plural = sql_object_type_plural(object_type);
544 let schema_name = schema.unwrap_or("public");
545 format!("ALL {plural} IN SCHEMA {}", quote_ident(schema_name))
546 }
547 Some(obj_name) => {
548 let schema_name = schema.unwrap_or("public");
550 format!(
551 "{type_keyword} {}.{}",
552 quote_ident(schema_name),
553 quote_ident(obj_name)
554 )
555 }
556 None => {
557 let schema_name = schema.unwrap_or("public");
559 format!("{type_keyword} {}", quote_ident(schema_name))
560 }
561 }
562 }
563 }
564}
565
566fn format_function_target(schema: Option<&str>, function_name: &str) -> String {
567 let schema_name = schema.unwrap_or("public");
568
569 match function_name.rfind('(') {
570 Some(paren_idx) if function_name.ends_with(')') => {
571 let base_name = &function_name[..paren_idx];
572 let args = &function_name[paren_idx..];
573 format!(
574 "ROUTINE {}.{}{}",
575 quote_ident(schema_name),
576 quote_ident(base_name),
577 args
578 )
579 }
580 _ => format!(
581 "ROUTINE {}.{}",
582 quote_ident(schema_name),
583 quote_ident(function_name)
584 ),
585 }
586}
587
588fn sql_object_type_keyword(object_type: ObjectType) -> &'static str {
590 match object_type {
591 ObjectType::Table => "TABLE",
592 ObjectType::View => "TABLE", ObjectType::MaterializedView => "TABLE", ObjectType::Sequence => "SEQUENCE",
595 ObjectType::Function => "ROUTINE",
596 ObjectType::Schema => "SCHEMA",
597 ObjectType::Database => "DATABASE",
598 ObjectType::Type => "TYPE",
599 }
600}
601
602fn sql_object_type_plural(object_type: ObjectType) -> &'static str {
604 match object_type {
605 ObjectType::Table | ObjectType::View | ObjectType::MaterializedView => "TABLES",
606 ObjectType::Sequence => "SEQUENCES",
607 ObjectType::Function => "ROUTINES",
608 ObjectType::Type => "TABLES",
612 ObjectType::Schema | ObjectType::Database => "TABLES",
614 }
615}
616
617fn format_privileges(privileges: &BTreeSet<Privilege>) -> String {
619 privileges
620 .iter()
621 .map(|p| p.to_string())
622 .collect::<Vec<_>>()
623 .join(", ")
624}
625
626fn render_set_default_privilege(
631 owner: &str,
632 schema: &str,
633 on_type: ObjectType,
634 grantee: &str,
635 privileges: &BTreeSet<Privilege>,
636) -> Vec<String> {
637 let privilege_list = format_privileges(privileges);
638 let type_plural = sql_object_type_plural(on_type);
639 vec![format!(
640 "ALTER DEFAULT PRIVILEGES FOR ROLE {} IN SCHEMA {} GRANT {} ON {} TO {};",
641 quote_ident(owner),
642 quote_ident(schema),
643 privilege_list,
644 type_plural,
645 quote_ident(grantee)
646 )]
647}
648
649fn render_revoke_default_privilege(
650 owner: &str,
651 schema: &str,
652 on_type: ObjectType,
653 grantee: &str,
654 privileges: &BTreeSet<Privilege>,
655) -> Vec<String> {
656 let privilege_list = format_privileges(privileges);
657 let type_plural = sql_object_type_plural(on_type);
658 vec![format!(
659 "ALTER DEFAULT PRIVILEGES FOR ROLE {} IN SCHEMA {} REVOKE {} ON {} FROM {};",
660 quote_ident(owner),
661 quote_ident(schema),
662 privilege_list,
663 type_plural,
664 quote_ident(grantee)
665 )]
666}
667
668fn render_add_member(
673 role: &str,
674 member: &str,
675 inherit: bool,
676 admin: bool,
677 ctx: &SqlContext,
678) -> Vec<String> {
679 let mut sql = format!("GRANT {} TO {}", quote_ident(role), quote_ident(member));
680
681 if ctx.supports_grant_with_options() {
682 let mut options = Vec::new();
684 if inherit {
685 options.push("INHERIT TRUE");
686 } else {
687 options.push("INHERIT FALSE");
688 }
689 if admin {
690 options.push("ADMIN TRUE");
691 }
692 if !options.is_empty() {
693 let _ = write!(sql, " WITH {}", options.join(", "));
694 }
695 } else {
696 if admin {
699 sql.push_str(" WITH ADMIN OPTION");
700 }
701 }
702
703 sql.push(';');
704 vec![sql]
705}
706
707fn render_remove_member(role: &str, member: &str) -> Vec<String> {
708 vec![format!(
709 "REVOKE {} FROM {};",
710 quote_ident(role),
711 quote_ident(member)
712 )]
713}
714
715fn render_reassign_owned(from_role: &str, to_role: &str) -> Vec<String> {
716 vec![format!(
717 "REASSIGN OWNED BY {} TO {};",
718 quote_ident(from_role),
719 quote_ident(to_role)
720 )]
721}
722
723fn render_drop_owned(role: &str) -> Vec<String> {
724 vec![format!("DROP OWNED BY {};", quote_ident(role))]
725}
726
727fn render_terminate_sessions(role: &str) -> Vec<String> {
728 vec![format!(
729 "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = {} AND pid <> pg_backend_pid();",
730 quote_literal(role)
731 )]
732}
733
734fn render_set_password(name: &str, password: &str) -> Vec<String> {
740 vec![format!(
741 "ALTER ROLE {} PASSWORD {};",
742 quote_ident(name),
743 quote_literal(password)
744 )]
745}
746
747fn quote_literal(value: &str) -> String {
753 format!("'{}'", value.replace('\'', "''"))
754}
755
756#[cfg(test)]
761mod tests {
762 use super::*;
763
764 #[test]
765 fn quote_ident_simple() {
766 assert_eq!(quote_ident("simple"), "\"simple\"");
767 }
768
769 #[test]
770 fn quote_ident_with_hyphen() {
771 assert_eq!(quote_ident("inventory-editor"), "\"inventory-editor\"");
772 }
773
774 #[test]
775 fn quote_ident_with_email() {
776 assert_eq!(quote_ident("user@example.com"), "\"user@example.com\"");
777 }
778
779 #[test]
780 fn quote_ident_with_embedded_quotes() {
781 assert_eq!(quote_ident("has\"quote"), "\"has\"\"quote\"");
782 }
783
784 #[test]
785 fn quote_literal_simple() {
786 assert_eq!(quote_literal("hello"), "'hello'");
787 }
788
789 #[test]
790 fn quote_literal_with_embedded_quotes() {
791 assert_eq!(quote_literal("it's"), "'it''s'");
792 }
793
794 #[test]
795 fn render_create_role_does_not_inline_config() {
796 let change = Change::CreateRole {
799 name: "blue".to_string(),
800 state: RoleState {
801 login: true,
802 config: [("role".to_string(), "combined".to_string())]
803 .into_iter()
804 .collect(),
805 ..RoleState::default()
806 },
807 };
808 let statements = render_statements(&change);
809 assert_eq!(statements.len(), 1);
810 assert!(statements[0].starts_with("CREATE ROLE \"blue\""));
811 }
812
813 #[test]
814 fn render_alter_role_config_only_emits_set_and_reset() {
815 let change = Change::AlterRole {
816 name: "blue".to_string(),
817 attributes: vec![
818 RoleAttribute::SetConfig("role".to_string(), "combined".to_string()),
819 RoleAttribute::ResetConfig("statement_timeout".to_string()),
820 ],
821 };
822 let statements = render_statements(&change);
823 assert_eq!(
824 statements,
825 vec![
826 "ALTER ROLE \"blue\" SET \"role\" = 'combined';".to_string(),
827 "ALTER ROLE \"blue\" RESET \"statement_timeout\";".to_string(),
828 ]
829 );
830 }
831
832 #[test]
833 fn render_alter_role_mixes_attributes_and_config() {
834 let change = Change::AlterRole {
835 name: "blue".to_string(),
836 attributes: vec![
837 RoleAttribute::Login(true),
838 RoleAttribute::SetConfig("search_path".to_string(), "app, public".to_string()),
839 ],
840 };
841 let statements = render_statements(&change);
842 assert_eq!(
843 statements,
844 vec![
845 "ALTER ROLE \"blue\" LOGIN;".to_string(),
846 "ALTER ROLE \"blue\" SET \"search_path\" = 'app', 'public';".to_string(),
849 ]
850 );
851 }
852
853 #[test]
854 fn render_set_config_splits_list_guc_elements() {
855 let change = Change::AlterRole {
856 name: "blue".to_string(),
857 attributes: vec![RoleAttribute::SetConfig(
858 "search_path".to_string(),
859 "\"$user\", public".to_string(),
860 )],
861 };
862 let statements = render_statements(&change);
863 assert_eq!(
864 statements,
865 vec!["ALTER ROLE \"blue\" SET \"search_path\" = '$user', 'public';".to_string()]
866 );
867 }
868
869 #[test]
870 fn render_set_config_keeps_non_list_values_as_single_literal() {
871 let change = Change::AlterRole {
872 name: "blue".to_string(),
873 attributes: vec![RoleAttribute::SetConfig(
874 "app.motd".to_string(),
875 "hello, world".to_string(),
876 )],
877 };
878 let statements = render_statements(&change);
879 assert_eq!(
880 statements,
881 vec!["ALTER ROLE \"blue\" SET \"app.motd\" = 'hello, world';".to_string()]
882 );
883 }
884
885 #[test]
886 fn render_set_config_quotes_literal_values() {
887 let change = Change::AlterRole {
888 name: "blue".to_string(),
889 attributes: vec![RoleAttribute::SetConfig(
890 "app.tenant".to_string(),
891 "o'brien".to_string(),
892 )],
893 };
894 let statements = render_statements(&change);
895 assert_eq!(
896 statements,
897 vec!["ALTER ROLE \"blue\" SET \"app.tenant\" = 'o''brien';".to_string()]
898 );
899 }
900
901 #[test]
902 fn render_create_role_basic() {
903 let change = Change::CreateRole {
904 name: "inventory-editor".to_string(),
905 state: RoleState::default(),
906 };
907 let sql = render(&change);
908 assert!(sql.starts_with("CREATE ROLE \"inventory-editor\""));
909 assert!(sql.contains("NOLOGIN"));
910 assert!(sql.contains("NOSUPERUSER"));
911 assert!(sql.contains("INHERIT")); assert!(sql.ends_with(';'));
913 }
914
915 #[test]
916 fn render_create_schema_with_owner() {
917 let change = Change::CreateSchema {
918 name: "inventory".to_string(),
919 owner: Some("inventory_owner".to_string()),
920 };
921 assert_eq!(
922 render(&change),
923 "CREATE SCHEMA \"inventory\" AUTHORIZATION \"inventory_owner\";"
924 );
925 }
926
927 #[test]
928 fn render_create_schema_without_owner() {
929 let change = Change::CreateSchema {
930 name: "inventory".to_string(),
931 owner: None,
932 };
933 assert_eq!(render(&change), "CREATE SCHEMA \"inventory\";");
934 }
935
936 #[test]
937 fn render_alter_schema_owner() {
938 let change = Change::AlterSchemaOwner {
939 name: "inventory".to_string(),
940 owner: "inventory_owner".to_string(),
941 };
942 assert_eq!(
943 render(&change),
944 "ALTER SCHEMA \"inventory\" OWNER TO \"inventory_owner\";"
945 );
946 }
947
948 #[test]
949 fn render_ensure_schema_owner_privileges() {
950 let change = Change::EnsureSchemaOwnerPrivileges {
951 name: "inventory".to_string(),
952 owner: "inventory_owner".to_string(),
953 privileges: BTreeSet::from([Privilege::Create, Privilege::Usage]),
954 };
955 assert_eq!(
956 render(&change),
957 "GRANT CREATE, USAGE ON SCHEMA \"inventory\" TO \"inventory_owner\";"
958 );
959 }
960
961 #[test]
962 fn render_create_role_with_login_and_comment() {
963 let change = Change::CreateRole {
964 name: "analytics".to_string(),
965 state: RoleState {
966 login: true,
967 comment: Some("Analytics readonly role".to_string()),
968 ..RoleState::default()
969 },
970 };
971 let sql = render(&change);
972 assert!(sql.contains("LOGIN"));
973 assert!(sql.contains("COMMENT ON ROLE \"analytics\" IS 'Analytics readonly role';"));
974 }
975
976 #[test]
977 fn render_alter_role() {
978 let change = Change::AlterRole {
979 name: "r1".to_string(),
980 attributes: vec![RoleAttribute::Login(true), RoleAttribute::Createdb(true)],
981 };
982 let sql = render(&change);
983 assert_eq!(sql, "ALTER ROLE \"r1\" LOGIN CREATEDB;");
984 }
985
986 #[test]
987 fn render_drop_role() {
988 let change = Change::DropRole {
989 name: "old-role".to_string(),
990 };
991 assert_eq!(render(&change), "DROP ROLE IF EXISTS \"old-role\";");
992 }
993
994 #[test]
995 fn render_grant_schema_usage() {
996 let change = Change::Grant {
997 role: "inventory-editor".to_string(),
998 privileges: BTreeSet::from([Privilege::Usage]),
999 object_type: ObjectType::Schema,
1000 schema: None,
1001 name: Some("inventory".to_string()),
1002 };
1003 let sql = render(&change);
1004 assert_eq!(
1005 sql,
1006 "GRANT USAGE ON SCHEMA \"inventory\" TO \"inventory-editor\";"
1007 );
1008 }
1009
1010 #[test]
1011 fn render_grant_all_tables() {
1012 let change = Change::Grant {
1013 role: "inventory-editor".to_string(),
1014 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
1015 object_type: ObjectType::Table,
1016 schema: Some("inventory".to_string()),
1017 name: Some("*".to_string()),
1018 };
1019 let sql = render_statements_with_context(
1020 &change,
1021 &SqlContext::default().with_relation_inventory(BTreeMap::from([(
1022 (ObjectType::Table, "inventory".to_string()),
1023 vec!["orders".to_string(), "widgets".to_string()],
1024 )])),
1025 )
1026 .join("\n");
1027 assert_eq!(
1028 sql,
1029 "GRANT INSERT, SELECT ON TABLE \"inventory\".\"orders\" TO \"inventory-editor\";\nGRANT INSERT, SELECT ON TABLE \"inventory\".\"widgets\" TO \"inventory-editor\";"
1030 );
1031 }
1032
1033 #[test]
1034 fn render_grant_specific_table() {
1035 let change = Change::Grant {
1036 role: "r1".to_string(),
1037 privileges: BTreeSet::from([Privilege::Select]),
1038 object_type: ObjectType::Table,
1039 schema: Some("public".to_string()),
1040 name: Some("users".to_string()),
1041 };
1042 let sql = render(&change);
1043 assert_eq!(sql, "GRANT SELECT ON TABLE \"public\".\"users\" TO \"r1\";");
1044 }
1045
1046 #[test]
1047 fn render_grant_specific_function() {
1048 let change = Change::Grant {
1049 role: "r1".to_string(),
1050 privileges: BTreeSet::from([Privilege::Execute]),
1051 object_type: ObjectType::Function,
1052 schema: Some("public".to_string()),
1053 name: Some("refresh_users(integer, text)".to_string()),
1054 };
1055 let sql = render(&change);
1056 assert_eq!(
1057 sql,
1058 "GRANT EXECUTE ON ROUTINE \"public\".\"refresh_users\"(integer, text) TO \"r1\";"
1059 );
1060 }
1061
1062 #[test]
1063 fn render_revoke_specific_routine_for_function_object() {
1064 let change = Change::Revoke {
1065 role: "r1".to_string(),
1066 privileges: BTreeSet::from([Privilege::Execute]),
1067 object_type: ObjectType::Function,
1068 schema: Some("public".to_string()),
1069 name: Some("run_something()".to_string()),
1070 };
1071 let sql = render(&change);
1072 assert_eq!(
1073 sql,
1074 "REVOKE EXECUTE ON ROUTINE \"public\".\"run_something\"() FROM \"r1\";"
1075 );
1076 }
1077
1078 #[test]
1079 fn render_grant_all_routines_for_function_wildcard() {
1080 let change = Change::Grant {
1081 role: "inventory-editor".to_string(),
1082 privileges: BTreeSet::from([Privilege::Execute]),
1083 object_type: ObjectType::Function,
1084 schema: Some("inventory".to_string()),
1085 name: Some("*".to_string()),
1086 };
1087 let sql = render(&change);
1088 assert_eq!(
1089 sql,
1090 "GRANT EXECUTE ON ALL ROUTINES IN SCHEMA \"inventory\" TO \"inventory-editor\";"
1091 );
1092 }
1093
1094 #[test]
1095 fn render_revoke_all_sequences() {
1096 let change = Change::Revoke {
1097 role: "inventory-editor".to_string(),
1098 privileges: BTreeSet::from([Privilege::Usage, Privilege::Select]),
1099 object_type: ObjectType::Sequence,
1100 schema: Some("inventory".to_string()),
1101 name: Some("*".to_string()),
1102 };
1103 let sql = render(&change);
1104 assert_eq!(
1105 sql,
1106 "REVOKE SELECT, USAGE ON ALL SEQUENCES IN SCHEMA \"inventory\" FROM \"inventory-editor\";"
1107 );
1108 }
1109
1110 #[test]
1111 fn render_set_default_privilege() {
1112 let change = Change::SetDefaultPrivilege {
1113 owner: "app_owner".to_string(),
1114 schema: "inventory".to_string(),
1115 on_type: ObjectType::Table,
1116 grantee: "inventory-editor".to_string(),
1117 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
1118 };
1119 let sql = render(&change);
1120 assert_eq!(
1121 sql,
1122 "ALTER DEFAULT PRIVILEGES FOR ROLE \"app_owner\" IN SCHEMA \"inventory\" GRANT INSERT, SELECT ON TABLES TO \"inventory-editor\";"
1123 );
1124 }
1125
1126 #[test]
1127 fn render_revoke_default_privilege() {
1128 let change = Change::RevokeDefaultPrivilege {
1129 owner: "app_owner".to_string(),
1130 schema: "inventory".to_string(),
1131 on_type: ObjectType::Function,
1132 grantee: "inventory-editor".to_string(),
1133 privileges: BTreeSet::from([Privilege::Execute]),
1134 };
1135 let sql = render(&change);
1136 assert_eq!(
1137 sql,
1138 "ALTER DEFAULT PRIVILEGES FOR ROLE \"app_owner\" IN SCHEMA \"inventory\" REVOKE EXECUTE ON ROUTINES FROM \"inventory-editor\";"
1139 );
1140 }
1141
1142 #[test]
1143 fn render_add_member_basic() {
1144 let change = Change::AddMember {
1145 role: "inventory-editor".to_string(),
1146 member: "user@example.com".to_string(),
1147 inherit: true,
1148 admin: false,
1149 };
1150 let sql = render(&change);
1151 assert_eq!(
1152 sql,
1153 "GRANT \"inventory-editor\" TO \"user@example.com\" WITH INHERIT TRUE;"
1154 );
1155 }
1156
1157 #[test]
1158 fn render_add_member_with_admin() {
1159 let change = Change::AddMember {
1160 role: "inventory-editor".to_string(),
1161 member: "admin@example.com".to_string(),
1162 inherit: true,
1163 admin: true,
1164 };
1165 let sql = render(&change);
1166 assert_eq!(
1167 sql,
1168 "GRANT \"inventory-editor\" TO \"admin@example.com\" WITH INHERIT TRUE, ADMIN TRUE;"
1169 );
1170 }
1171
1172 #[test]
1173 fn render_add_member_no_inherit() {
1174 let change = Change::AddMember {
1175 role: "inventory-editor".to_string(),
1176 member: "noinherit@example.com".to_string(),
1177 inherit: false,
1178 admin: false,
1179 };
1180 let sql = render(&change);
1181 assert_eq!(
1182 sql,
1183 "GRANT \"inventory-editor\" TO \"noinherit@example.com\" WITH INHERIT FALSE;"
1184 );
1185 }
1186
1187 #[test]
1188 fn render_remove_member() {
1189 let change = Change::RemoveMember {
1190 role: "inventory-editor".to_string(),
1191 member: "user@example.com".to_string(),
1192 };
1193 let sql = render(&change);
1194 assert_eq!(
1195 sql,
1196 "REVOKE \"inventory-editor\" FROM \"user@example.com\";"
1197 );
1198 }
1199
1200 #[test]
1201 fn render_reassign_owned() {
1202 let change = Change::ReassignOwned {
1203 from_role: "legacy-owner".to_string(),
1204 to_role: "app-owner".to_string(),
1205 };
1206 assert_eq!(
1207 render(&change),
1208 "REASSIGN OWNED BY \"legacy-owner\" TO \"app-owner\";"
1209 );
1210 }
1211
1212 #[test]
1213 fn render_drop_owned() {
1214 let change = Change::DropOwned {
1215 role: "legacy-owner".to_string(),
1216 };
1217 assert_eq!(render(&change), "DROP OWNED BY \"legacy-owner\";");
1218 }
1219
1220 #[test]
1221 fn render_terminate_sessions() {
1222 let change = Change::TerminateSessions {
1223 role: "legacy-owner".to_string(),
1224 };
1225 assert_eq!(
1226 render(&change),
1227 "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = 'legacy-owner' AND pid <> pg_backend_pid();"
1228 );
1229 }
1230
1231 #[test]
1232 fn render_set_comment_some() {
1233 let change = Change::SetComment {
1234 name: "r1".to_string(),
1235 comment: Some("A test role".to_string()),
1236 };
1237 assert_eq!(render(&change), "COMMENT ON ROLE \"r1\" IS 'A test role';");
1238 }
1239
1240 #[test]
1241 fn render_set_comment_none() {
1242 let change = Change::SetComment {
1243 name: "r1".to_string(),
1244 comment: None,
1245 };
1246 assert_eq!(render(&change), "COMMENT ON ROLE \"r1\" IS NULL;");
1247 }
1248
1249 #[test]
1254 fn render_add_member_pg15_legacy_syntax() {
1255 let ctx = SqlContext {
1256 pg_major_version: 15,
1257 ..Default::default()
1258 };
1259 let change = Change::AddMember {
1260 role: "editors".to_string(),
1261 member: "user@example.com".to_string(),
1262 inherit: true,
1263 admin: false,
1264 };
1265 let sql = render_statements_with_context(&change, &ctx).join("\n");
1266 assert_eq!(sql, "GRANT \"editors\" TO \"user@example.com\";");
1267 }
1268
1269 #[test]
1270 fn render_add_member_pg15_with_admin() {
1271 let ctx = SqlContext {
1272 pg_major_version: 15,
1273 ..Default::default()
1274 };
1275 let change = Change::AddMember {
1276 role: "editors".to_string(),
1277 member: "admin@example.com".to_string(),
1278 inherit: true,
1279 admin: true,
1280 };
1281 let sql = render_statements_with_context(&change, &ctx).join("\n");
1282 assert_eq!(
1283 sql,
1284 "GRANT \"editors\" TO \"admin@example.com\" WITH ADMIN OPTION;"
1285 );
1286 }
1287
1288 #[test]
1289 fn render_add_member_pg16_with_options() {
1290 let ctx = SqlContext {
1291 pg_major_version: 16,
1292 ..Default::default()
1293 };
1294 let change = Change::AddMember {
1295 role: "editors".to_string(),
1296 member: "user@example.com".to_string(),
1297 inherit: false,
1298 admin: true,
1299 };
1300 let sql = render_statements_with_context(&change, &ctx).join("\n");
1301 assert_eq!(
1302 sql,
1303 "GRANT \"editors\" TO \"user@example.com\" WITH INHERIT FALSE, ADMIN TRUE;"
1304 );
1305 }
1306
1307 #[test]
1308 fn render_materialized_view_wildcard_with_inventory_expands_per_object() {
1309 let ctx = SqlContext::default().with_relation_inventory(BTreeMap::from([(
1310 (ObjectType::MaterializedView, "reporting".to_string()),
1311 vec!["daily_sales".to_string(), "weekly_sales".to_string()],
1312 )]));
1313 let change = Change::Revoke {
1314 role: "analytics".to_string(),
1315 privileges: [Privilege::Select].into_iter().collect(),
1316 object_type: ObjectType::MaterializedView,
1317 schema: Some("reporting".to_string()),
1318 name: Some("*".to_string()),
1319 };
1320
1321 let sql = render_statements_with_context(&change, &ctx);
1322 assert_eq!(
1323 sql,
1324 vec![
1325 "REVOKE SELECT ON TABLE \"reporting\".\"daily_sales\" FROM \"analytics\";"
1326 .to_string(),
1327 "REVOKE SELECT ON TABLE \"reporting\".\"weekly_sales\" FROM \"analytics\";"
1328 .to_string(),
1329 ]
1330 );
1331 }
1332
1333 #[test]
1334 fn render_materialized_view_wildcard_without_inventory_uses_catalog_loop() {
1335 let change = Change::Revoke {
1336 role: "analytics".to_string(),
1337 privileges: [Privilege::Select].into_iter().collect(),
1338 object_type: ObjectType::MaterializedView,
1339 schema: Some("reporting".to_string()),
1340 name: Some("*".to_string()),
1341 };
1342
1343 let sql = render_statements_with_context(&change, &SqlContext::default());
1344 assert_eq!(sql.len(), 1);
1345 assert!(sql[0].contains("WHERE c.relkind IN ('m')"));
1346 assert!(sql[0].contains("REVOKE SELECT ON TABLE %I.%I FROM %I;"));
1347 }
1348
1349 #[test]
1354 fn change_serializes_to_json() {
1355 let change = Change::CreateRole {
1356 name: "test".to_string(),
1357 state: RoleState::default(),
1358 };
1359 let json = serde_json::to_string(&change).unwrap();
1360 assert!(json.contains("CreateRole"));
1361 assert!(json.contains("test"));
1362 }
1363
1364 #[test]
1366 fn full_pipeline_manifest_to_sql() {
1367 use crate::diff::diff;
1368 use crate::manifest::{expand_manifest, parse_manifest};
1369 use crate::model::RoleGraph;
1370
1371 let yaml = r#"
1372default_owner: app_owner
1373
1374profiles:
1375 editor:
1376 grants:
1377 - privileges: [USAGE]
1378 object: { type: schema }
1379 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1380 object: { type: table, name: "*" }
1381 default_privileges:
1382 - privileges: [SELECT, INSERT, UPDATE, DELETE]
1383 on_type: table
1384
1385schemas:
1386 - name: inventory
1387 owner: inventory_owner
1388 profiles: [editor]
1389
1390memberships:
1391 - role: inventory-editor
1392 members:
1393 - name: "user@example.com"
1394"#;
1395 let manifest = parse_manifest(yaml).unwrap();
1396 let expanded = expand_manifest(&manifest).unwrap();
1397 let desired =
1398 RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
1399 let current = RoleGraph::default();
1400
1401 let changes = diff(¤t, &desired);
1402 let sql = render_all(&changes);
1403
1404 assert!(sql.contains("CREATE ROLE \"inventory-editor\""));
1406 assert!(sql.contains("CREATE SCHEMA \"inventory\" AUTHORIZATION \"inventory_owner\";"));
1407 assert!(sql.contains("GRANT USAGE ON SCHEMA \"inventory\" TO \"inventory-editor\""));
1408 assert!(sql.contains("GRANT DELETE, INSERT, SELECT, UPDATE ON TABLE"));
1409 assert!(sql.contains("ALTER DEFAULT PRIVILEGES"));
1410 assert!(sql.contains("GRANT \"inventory-editor\" TO \"user@example.com\""));
1411
1412 #[cfg(test)]
1414 {
1415 eprintln!("--- Generated SQL ---\n{sql}\n--- End ---");
1416 }
1417 }
1418
1419 #[test]
1420 fn render_set_password() {
1421 let change = Change::SetPassword {
1422 name: "app-service".to_string(),
1423 password: "s3cret!".to_string(),
1424 };
1425 let sql = render(&change);
1426 assert_eq!(sql, "ALTER ROLE \"app-service\" PASSWORD 's3cret!';");
1427 }
1428
1429 #[test]
1430 fn render_set_password_escapes_quotes() {
1431 let change = Change::SetPassword {
1432 name: "r1".to_string(),
1433 password: "pass'word".to_string(),
1434 };
1435 let sql = render(&change);
1436 assert_eq!(sql, "ALTER ROLE \"r1\" PASSWORD 'pass''word';");
1437 }
1438
1439 #[test]
1440 fn render_set_password_with_backslash() {
1441 let change = Change::SetPassword {
1442 name: "r1".to_string(),
1443 password: r"pass\word".to_string(),
1444 };
1445 let sql = render(&change);
1446 assert_eq!(sql, r#"ALTER ROLE "r1" PASSWORD 'pass\word';"#);
1447 }
1448
1449 #[test]
1450 fn render_set_password_with_dollar_signs() {
1451 let change = Change::SetPassword {
1452 name: "r1".to_string(),
1453 password: "pa$$word".to_string(),
1454 };
1455 let sql = render(&change);
1456 assert_eq!(sql, "ALTER ROLE \"r1\" PASSWORD 'pa$$word';");
1457 }
1458
1459 #[test]
1460 fn render_set_password_with_unicode() {
1461 let change = Change::SetPassword {
1462 name: "r1".to_string(),
1463 password: "pässwörd_日本語".to_string(),
1464 };
1465 let sql = render(&change);
1466 assert_eq!(sql, "ALTER ROLE \"r1\" PASSWORD 'pässwörd_日本語';");
1467 }
1468
1469 #[test]
1470 fn render_set_password_with_newline() {
1471 let change = Change::SetPassword {
1472 name: "r1".to_string(),
1473 password: "line1\nline2".to_string(),
1474 };
1475 let sql = render(&change);
1476 assert_eq!(sql, "ALTER ROLE \"r1\" PASSWORD 'line1\nline2';");
1478 }
1479
1480 #[test]
1481 fn quote_literal_with_backslash() {
1482 assert_eq!(quote_literal(r"back\slash"), r"'back\slash'");
1485 }
1486
1487 #[test]
1488 fn quote_literal_with_multiple_quotes() {
1489 assert_eq!(quote_literal("it's a 'test'"), "'it''s a ''test'''");
1490 }
1491
1492 #[test]
1493 fn render_create_role_with_valid_until() {
1494 let change = Change::CreateRole {
1495 name: "expiring-role".to_string(),
1496 state: RoleState {
1497 login: true,
1498 password_valid_until: Some("2025-12-31T00:00:00Z".to_string()),
1499 ..RoleState::default()
1500 },
1501 };
1502 let sql = render(&change);
1503 assert!(sql.contains("LOGIN"));
1504 assert!(sql.contains("VALID UNTIL '2025-12-31T00:00:00Z'"));
1505 }
1506
1507 #[test]
1508 fn render_alter_role_valid_until_set() {
1509 let change = Change::AlterRole {
1510 name: "r1".to_string(),
1511 attributes: vec![RoleAttribute::ValidUntil(Some(
1512 "2025-06-01T00:00:00Z".to_string(),
1513 ))],
1514 };
1515 let sql = render(&change);
1516 assert_eq!(sql, "ALTER ROLE \"r1\" VALID UNTIL '2025-06-01T00:00:00Z';");
1517 }
1518
1519 #[test]
1520 fn render_alter_role_valid_until_remove() {
1521 let change = Change::AlterRole {
1522 name: "r1".to_string(),
1523 attributes: vec![RoleAttribute::ValidUntil(None)],
1524 };
1525 let sql = render(&change);
1526 assert_eq!(sql, "ALTER ROLE \"r1\" VALID UNTIL 'infinity';");
1527 }
1528}