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