Skip to main content

pgroles_core/
sql.rs

1//! SQL generation from [`Change`] operations.
2//!
3//! Each [`Change`] variant is rendered into one or more PostgreSQL DDL
4//! statements. All identifiers are double-quoted to handle names containing
5//! hyphens, dots, `@` signs, etc.
6
7use std::collections::BTreeSet;
8use std::fmt::Write;
9
10use crate::diff::Change;
11use crate::manifest::{ObjectType, Privilege};
12use crate::model::{RoleAttribute, RoleState};
13
14// ---------------------------------------------------------------------------
15// Identifier quoting
16// ---------------------------------------------------------------------------
17
18/// Double-quote a PostgreSQL identifier, escaping any embedded double quotes.
19///
20/// ```
21/// use pgroles_core::sql::quote_ident;
22/// assert_eq!(quote_ident("simple"), r#""simple""#);
23/// assert_eq!(quote_ident("has\"quote"), r#""has""quote""#);
24/// assert_eq!(quote_ident("user@example.com"), r#""user@example.com""#);
25/// ```
26pub fn quote_ident(identifier: &str) -> String {
27    format!("\"{}\"", identifier.replace('"', "\"\""))
28}
29
30// ---------------------------------------------------------------------------
31// SQL context for version-dependent rendering
32// ---------------------------------------------------------------------------
33
34/// Context controlling version-dependent SQL generation.
35#[derive(Debug, Clone, Copy)]
36pub struct SqlContext {
37    /// PostgreSQL major version (e.g., 14, 15, 16, 17).
38    /// Controls syntax differences like `WITH INHERIT` in membership grants.
39    pub pg_major_version: i32,
40}
41
42impl SqlContext {
43    /// Create a context for a specific PG version number (from `server_version_num`).
44    pub fn from_version_num(version_num: i32) -> Self {
45        Self {
46            pg_major_version: version_num / 10000,
47        }
48    }
49
50    /// Whether PG supports `GRANT ... WITH INHERIT TRUE/FALSE` (PG 16+).
51    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, // Default to PG 16+ (current minimum)
60        }
61    }
62}
63
64// ---------------------------------------------------------------------------
65// SQL rendering
66// ---------------------------------------------------------------------------
67
68/// Render a single [`Change`] into a SQL statement (including trailing `;`).
69/// Uses default context (PG 16+).
70pub fn render(change: &Change) -> String {
71    render_statements(change).join("\n")
72}
73
74/// Render a single [`Change`] into one or more SQL statements.
75/// Uses default context (PG 16+).
76pub fn render_statements(change: &Change) -> Vec<String> {
77    render_statements_with_context(change, &SqlContext::default())
78}
79
80/// Render a single [`Change`] into one or more SQL statements,
81/// using the given [`SqlContext`] for version-dependent syntax.
82pub 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
141/// Render all changes into a single SQL script (default context, PG 16+).
142pub fn render_all(changes: &[Change]) -> String {
143    render_all_with_context(changes, &SqlContext::default())
144}
145
146/// Render all changes into a single SQL script with version context.
147pub 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
155// ---------------------------------------------------------------------------
156// CREATE ROLE
157// ---------------------------------------------------------------------------
158
159fn 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
198// ---------------------------------------------------------------------------
199// ALTER ROLE
200// ---------------------------------------------------------------------------
201
202fn 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
235// ---------------------------------------------------------------------------
236// COMMENT ON ROLE
237// ---------------------------------------------------------------------------
238
239fn 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
250// ---------------------------------------------------------------------------
251// GRANT / REVOKE
252// ---------------------------------------------------------------------------
253
254fn 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
288/// Format the object target for GRANT/REVOKE statements.
289///
290/// - Schema-level: `SCHEMA "myschema"` — object_type=Schema, name=Some("myschema")
291/// - Wildcard: `ALL TABLES IN SCHEMA "myschema"` — name=Some("*")
292/// - Specific: `TABLE "myschema"."mytable"` — name=Some("mytable")
293/// - Database: `DATABASE "mydb"` — object_type=Database, name=Some("mydb")
294fn 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            // Schema grants: name is the schema name itself
304            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                    // Wildcard: ALL TABLES IN SCHEMA "schema"
326                    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                    // Specific object: TABLE "schema"."table"
332                    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                    // Shouldn't happen for non-schema/database types, but handle gracefully
341                    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
371/// Map ObjectType to the SQL keyword used in GRANT/REVOKE.
372fn sql_object_type_keyword(object_type: ObjectType) -> &'static str {
373    match object_type {
374        ObjectType::Table => "TABLE",
375        ObjectType::View => "TABLE", // PostgreSQL treats views as tables for GRANT
376        ObjectType::MaterializedView => "TABLE", // Same
377        ObjectType::Sequence => "SEQUENCE",
378        ObjectType::Function => "FUNCTION",
379        ObjectType::Schema => "SCHEMA",
380        ObjectType::Database => "DATABASE",
381        ObjectType::Type => "TYPE",
382    }
383}
384
385/// Map ObjectType to the SQL plural keyword used in ALL ... IN SCHEMA.
386fn 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        // PostgreSQL has no ALL TYPES IN SCHEMA syntax. Type grants should use
392        // specific object names, not wildcards. If we get here the manifest is
393        // likely misconfigured, but produce the closest valid SQL anyway.
394        ObjectType::Type => "TABLES",
395        // Schema/Database don't use ALL ... IN SCHEMA syntax
396        ObjectType::Schema | ObjectType::Database => "TABLES",
397    }
398}
399
400/// Format a privilege set as a comma-separated string.
401fn format_privileges(privileges: &BTreeSet<Privilege>) -> String {
402    privileges
403        .iter()
404        .map(|p| p.to_string())
405        .collect::<Vec<_>>()
406        .join(", ")
407}
408
409// ---------------------------------------------------------------------------
410// ALTER DEFAULT PRIVILEGES
411// ---------------------------------------------------------------------------
412
413fn 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
451// ---------------------------------------------------------------------------
452// Membership
453// ---------------------------------------------------------------------------
454
455fn 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        // PostgreSQL 16+: use WITH INHERIT / ADMIN syntax.
466        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        // PostgreSQL < 16: use legacy WITH ADMIN OPTION syntax.
480        // INHERIT is controlled by the member role's attribute, not the grant.
481        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
517// ---------------------------------------------------------------------------
518// String quoting
519// ---------------------------------------------------------------------------
520
521/// Single-quote a SQL string literal, escaping single quotes.
522fn quote_literal(value: &str) -> String {
523    format!("'{}'", value.replace('\'', "''"))
524}
525
526// ---------------------------------------------------------------------------
527// Tests
528// ---------------------------------------------------------------------------
529
530#[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")); // default is INHERIT
575        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    // -----------------------------------------------------------------------
828    // PG version-dependent rendering
829    // -----------------------------------------------------------------------
830
831    #[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    // -----------------------------------------------------------------------
883    // JSON serialization of changes
884    // -----------------------------------------------------------------------
885
886    #[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    /// Full integration: manifest → expand → model → diff → SQL
898    #[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(&current, &desired);
934        let sql = render_all(&changes);
935
936        // Smoke test: the output should contain key SQL statements
937        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        // Print for manual inspection during development
944        #[cfg(test)]
945        {
946            eprintln!("--- Generated SQL ---\n{sql}\n--- End ---");
947        }
948    }
949}