1use std::collections::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, Copy)]
36pub struct SqlContext {
37 pub pg_major_version: i32,
40}
41
42impl SqlContext {
43 pub fn from_version_num(version_num: i32) -> Self {
45 Self {
46 pg_major_version: version_num / 10000,
47 }
48 }
49
50 pub fn supports_grant_with_options(&self) -> bool {
52 self.pg_major_version >= 16
53 }
54}
55
56impl Default for SqlContext {
57 fn default() -> Self {
58 Self {
59 pg_major_version: 16, }
61 }
62}
63
64pub fn render(change: &Change) -> String {
71 render_statements(change).join("\n")
72}
73
74pub fn render_statements(change: &Change) -> Vec<String> {
77 render_statements_with_context(change, &SqlContext::default())
78}
79
80pub fn render_statements_with_context(change: &Change, ctx: &SqlContext) -> Vec<String> {
83 match change {
84 Change::CreateRole { name, state } => render_create_role(name, state),
85 Change::AlterRole { name, attributes } => render_alter_role(name, attributes),
86 Change::SetComment { name, comment } => render_set_comment(name, comment),
87 Change::Grant {
88 role,
89 privileges,
90 object_type,
91 schema,
92 name,
93 } => render_grant(
94 role,
95 privileges,
96 *object_type,
97 schema.as_deref(),
98 name.as_deref(),
99 ),
100 Change::Revoke {
101 role,
102 privileges,
103 object_type,
104 schema,
105 name,
106 } => render_revoke(
107 role,
108 privileges,
109 *object_type,
110 schema.as_deref(),
111 name.as_deref(),
112 ),
113 Change::SetDefaultPrivilege {
114 owner,
115 schema,
116 on_type,
117 grantee,
118 privileges,
119 } => render_set_default_privilege(owner, schema, *on_type, grantee, privileges),
120 Change::RevokeDefaultPrivilege {
121 owner,
122 schema,
123 on_type,
124 grantee,
125 privileges,
126 } => render_revoke_default_privilege(owner, schema, *on_type, grantee, privileges),
127 Change::AddMember {
128 role,
129 member,
130 inherit,
131 admin,
132 } => render_add_member(role, member, *inherit, *admin, ctx),
133 Change::RemoveMember { role, member } => render_remove_member(role, member),
134 Change::ReassignOwned { from_role, to_role } => render_reassign_owned(from_role, to_role),
135 Change::DropOwned { role } => render_drop_owned(role),
136 Change::TerminateSessions { role } => render_terminate_sessions(role),
137 Change::DropRole { name } => vec![format!("DROP ROLE {};", quote_ident(name))],
138 }
139}
140
141pub fn render_all(changes: &[Change]) -> String {
143 render_all_with_context(changes, &SqlContext::default())
144}
145
146pub fn render_all_with_context(changes: &[Change], ctx: &SqlContext) -> String {
148 changes
149 .iter()
150 .flat_map(|c| render_statements_with_context(c, ctx))
151 .collect::<Vec<_>>()
152 .join("\n")
153}
154
155fn render_create_role(name: &str, state: &RoleState) -> Vec<String> {
160 let mut sql = format!("CREATE ROLE {}", quote_ident(name));
161 let mut options = vec![
162 bool_option("LOGIN", "NOLOGIN", state.login),
163 bool_option("SUPERUSER", "NOSUPERUSER", state.superuser),
164 bool_option("CREATEDB", "NOCREATEDB", state.createdb),
165 bool_option("CREATEROLE", "NOCREATEROLE", state.createrole),
166 bool_option("INHERIT", "NOINHERIT", state.inherit),
167 bool_option("REPLICATION", "NOREPLICATION", state.replication),
168 bool_option("BYPASSRLS", "NOBYPASSRLS", state.bypassrls),
169 ];
170
171 if state.connection_limit != -1 {
172 options.push(format!("CONNECTION LIMIT {}", state.connection_limit));
173 }
174
175 let _ = write!(sql, " {}", options.join(" "));
176 sql.push(';');
177
178 let mut statements = vec![sql];
179 if let Some(comment) = &state.comment {
180 statements.push(format!(
181 "COMMENT ON ROLE {} IS {};",
182 quote_ident(name),
183 quote_literal(comment)
184 ));
185 }
186
187 statements
188}
189
190fn bool_option(positive: &str, negative: &str, value: bool) -> String {
191 if value {
192 positive.to_string()
193 } else {
194 negative.to_string()
195 }
196}
197
198fn render_alter_role(name: &str, attributes: &[RoleAttribute]) -> Vec<String> {
203 let mut options = Vec::new();
204 for attr in attributes {
205 match attr {
206 RoleAttribute::Login(v) => options.push(bool_option("LOGIN", "NOLOGIN", *v)),
207 RoleAttribute::Superuser(v) => {
208 options.push(bool_option("SUPERUSER", "NOSUPERUSER", *v));
209 }
210 RoleAttribute::Createdb(v) => {
211 options.push(bool_option("CREATEDB", "NOCREATEDB", *v));
212 }
213 RoleAttribute::Createrole(v) => {
214 options.push(bool_option("CREATEROLE", "NOCREATEROLE", *v));
215 }
216 RoleAttribute::Inherit(v) => options.push(bool_option("INHERIT", "NOINHERIT", *v)),
217 RoleAttribute::Replication(v) => {
218 options.push(bool_option("REPLICATION", "NOREPLICATION", *v));
219 }
220 RoleAttribute::Bypassrls(v) => {
221 options.push(bool_option("BYPASSRLS", "NOBYPASSRLS", *v));
222 }
223 RoleAttribute::ConnectionLimit(v) => {
224 options.push(format!("CONNECTION LIMIT {v}"));
225 }
226 }
227 }
228 vec![format!(
229 "ALTER ROLE {} {};",
230 quote_ident(name),
231 options.join(" ")
232 )]
233}
234
235fn render_set_comment(name: &str, comment: &Option<String>) -> Vec<String> {
240 vec![match comment {
241 Some(text) => format!(
242 "COMMENT ON ROLE {} IS {};",
243 quote_ident(name),
244 quote_literal(text)
245 ),
246 None => format!("COMMENT ON ROLE {} IS NULL;", quote_ident(name)),
247 }]
248}
249
250fn render_grant(
255 role: &str,
256 privileges: &BTreeSet<Privilege>,
257 object_type: ObjectType,
258 schema: Option<&str>,
259 name: Option<&str>,
260) -> Vec<String> {
261 let privilege_list = format_privileges(privileges);
262 let target = format_object_target(object_type, schema, name);
263 vec![format!(
264 "GRANT {} ON {} TO {};",
265 privilege_list,
266 target,
267 quote_ident(role)
268 )]
269}
270
271fn render_revoke(
272 role: &str,
273 privileges: &BTreeSet<Privilege>,
274 object_type: ObjectType,
275 schema: Option<&str>,
276 name: Option<&str>,
277) -> Vec<String> {
278 let privilege_list = format_privileges(privileges);
279 let target = format_object_target(object_type, schema, name);
280 vec![format!(
281 "REVOKE {} ON {} FROM {};",
282 privilege_list,
283 target,
284 quote_ident(role)
285 )]
286}
287
288fn format_object_target(
295 object_type: ObjectType,
296 schema: Option<&str>,
297 name: Option<&str>,
298) -> String {
299 let type_keyword = sql_object_type_keyword(object_type);
300
301 match object_type {
302 ObjectType::Schema => {
303 let schema_name = name.unwrap_or("public");
305 format!("{type_keyword} {}", quote_ident(schema_name))
306 }
307 ObjectType::Database => {
308 let db_name = name.unwrap_or("postgres");
309 format!("{type_keyword} {}", quote_ident(db_name))
310 }
311 ObjectType::Function => match name {
312 Some("*") => {
313 let schema_name = schema.unwrap_or("public");
314 format!("ALL FUNCTIONS IN SCHEMA {}", quote_ident(schema_name))
315 }
316 Some(function_name) => format_function_target(schema, function_name),
317 None => {
318 let schema_name = schema.unwrap_or("public");
319 format!("{type_keyword} {}", quote_ident(schema_name))
320 }
321 },
322 _ => {
323 match name {
324 Some("*") => {
325 let plural = sql_object_type_plural(object_type);
327 let schema_name = schema.unwrap_or("public");
328 format!("ALL {plural} IN SCHEMA {}", quote_ident(schema_name))
329 }
330 Some(obj_name) => {
331 let schema_name = schema.unwrap_or("public");
333 format!(
334 "{type_keyword} {}.{}",
335 quote_ident(schema_name),
336 quote_ident(obj_name)
337 )
338 }
339 None => {
340 let schema_name = schema.unwrap_or("public");
342 format!("{type_keyword} {}", quote_ident(schema_name))
343 }
344 }
345 }
346 }
347}
348
349fn format_function_target(schema: Option<&str>, function_name: &str) -> String {
350 let schema_name = schema.unwrap_or("public");
351
352 match function_name.rfind('(') {
353 Some(paren_idx) if function_name.ends_with(')') => {
354 let base_name = &function_name[..paren_idx];
355 let args = &function_name[paren_idx..];
356 format!(
357 "FUNCTION {}.{}{}",
358 quote_ident(schema_name),
359 quote_ident(base_name),
360 args
361 )
362 }
363 _ => format!(
364 "FUNCTION {}.{}",
365 quote_ident(schema_name),
366 quote_ident(function_name)
367 ),
368 }
369}
370
371fn sql_object_type_keyword(object_type: ObjectType) -> &'static str {
373 match object_type {
374 ObjectType::Table => "TABLE",
375 ObjectType::View => "TABLE", ObjectType::MaterializedView => "TABLE", ObjectType::Sequence => "SEQUENCE",
378 ObjectType::Function => "FUNCTION",
379 ObjectType::Schema => "SCHEMA",
380 ObjectType::Database => "DATABASE",
381 ObjectType::Type => "TYPE",
382 }
383}
384
385fn sql_object_type_plural(object_type: ObjectType) -> &'static str {
387 match object_type {
388 ObjectType::Table | ObjectType::View | ObjectType::MaterializedView => "TABLES",
389 ObjectType::Sequence => "SEQUENCES",
390 ObjectType::Function => "FUNCTIONS",
391 ObjectType::Type => "TABLES",
395 ObjectType::Schema | ObjectType::Database => "TABLES",
397 }
398}
399
400fn format_privileges(privileges: &BTreeSet<Privilege>) -> String {
402 privileges
403 .iter()
404 .map(|p| p.to_string())
405 .collect::<Vec<_>>()
406 .join(", ")
407}
408
409fn render_set_default_privilege(
414 owner: &str,
415 schema: &str,
416 on_type: ObjectType,
417 grantee: &str,
418 privileges: &BTreeSet<Privilege>,
419) -> Vec<String> {
420 let privilege_list = format_privileges(privileges);
421 let type_plural = sql_object_type_plural(on_type);
422 vec![format!(
423 "ALTER DEFAULT PRIVILEGES FOR ROLE {} IN SCHEMA {} GRANT {} ON {} TO {};",
424 quote_ident(owner),
425 quote_ident(schema),
426 privilege_list,
427 type_plural,
428 quote_ident(grantee)
429 )]
430}
431
432fn render_revoke_default_privilege(
433 owner: &str,
434 schema: &str,
435 on_type: ObjectType,
436 grantee: &str,
437 privileges: &BTreeSet<Privilege>,
438) -> Vec<String> {
439 let privilege_list = format_privileges(privileges);
440 let type_plural = sql_object_type_plural(on_type);
441 vec![format!(
442 "ALTER DEFAULT PRIVILEGES FOR ROLE {} IN SCHEMA {} REVOKE {} ON {} FROM {};",
443 quote_ident(owner),
444 quote_ident(schema),
445 privilege_list,
446 type_plural,
447 quote_ident(grantee)
448 )]
449}
450
451fn render_add_member(
456 role: &str,
457 member: &str,
458 inherit: bool,
459 admin: bool,
460 ctx: &SqlContext,
461) -> Vec<String> {
462 let mut sql = format!("GRANT {} TO {}", quote_ident(role), quote_ident(member));
463
464 if ctx.supports_grant_with_options() {
465 let mut options = Vec::new();
467 if inherit {
468 options.push("INHERIT TRUE");
469 } else {
470 options.push("INHERIT FALSE");
471 }
472 if admin {
473 options.push("ADMIN TRUE");
474 }
475 if !options.is_empty() {
476 let _ = write!(sql, " WITH {}", options.join(", "));
477 }
478 } else {
479 if admin {
482 sql.push_str(" WITH ADMIN OPTION");
483 }
484 }
485
486 sql.push(';');
487 vec![sql]
488}
489
490fn render_remove_member(role: &str, member: &str) -> Vec<String> {
491 vec![format!(
492 "REVOKE {} FROM {};",
493 quote_ident(role),
494 quote_ident(member)
495 )]
496}
497
498fn render_reassign_owned(from_role: &str, to_role: &str) -> Vec<String> {
499 vec![format!(
500 "REASSIGN OWNED BY {} TO {};",
501 quote_ident(from_role),
502 quote_ident(to_role)
503 )]
504}
505
506fn render_drop_owned(role: &str) -> Vec<String> {
507 vec![format!("DROP OWNED BY {};", quote_ident(role))]
508}
509
510fn render_terminate_sessions(role: &str) -> Vec<String> {
511 vec![format!(
512 "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = {} AND pid <> pg_backend_pid();",
513 quote_literal(role)
514 )]
515}
516
517fn quote_literal(value: &str) -> String {
523 format!("'{}'", value.replace('\'', "''"))
524}
525
526#[cfg(test)]
531mod tests {
532 use super::*;
533
534 #[test]
535 fn quote_ident_simple() {
536 assert_eq!(quote_ident("simple"), "\"simple\"");
537 }
538
539 #[test]
540 fn quote_ident_with_hyphen() {
541 assert_eq!(quote_ident("inventory-editor"), "\"inventory-editor\"");
542 }
543
544 #[test]
545 fn quote_ident_with_email() {
546 assert_eq!(quote_ident("user@example.com"), "\"user@example.com\"");
547 }
548
549 #[test]
550 fn quote_ident_with_embedded_quotes() {
551 assert_eq!(quote_ident("has\"quote"), "\"has\"\"quote\"");
552 }
553
554 #[test]
555 fn quote_literal_simple() {
556 assert_eq!(quote_literal("hello"), "'hello'");
557 }
558
559 #[test]
560 fn quote_literal_with_embedded_quotes() {
561 assert_eq!(quote_literal("it's"), "'it''s'");
562 }
563
564 #[test]
565 fn render_create_role_basic() {
566 let change = Change::CreateRole {
567 name: "inventory-editor".to_string(),
568 state: RoleState::default(),
569 };
570 let sql = render(&change);
571 assert!(sql.starts_with("CREATE ROLE \"inventory-editor\""));
572 assert!(sql.contains("NOLOGIN"));
573 assert!(sql.contains("NOSUPERUSER"));
574 assert!(sql.contains("INHERIT")); assert!(sql.ends_with(';'));
576 }
577
578 #[test]
579 fn render_create_role_with_login_and_comment() {
580 let change = Change::CreateRole {
581 name: "analytics".to_string(),
582 state: RoleState {
583 login: true,
584 comment: Some("Analytics readonly role".to_string()),
585 ..RoleState::default()
586 },
587 };
588 let sql = render(&change);
589 assert!(sql.contains("LOGIN"));
590 assert!(sql.contains("COMMENT ON ROLE \"analytics\" IS 'Analytics readonly role';"));
591 }
592
593 #[test]
594 fn render_alter_role() {
595 let change = Change::AlterRole {
596 name: "r1".to_string(),
597 attributes: vec![RoleAttribute::Login(true), RoleAttribute::Createdb(true)],
598 };
599 let sql = render(&change);
600 assert_eq!(sql, "ALTER ROLE \"r1\" LOGIN CREATEDB;");
601 }
602
603 #[test]
604 fn render_drop_role() {
605 let change = Change::DropRole {
606 name: "old-role".to_string(),
607 };
608 assert_eq!(render(&change), "DROP ROLE \"old-role\";");
609 }
610
611 #[test]
612 fn render_grant_schema_usage() {
613 let change = Change::Grant {
614 role: "inventory-editor".to_string(),
615 privileges: BTreeSet::from([Privilege::Usage]),
616 object_type: ObjectType::Schema,
617 schema: None,
618 name: Some("inventory".to_string()),
619 };
620 let sql = render(&change);
621 assert_eq!(
622 sql,
623 "GRANT USAGE ON SCHEMA \"inventory\" TO \"inventory-editor\";"
624 );
625 }
626
627 #[test]
628 fn render_grant_all_tables() {
629 let change = Change::Grant {
630 role: "inventory-editor".to_string(),
631 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
632 object_type: ObjectType::Table,
633 schema: Some("inventory".to_string()),
634 name: Some("*".to_string()),
635 };
636 let sql = render(&change);
637 assert_eq!(
638 sql,
639 "GRANT INSERT, SELECT ON ALL TABLES IN SCHEMA \"inventory\" TO \"inventory-editor\";"
640 );
641 }
642
643 #[test]
644 fn render_grant_specific_table() {
645 let change = Change::Grant {
646 role: "r1".to_string(),
647 privileges: BTreeSet::from([Privilege::Select]),
648 object_type: ObjectType::Table,
649 schema: Some("public".to_string()),
650 name: Some("users".to_string()),
651 };
652 let sql = render(&change);
653 assert_eq!(sql, "GRANT SELECT ON TABLE \"public\".\"users\" TO \"r1\";");
654 }
655
656 #[test]
657 fn render_grant_specific_function() {
658 let change = Change::Grant {
659 role: "r1".to_string(),
660 privileges: BTreeSet::from([Privilege::Execute]),
661 object_type: ObjectType::Function,
662 schema: Some("public".to_string()),
663 name: Some("refresh_users(integer, text)".to_string()),
664 };
665 let sql = render(&change);
666 assert_eq!(
667 sql,
668 "GRANT EXECUTE ON FUNCTION \"public\".\"refresh_users\"(integer, text) TO \"r1\";"
669 );
670 }
671
672 #[test]
673 fn render_revoke_all_sequences() {
674 let change = Change::Revoke {
675 role: "inventory-editor".to_string(),
676 privileges: BTreeSet::from([Privilege::Usage, Privilege::Select]),
677 object_type: ObjectType::Sequence,
678 schema: Some("inventory".to_string()),
679 name: Some("*".to_string()),
680 };
681 let sql = render(&change);
682 assert_eq!(
683 sql,
684 "REVOKE SELECT, USAGE ON ALL SEQUENCES IN SCHEMA \"inventory\" FROM \"inventory-editor\";"
685 );
686 }
687
688 #[test]
689 fn render_set_default_privilege() {
690 let change = Change::SetDefaultPrivilege {
691 owner: "app_owner".to_string(),
692 schema: "inventory".to_string(),
693 on_type: ObjectType::Table,
694 grantee: "inventory-editor".to_string(),
695 privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
696 };
697 let sql = render(&change);
698 assert_eq!(
699 sql,
700 "ALTER DEFAULT PRIVILEGES FOR ROLE \"app_owner\" IN SCHEMA \"inventory\" GRANT INSERT, SELECT ON TABLES TO \"inventory-editor\";"
701 );
702 }
703
704 #[test]
705 fn render_revoke_default_privilege() {
706 let change = Change::RevokeDefaultPrivilege {
707 owner: "app_owner".to_string(),
708 schema: "inventory".to_string(),
709 on_type: ObjectType::Function,
710 grantee: "inventory-editor".to_string(),
711 privileges: BTreeSet::from([Privilege::Execute]),
712 };
713 let sql = render(&change);
714 assert_eq!(
715 sql,
716 "ALTER DEFAULT PRIVILEGES FOR ROLE \"app_owner\" IN SCHEMA \"inventory\" REVOKE EXECUTE ON FUNCTIONS FROM \"inventory-editor\";"
717 );
718 }
719
720 #[test]
721 fn render_add_member_basic() {
722 let change = Change::AddMember {
723 role: "inventory-editor".to_string(),
724 member: "user@example.com".to_string(),
725 inherit: true,
726 admin: false,
727 };
728 let sql = render(&change);
729 assert_eq!(
730 sql,
731 "GRANT \"inventory-editor\" TO \"user@example.com\" WITH INHERIT TRUE;"
732 );
733 }
734
735 #[test]
736 fn render_add_member_with_admin() {
737 let change = Change::AddMember {
738 role: "inventory-editor".to_string(),
739 member: "admin@example.com".to_string(),
740 inherit: true,
741 admin: true,
742 };
743 let sql = render(&change);
744 assert_eq!(
745 sql,
746 "GRANT \"inventory-editor\" TO \"admin@example.com\" WITH INHERIT TRUE, ADMIN TRUE;"
747 );
748 }
749
750 #[test]
751 fn render_add_member_no_inherit() {
752 let change = Change::AddMember {
753 role: "inventory-editor".to_string(),
754 member: "noinherit@example.com".to_string(),
755 inherit: false,
756 admin: false,
757 };
758 let sql = render(&change);
759 assert_eq!(
760 sql,
761 "GRANT \"inventory-editor\" TO \"noinherit@example.com\" WITH INHERIT FALSE;"
762 );
763 }
764
765 #[test]
766 fn render_remove_member() {
767 let change = Change::RemoveMember {
768 role: "inventory-editor".to_string(),
769 member: "user@example.com".to_string(),
770 };
771 let sql = render(&change);
772 assert_eq!(
773 sql,
774 "REVOKE \"inventory-editor\" FROM \"user@example.com\";"
775 );
776 }
777
778 #[test]
779 fn render_reassign_owned() {
780 let change = Change::ReassignOwned {
781 from_role: "legacy-owner".to_string(),
782 to_role: "app-owner".to_string(),
783 };
784 assert_eq!(
785 render(&change),
786 "REASSIGN OWNED BY \"legacy-owner\" TO \"app-owner\";"
787 );
788 }
789
790 #[test]
791 fn render_drop_owned() {
792 let change = Change::DropOwned {
793 role: "legacy-owner".to_string(),
794 };
795 assert_eq!(render(&change), "DROP OWNED BY \"legacy-owner\";");
796 }
797
798 #[test]
799 fn render_terminate_sessions() {
800 let change = Change::TerminateSessions {
801 role: "legacy-owner".to_string(),
802 };
803 assert_eq!(
804 render(&change),
805 "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = 'legacy-owner' AND pid <> pg_backend_pid();"
806 );
807 }
808
809 #[test]
810 fn render_set_comment_some() {
811 let change = Change::SetComment {
812 name: "r1".to_string(),
813 comment: Some("A test role".to_string()),
814 };
815 assert_eq!(render(&change), "COMMENT ON ROLE \"r1\" IS 'A test role';");
816 }
817
818 #[test]
819 fn render_set_comment_none() {
820 let change = Change::SetComment {
821 name: "r1".to_string(),
822 comment: None,
823 };
824 assert_eq!(render(&change), "COMMENT ON ROLE \"r1\" IS NULL;");
825 }
826
827 #[test]
832 fn render_add_member_pg15_legacy_syntax() {
833 let ctx = SqlContext {
834 pg_major_version: 15,
835 };
836 let change = Change::AddMember {
837 role: "editors".to_string(),
838 member: "user@example.com".to_string(),
839 inherit: true,
840 admin: false,
841 };
842 let sql = render_statements_with_context(&change, &ctx).join("\n");
843 assert_eq!(sql, "GRANT \"editors\" TO \"user@example.com\";");
844 }
845
846 #[test]
847 fn render_add_member_pg15_with_admin() {
848 let ctx = SqlContext {
849 pg_major_version: 15,
850 };
851 let change = Change::AddMember {
852 role: "editors".to_string(),
853 member: "admin@example.com".to_string(),
854 inherit: true,
855 admin: true,
856 };
857 let sql = render_statements_with_context(&change, &ctx).join("\n");
858 assert_eq!(
859 sql,
860 "GRANT \"editors\" TO \"admin@example.com\" WITH ADMIN OPTION;"
861 );
862 }
863
864 #[test]
865 fn render_add_member_pg16_with_options() {
866 let ctx = SqlContext {
867 pg_major_version: 16,
868 };
869 let change = Change::AddMember {
870 role: "editors".to_string(),
871 member: "user@example.com".to_string(),
872 inherit: false,
873 admin: true,
874 };
875 let sql = render_statements_with_context(&change, &ctx).join("\n");
876 assert_eq!(
877 sql,
878 "GRANT \"editors\" TO \"user@example.com\" WITH INHERIT FALSE, ADMIN TRUE;"
879 );
880 }
881
882 #[test]
887 fn change_serializes_to_json() {
888 let change = Change::CreateRole {
889 name: "test".to_string(),
890 state: RoleState::default(),
891 };
892 let json = serde_json::to_string(&change).unwrap();
893 assert!(json.contains("CreateRole"));
894 assert!(json.contains("test"));
895 }
896
897 #[test]
899 fn full_pipeline_manifest_to_sql() {
900 use crate::diff::diff;
901 use crate::manifest::{expand_manifest, parse_manifest};
902 use crate::model::RoleGraph;
903
904 let yaml = r#"
905default_owner: app_owner
906
907profiles:
908 editor:
909 grants:
910 - privileges: [USAGE]
911 on: { type: schema }
912 - privileges: [SELECT, INSERT, UPDATE, DELETE]
913 on: { type: table, name: "*" }
914 default_privileges:
915 - privileges: [SELECT, INSERT, UPDATE, DELETE]
916 on_type: table
917
918schemas:
919 - name: inventory
920 profiles: [editor]
921
922memberships:
923 - role: inventory-editor
924 members:
925 - name: "user@example.com"
926"#;
927 let manifest = parse_manifest(yaml).unwrap();
928 let expanded = expand_manifest(&manifest).unwrap();
929 let desired =
930 RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
931 let current = RoleGraph::default();
932
933 let changes = diff(¤t, &desired);
934 let sql = render_all(&changes);
935
936 assert!(sql.contains("CREATE ROLE \"inventory-editor\""));
938 assert!(sql.contains("GRANT USAGE ON SCHEMA \"inventory\" TO \"inventory-editor\""));
939 assert!(sql.contains("ALL TABLES IN SCHEMA \"inventory\""));
940 assert!(sql.contains("ALTER DEFAULT PRIVILEGES"));
941 assert!(sql.contains("GRANT \"inventory-editor\" TO \"user@example.com\""));
942
943 #[cfg(test)]
945 {
946 eprintln!("--- Generated SQL ---\n{sql}\n--- End ---");
947 }
948 }
949}