Skip to main content

pgroles_core/
sql.rs

1//! SQL generation from [`Change`] operations.
2//!
3//! Each [`Change`] variant is rendered into one or more PostgreSQL DDL
4//! statements. All identifiers are double-quoted to handle names containing
5//! hyphens, dots, `@` signs, etc.
6
7use std::collections::BTreeSet;
8use std::fmt::Write;
9
10use crate::diff::Change;
11use crate::manifest::{ObjectType, Privilege};
12use crate::model::{RoleAttribute, RoleState};
13
14// ---------------------------------------------------------------------------
15// Identifier quoting
16// ---------------------------------------------------------------------------
17
18/// Double-quote a PostgreSQL identifier, escaping any embedded double quotes.
19///
20/// ```
21/// use pgroles_core::sql::quote_ident;
22/// assert_eq!(quote_ident("simple"), r#""simple""#);
23/// assert_eq!(quote_ident("has\"quote"), r#""has""quote""#);
24/// assert_eq!(quote_ident("user@example.com"), r#""user@example.com""#);
25/// ```
26pub fn quote_ident(identifier: &str) -> String {
27    format!("\"{}\"", identifier.replace('"', "\"\""))
28}
29
30// ---------------------------------------------------------------------------
31// SQL rendering
32// ---------------------------------------------------------------------------
33
34/// Render a single [`Change`] into a SQL statement (including trailing `;`).
35pub fn render(change: &Change) -> String {
36    render_statements(change).join("\n")
37}
38
39/// Render a single [`Change`] into one or more SQL statements.
40pub fn render_statements(change: &Change) -> Vec<String> {
41    match change {
42        Change::CreateRole { name, state } => render_create_role(name, state),
43        Change::AlterRole { name, attributes } => render_alter_role(name, attributes),
44        Change::SetComment { name, comment } => render_set_comment(name, comment),
45        Change::Grant {
46            role,
47            privileges,
48            object_type,
49            schema,
50            name,
51        } => render_grant(
52            role,
53            privileges,
54            *object_type,
55            schema.as_deref(),
56            name.as_deref(),
57        ),
58        Change::Revoke {
59            role,
60            privileges,
61            object_type,
62            schema,
63            name,
64        } => render_revoke(
65            role,
66            privileges,
67            *object_type,
68            schema.as_deref(),
69            name.as_deref(),
70        ),
71        Change::SetDefaultPrivilege {
72            owner,
73            schema,
74            on_type,
75            grantee,
76            privileges,
77        } => render_set_default_privilege(owner, schema, *on_type, grantee, privileges),
78        Change::RevokeDefaultPrivilege {
79            owner,
80            schema,
81            on_type,
82            grantee,
83            privileges,
84        } => render_revoke_default_privilege(owner, schema, *on_type, grantee, privileges),
85        Change::AddMember {
86            role,
87            member,
88            inherit,
89            admin,
90        } => render_add_member(role, member, *inherit, *admin),
91        Change::RemoveMember { role, member } => render_remove_member(role, member),
92        Change::ReassignOwned { from_role, to_role } => render_reassign_owned(from_role, to_role),
93        Change::DropOwned { role } => render_drop_owned(role),
94        Change::DropRole { name } => vec![format!("DROP ROLE {};", quote_ident(name))],
95    }
96}
97
98/// Render all changes into a single SQL script.
99pub fn render_all(changes: &[Change]) -> String {
100    changes
101        .iter()
102        .flat_map(render_statements)
103        .collect::<Vec<_>>()
104        .join("\n")
105}
106
107// ---------------------------------------------------------------------------
108// CREATE ROLE
109// ---------------------------------------------------------------------------
110
111fn render_create_role(name: &str, state: &RoleState) -> Vec<String> {
112    let mut sql = format!("CREATE ROLE {}", quote_ident(name));
113    let mut options = vec![
114        bool_option("LOGIN", "NOLOGIN", state.login),
115        bool_option("SUPERUSER", "NOSUPERUSER", state.superuser),
116        bool_option("CREATEDB", "NOCREATEDB", state.createdb),
117        bool_option("CREATEROLE", "NOCREATEROLE", state.createrole),
118        bool_option("INHERIT", "NOINHERIT", state.inherit),
119        bool_option("REPLICATION", "NOREPLICATION", state.replication),
120        bool_option("BYPASSRLS", "NOBYPASSRLS", state.bypassrls),
121    ];
122
123    if state.connection_limit != -1 {
124        options.push(format!("CONNECTION LIMIT {}", state.connection_limit));
125    }
126
127    let _ = write!(sql, " {}", options.join(" "));
128    sql.push(';');
129
130    let mut statements = vec![sql];
131    if let Some(comment) = &state.comment {
132        statements.push(format!(
133            "COMMENT ON ROLE {} IS {};",
134            quote_ident(name),
135            quote_literal(comment)
136        ));
137    }
138
139    statements
140}
141
142fn bool_option(positive: &str, negative: &str, value: bool) -> String {
143    if value {
144        positive.to_string()
145    } else {
146        negative.to_string()
147    }
148}
149
150// ---------------------------------------------------------------------------
151// ALTER ROLE
152// ---------------------------------------------------------------------------
153
154fn render_alter_role(name: &str, attributes: &[RoleAttribute]) -> Vec<String> {
155    let mut options = Vec::new();
156    for attr in attributes {
157        match attr {
158            RoleAttribute::Login(v) => options.push(bool_option("LOGIN", "NOLOGIN", *v)),
159            RoleAttribute::Superuser(v) => {
160                options.push(bool_option("SUPERUSER", "NOSUPERUSER", *v));
161            }
162            RoleAttribute::Createdb(v) => {
163                options.push(bool_option("CREATEDB", "NOCREATEDB", *v));
164            }
165            RoleAttribute::Createrole(v) => {
166                options.push(bool_option("CREATEROLE", "NOCREATEROLE", *v));
167            }
168            RoleAttribute::Inherit(v) => options.push(bool_option("INHERIT", "NOINHERIT", *v)),
169            RoleAttribute::Replication(v) => {
170                options.push(bool_option("REPLICATION", "NOREPLICATION", *v));
171            }
172            RoleAttribute::Bypassrls(v) => {
173                options.push(bool_option("BYPASSRLS", "NOBYPASSRLS", *v));
174            }
175            RoleAttribute::ConnectionLimit(v) => {
176                options.push(format!("CONNECTION LIMIT {v}"));
177            }
178        }
179    }
180    vec![format!(
181        "ALTER ROLE {} {};",
182        quote_ident(name),
183        options.join(" ")
184    )]
185}
186
187// ---------------------------------------------------------------------------
188// COMMENT ON ROLE
189// ---------------------------------------------------------------------------
190
191fn render_set_comment(name: &str, comment: &Option<String>) -> Vec<String> {
192    vec![match comment {
193        Some(text) => format!(
194            "COMMENT ON ROLE {} IS {};",
195            quote_ident(name),
196            quote_literal(text)
197        ),
198        None => format!("COMMENT ON ROLE {} IS NULL;", quote_ident(name)),
199    }]
200}
201
202// ---------------------------------------------------------------------------
203// GRANT / REVOKE
204// ---------------------------------------------------------------------------
205
206fn render_grant(
207    role: &str,
208    privileges: &BTreeSet<Privilege>,
209    object_type: ObjectType,
210    schema: Option<&str>,
211    name: Option<&str>,
212) -> Vec<String> {
213    let privilege_list = format_privileges(privileges);
214    let target = format_object_target(object_type, schema, name);
215    vec![format!(
216        "GRANT {} ON {} TO {};",
217        privilege_list,
218        target,
219        quote_ident(role)
220    )]
221}
222
223fn render_revoke(
224    role: &str,
225    privileges: &BTreeSet<Privilege>,
226    object_type: ObjectType,
227    schema: Option<&str>,
228    name: Option<&str>,
229) -> Vec<String> {
230    let privilege_list = format_privileges(privileges);
231    let target = format_object_target(object_type, schema, name);
232    vec![format!(
233        "REVOKE {} ON {} FROM {};",
234        privilege_list,
235        target,
236        quote_ident(role)
237    )]
238}
239
240/// Format the object target for GRANT/REVOKE statements.
241///
242/// - Schema-level: `SCHEMA "myschema"` — object_type=Schema, name=Some("myschema")
243/// - Wildcard: `ALL TABLES IN SCHEMA "myschema"` — name=Some("*")
244/// - Specific: `TABLE "myschema"."mytable"` — name=Some("mytable")
245/// - Database: `DATABASE "mydb"` — object_type=Database, name=Some("mydb")
246fn format_object_target(
247    object_type: ObjectType,
248    schema: Option<&str>,
249    name: Option<&str>,
250) -> String {
251    let type_keyword = sql_object_type_keyword(object_type);
252
253    match object_type {
254        ObjectType::Schema => {
255            // Schema grants: name is the schema name itself
256            let schema_name = name.unwrap_or("public");
257            format!("{type_keyword} {}", quote_ident(schema_name))
258        }
259        ObjectType::Database => {
260            let db_name = name.unwrap_or("postgres");
261            format!("{type_keyword} {}", quote_ident(db_name))
262        }
263        ObjectType::Function => match name {
264            Some("*") => {
265                let schema_name = schema.unwrap_or("public");
266                format!("ALL FUNCTIONS IN SCHEMA {}", quote_ident(schema_name))
267            }
268            Some(function_name) => format_function_target(schema, function_name),
269            None => {
270                let schema_name = schema.unwrap_or("public");
271                format!("{type_keyword} {}", quote_ident(schema_name))
272            }
273        },
274        _ => {
275            match name {
276                Some("*") => {
277                    // Wildcard: ALL TABLES IN SCHEMA "schema"
278                    let plural = sql_object_type_plural(object_type);
279                    let schema_name = schema.unwrap_or("public");
280                    format!("ALL {plural} IN SCHEMA {}", quote_ident(schema_name))
281                }
282                Some(obj_name) => {
283                    // Specific object: TABLE "schema"."table"
284                    let schema_name = schema.unwrap_or("public");
285                    format!(
286                        "{type_keyword} {}.{}",
287                        quote_ident(schema_name),
288                        quote_ident(obj_name)
289                    )
290                }
291                None => {
292                    // Shouldn't happen for non-schema/database types, but handle gracefully
293                    let schema_name = schema.unwrap_or("public");
294                    format!("{type_keyword} {}", quote_ident(schema_name))
295                }
296            }
297        }
298    }
299}
300
301fn format_function_target(schema: Option<&str>, function_name: &str) -> String {
302    let schema_name = schema.unwrap_or("public");
303
304    match function_name.rfind('(') {
305        Some(paren_idx) if function_name.ends_with(')') => {
306            let base_name = &function_name[..paren_idx];
307            let args = &function_name[paren_idx..];
308            format!(
309                "FUNCTION {}.{}{}",
310                quote_ident(schema_name),
311                quote_ident(base_name),
312                args
313            )
314        }
315        _ => format!(
316            "FUNCTION {}.{}",
317            quote_ident(schema_name),
318            quote_ident(function_name)
319        ),
320    }
321}
322
323/// Map ObjectType to the SQL keyword used in GRANT/REVOKE.
324fn sql_object_type_keyword(object_type: ObjectType) -> &'static str {
325    match object_type {
326        ObjectType::Table => "TABLE",
327        ObjectType::View => "TABLE", // PostgreSQL treats views as tables for GRANT
328        ObjectType::MaterializedView => "TABLE", // Same
329        ObjectType::Sequence => "SEQUENCE",
330        ObjectType::Function => "FUNCTION",
331        ObjectType::Schema => "SCHEMA",
332        ObjectType::Database => "DATABASE",
333        ObjectType::Type => "TYPE",
334    }
335}
336
337/// Map ObjectType to the SQL plural keyword used in ALL ... IN SCHEMA.
338fn sql_object_type_plural(object_type: ObjectType) -> &'static str {
339    match object_type {
340        ObjectType::Table | ObjectType::View | ObjectType::MaterializedView => "TABLES",
341        ObjectType::Sequence => "SEQUENCES",
342        ObjectType::Function => "FUNCTIONS",
343        // PostgreSQL has no ALL TYPES IN SCHEMA syntax. Type grants should use
344        // specific object names, not wildcards. If we get here the manifest is
345        // likely misconfigured, but produce the closest valid SQL anyway.
346        ObjectType::Type => "TABLES",
347        // Schema/Database don't use ALL ... IN SCHEMA syntax
348        ObjectType::Schema | ObjectType::Database => "TABLES",
349    }
350}
351
352/// Format a privilege set as a comma-separated string.
353fn format_privileges(privileges: &BTreeSet<Privilege>) -> String {
354    privileges
355        .iter()
356        .map(|p| p.to_string())
357        .collect::<Vec<_>>()
358        .join(", ")
359}
360
361// ---------------------------------------------------------------------------
362// ALTER DEFAULT PRIVILEGES
363// ---------------------------------------------------------------------------
364
365fn render_set_default_privilege(
366    owner: &str,
367    schema: &str,
368    on_type: ObjectType,
369    grantee: &str,
370    privileges: &BTreeSet<Privilege>,
371) -> Vec<String> {
372    let privilege_list = format_privileges(privileges);
373    let type_plural = sql_object_type_plural(on_type);
374    vec![format!(
375        "ALTER DEFAULT PRIVILEGES FOR ROLE {} IN SCHEMA {} GRANT {} ON {} TO {};",
376        quote_ident(owner),
377        quote_ident(schema),
378        privilege_list,
379        type_plural,
380        quote_ident(grantee)
381    )]
382}
383
384fn render_revoke_default_privilege(
385    owner: &str,
386    schema: &str,
387    on_type: ObjectType,
388    grantee: &str,
389    privileges: &BTreeSet<Privilege>,
390) -> Vec<String> {
391    let privilege_list = format_privileges(privileges);
392    let type_plural = sql_object_type_plural(on_type);
393    vec![format!(
394        "ALTER DEFAULT PRIVILEGES FOR ROLE {} IN SCHEMA {} REVOKE {} ON {} FROM {};",
395        quote_ident(owner),
396        quote_ident(schema),
397        privilege_list,
398        type_plural,
399        quote_ident(grantee)
400    )]
401}
402
403// ---------------------------------------------------------------------------
404// Membership
405// ---------------------------------------------------------------------------
406
407fn render_add_member(role: &str, member: &str, inherit: bool, admin: bool) -> Vec<String> {
408    let mut sql = format!("GRANT {} TO {}", quote_ident(role), quote_ident(member));
409
410    // PostgreSQL 16+ supports WITH INHERIT / WITH ADMIN in GRANT ... TO
411    let mut options = Vec::new();
412    if inherit {
413        options.push("INHERIT TRUE");
414    } else {
415        options.push("INHERIT FALSE");
416    }
417    if admin {
418        options.push("ADMIN TRUE");
419    }
420
421    if !options.is_empty() {
422        let _ = write!(sql, " WITH {}", options.join(", "));
423    }
424
425    sql.push(';');
426    vec![sql]
427}
428
429fn render_remove_member(role: &str, member: &str) -> Vec<String> {
430    vec![format!(
431        "REVOKE {} FROM {};",
432        quote_ident(role),
433        quote_ident(member)
434    )]
435}
436
437fn render_reassign_owned(from_role: &str, to_role: &str) -> Vec<String> {
438    vec![format!(
439        "REASSIGN OWNED BY {} TO {};",
440        quote_ident(from_role),
441        quote_ident(to_role)
442    )]
443}
444
445fn render_drop_owned(role: &str) -> Vec<String> {
446    vec![format!("DROP OWNED BY {};", quote_ident(role))]
447}
448
449// ---------------------------------------------------------------------------
450// String quoting
451// ---------------------------------------------------------------------------
452
453/// Single-quote a SQL string literal, escaping single quotes.
454fn quote_literal(value: &str) -> String {
455    format!("'{}'", value.replace('\'', "''"))
456}
457
458// ---------------------------------------------------------------------------
459// Tests
460// ---------------------------------------------------------------------------
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    #[test]
467    fn quote_ident_simple() {
468        assert_eq!(quote_ident("simple"), "\"simple\"");
469    }
470
471    #[test]
472    fn quote_ident_with_hyphen() {
473        assert_eq!(quote_ident("inventory-editor"), "\"inventory-editor\"");
474    }
475
476    #[test]
477    fn quote_ident_with_email() {
478        assert_eq!(quote_ident("user@example.com"), "\"user@example.com\"");
479    }
480
481    #[test]
482    fn quote_ident_with_embedded_quotes() {
483        assert_eq!(quote_ident("has\"quote"), "\"has\"\"quote\"");
484    }
485
486    #[test]
487    fn quote_literal_simple() {
488        assert_eq!(quote_literal("hello"), "'hello'");
489    }
490
491    #[test]
492    fn quote_literal_with_embedded_quotes() {
493        assert_eq!(quote_literal("it's"), "'it''s'");
494    }
495
496    #[test]
497    fn render_create_role_basic() {
498        let change = Change::CreateRole {
499            name: "inventory-editor".to_string(),
500            state: RoleState::default(),
501        };
502        let sql = render(&change);
503        assert!(sql.starts_with("CREATE ROLE \"inventory-editor\""));
504        assert!(sql.contains("NOLOGIN"));
505        assert!(sql.contains("NOSUPERUSER"));
506        assert!(sql.contains("INHERIT")); // default is INHERIT
507        assert!(sql.ends_with(';'));
508    }
509
510    #[test]
511    fn render_create_role_with_login_and_comment() {
512        let change = Change::CreateRole {
513            name: "analytics".to_string(),
514            state: RoleState {
515                login: true,
516                comment: Some("Analytics readonly role".to_string()),
517                ..RoleState::default()
518            },
519        };
520        let sql = render(&change);
521        assert!(sql.contains("LOGIN"));
522        assert!(sql.contains("COMMENT ON ROLE \"analytics\" IS 'Analytics readonly role';"));
523    }
524
525    #[test]
526    fn render_alter_role() {
527        let change = Change::AlterRole {
528            name: "r1".to_string(),
529            attributes: vec![RoleAttribute::Login(true), RoleAttribute::Createdb(true)],
530        };
531        let sql = render(&change);
532        assert_eq!(sql, "ALTER ROLE \"r1\" LOGIN CREATEDB;");
533    }
534
535    #[test]
536    fn render_drop_role() {
537        let change = Change::DropRole {
538            name: "old-role".to_string(),
539        };
540        assert_eq!(render(&change), "DROP ROLE \"old-role\";");
541    }
542
543    #[test]
544    fn render_grant_schema_usage() {
545        let change = Change::Grant {
546            role: "inventory-editor".to_string(),
547            privileges: BTreeSet::from([Privilege::Usage]),
548            object_type: ObjectType::Schema,
549            schema: None,
550            name: Some("inventory".to_string()),
551        };
552        let sql = render(&change);
553        assert_eq!(
554            sql,
555            "GRANT USAGE ON SCHEMA \"inventory\" TO \"inventory-editor\";"
556        );
557    }
558
559    #[test]
560    fn render_grant_all_tables() {
561        let change = Change::Grant {
562            role: "inventory-editor".to_string(),
563            privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
564            object_type: ObjectType::Table,
565            schema: Some("inventory".to_string()),
566            name: Some("*".to_string()),
567        };
568        let sql = render(&change);
569        assert_eq!(
570            sql,
571            "GRANT INSERT, SELECT ON ALL TABLES IN SCHEMA \"inventory\" TO \"inventory-editor\";"
572        );
573    }
574
575    #[test]
576    fn render_grant_specific_table() {
577        let change = Change::Grant {
578            role: "r1".to_string(),
579            privileges: BTreeSet::from([Privilege::Select]),
580            object_type: ObjectType::Table,
581            schema: Some("public".to_string()),
582            name: Some("users".to_string()),
583        };
584        let sql = render(&change);
585        assert_eq!(sql, "GRANT SELECT ON TABLE \"public\".\"users\" TO \"r1\";");
586    }
587
588    #[test]
589    fn render_grant_specific_function() {
590        let change = Change::Grant {
591            role: "r1".to_string(),
592            privileges: BTreeSet::from([Privilege::Execute]),
593            object_type: ObjectType::Function,
594            schema: Some("public".to_string()),
595            name: Some("refresh_users(integer, text)".to_string()),
596        };
597        let sql = render(&change);
598        assert_eq!(
599            sql,
600            "GRANT EXECUTE ON FUNCTION \"public\".\"refresh_users\"(integer, text) TO \"r1\";"
601        );
602    }
603
604    #[test]
605    fn render_revoke_all_sequences() {
606        let change = Change::Revoke {
607            role: "inventory-editor".to_string(),
608            privileges: BTreeSet::from([Privilege::Usage, Privilege::Select]),
609            object_type: ObjectType::Sequence,
610            schema: Some("inventory".to_string()),
611            name: Some("*".to_string()),
612        };
613        let sql = render(&change);
614        assert_eq!(
615            sql,
616            "REVOKE SELECT, USAGE ON ALL SEQUENCES IN SCHEMA \"inventory\" FROM \"inventory-editor\";"
617        );
618    }
619
620    #[test]
621    fn render_set_default_privilege() {
622        let change = Change::SetDefaultPrivilege {
623            owner: "app_owner".to_string(),
624            schema: "inventory".to_string(),
625            on_type: ObjectType::Table,
626            grantee: "inventory-editor".to_string(),
627            privileges: BTreeSet::from([Privilege::Select, Privilege::Insert]),
628        };
629        let sql = render(&change);
630        assert_eq!(
631            sql,
632            "ALTER DEFAULT PRIVILEGES FOR ROLE \"app_owner\" IN SCHEMA \"inventory\" GRANT INSERT, SELECT ON TABLES TO \"inventory-editor\";"
633        );
634    }
635
636    #[test]
637    fn render_revoke_default_privilege() {
638        let change = Change::RevokeDefaultPrivilege {
639            owner: "app_owner".to_string(),
640            schema: "inventory".to_string(),
641            on_type: ObjectType::Function,
642            grantee: "inventory-editor".to_string(),
643            privileges: BTreeSet::from([Privilege::Execute]),
644        };
645        let sql = render(&change);
646        assert_eq!(
647            sql,
648            "ALTER DEFAULT PRIVILEGES FOR ROLE \"app_owner\" IN SCHEMA \"inventory\" REVOKE EXECUTE ON FUNCTIONS FROM \"inventory-editor\";"
649        );
650    }
651
652    #[test]
653    fn render_add_member_basic() {
654        let change = Change::AddMember {
655            role: "inventory-editor".to_string(),
656            member: "user@example.com".to_string(),
657            inherit: true,
658            admin: false,
659        };
660        let sql = render(&change);
661        assert_eq!(
662            sql,
663            "GRANT \"inventory-editor\" TO \"user@example.com\" WITH INHERIT TRUE;"
664        );
665    }
666
667    #[test]
668    fn render_add_member_with_admin() {
669        let change = Change::AddMember {
670            role: "inventory-editor".to_string(),
671            member: "admin@example.com".to_string(),
672            inherit: true,
673            admin: true,
674        };
675        let sql = render(&change);
676        assert_eq!(
677            sql,
678            "GRANT \"inventory-editor\" TO \"admin@example.com\" WITH INHERIT TRUE, ADMIN TRUE;"
679        );
680    }
681
682    #[test]
683    fn render_add_member_no_inherit() {
684        let change = Change::AddMember {
685            role: "inventory-editor".to_string(),
686            member: "noinherit@example.com".to_string(),
687            inherit: false,
688            admin: false,
689        };
690        let sql = render(&change);
691        assert_eq!(
692            sql,
693            "GRANT \"inventory-editor\" TO \"noinherit@example.com\" WITH INHERIT FALSE;"
694        );
695    }
696
697    #[test]
698    fn render_remove_member() {
699        let change = Change::RemoveMember {
700            role: "inventory-editor".to_string(),
701            member: "user@example.com".to_string(),
702        };
703        let sql = render(&change);
704        assert_eq!(
705            sql,
706            "REVOKE \"inventory-editor\" FROM \"user@example.com\";"
707        );
708    }
709
710    #[test]
711    fn render_reassign_owned() {
712        let change = Change::ReassignOwned {
713            from_role: "legacy-owner".to_string(),
714            to_role: "app-owner".to_string(),
715        };
716        assert_eq!(
717            render(&change),
718            "REASSIGN OWNED BY \"legacy-owner\" TO \"app-owner\";"
719        );
720    }
721
722    #[test]
723    fn render_drop_owned() {
724        let change = Change::DropOwned {
725            role: "legacy-owner".to_string(),
726        };
727        assert_eq!(render(&change), "DROP OWNED BY \"legacy-owner\";");
728    }
729
730    #[test]
731    fn render_set_comment_some() {
732        let change = Change::SetComment {
733            name: "r1".to_string(),
734            comment: Some("A test role".to_string()),
735        };
736        assert_eq!(render(&change), "COMMENT ON ROLE \"r1\" IS 'A test role';");
737    }
738
739    #[test]
740    fn render_set_comment_none() {
741        let change = Change::SetComment {
742            name: "r1".to_string(),
743            comment: None,
744        };
745        assert_eq!(render(&change), "COMMENT ON ROLE \"r1\" IS NULL;");
746    }
747
748    /// Full integration: manifest → expand → model → diff → SQL
749    #[test]
750    fn full_pipeline_manifest_to_sql() {
751        use crate::diff::diff;
752        use crate::manifest::{expand_manifest, parse_manifest};
753        use crate::model::RoleGraph;
754
755        let yaml = r#"
756default_owner: app_owner
757
758profiles:
759  editor:
760    grants:
761      - privileges: [USAGE]
762        on: { type: schema }
763      - privileges: [SELECT, INSERT, UPDATE, DELETE]
764        on: { type: table, name: "*" }
765    default_privileges:
766      - privileges: [SELECT, INSERT, UPDATE, DELETE]
767        on_type: table
768
769schemas:
770  - name: inventory
771    profiles: [editor]
772
773memberships:
774  - role: inventory-editor
775    members:
776      - name: "user@example.com"
777"#;
778        let manifest = parse_manifest(yaml).unwrap();
779        let expanded = expand_manifest(&manifest).unwrap();
780        let desired =
781            RoleGraph::from_expanded(&expanded, manifest.default_owner.as_deref()).unwrap();
782        let current = RoleGraph::default();
783
784        let changes = diff(&current, &desired);
785        let sql = render_all(&changes);
786
787        // Smoke test: the output should contain key SQL statements
788        assert!(sql.contains("CREATE ROLE \"inventory-editor\""));
789        assert!(sql.contains("GRANT USAGE ON SCHEMA \"inventory\" TO \"inventory-editor\""));
790        assert!(sql.contains("ALL TABLES IN SCHEMA \"inventory\""));
791        assert!(sql.contains("ALTER DEFAULT PRIVILEGES"));
792        assert!(sql.contains("GRANT \"inventory-editor\" TO \"user@example.com\""));
793
794        // Print for manual inspection during development
795        #[cfg(test)]
796        {
797            eprintln!("--- Generated SQL ---\n{sql}\n--- End ---");
798        }
799    }
800}