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