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