Skip to main content

pgevolve_core/diff/
routines.rs

1//! Diff functions and procedures.
2//!
3//! [`diff_functions`] compares two slices of [`Function`] — one from the live
4//! catalog (`catalog`) and one from the declared source (`source`) — and
5//! populates a [`ChangeSet`] with the minimal sequence of
6//! [`FunctionChange`] variants required to
7//! converge the catalog toward the source.
8//!
9//! [`diff_procedures`] does the same for [`Procedure`] /
10//! [`ProcedureChange`].
11//!
12//! ## Identity keys
13//!
14//! Functions are keyed by `(qname, arg_types_normalized.canonical_hash)` to
15//! correctly handle overloads. Procedures are keyed by `qname` alone (PG does
16//! not overload procedures on argument types the same way functions are
17//! overloaded for resolution purposes — but the catalog reader only reads one
18//! procedure per qname in v0.2).
19
20use std::collections::{BTreeMap, BTreeSet};
21
22use crate::diff::change::{Change, FunctionChange, ProcedureChange};
23use crate::diff::changeset::ChangeSet;
24use crate::diff::destructiveness::Destructiveness;
25use crate::diff::owner_grants::{ColumnGrantMode, diff_owner_and_grants};
26use crate::diff::owner_op::{CatalogObjectRef, RoutineSignature};
27use crate::identifier::{Identifier, QualifiedName};
28use crate::ir::function::{ArgMode, Function, NormalizedArgTypes, ReturnType};
29use crate::ir::procedure::Procedure;
30
31/// Compute `Function`-level changes needed to converge `catalog` toward `source`.
32///
33/// Functions are paired by `(qname, arg_types_normalized.canonical_hash)` so
34/// that overloads with different input-argument signatures are treated as
35/// independent objects.
36pub fn diff_functions(
37    catalog: &[Function],
38    source: &[Function],
39    out: &mut ChangeSet,
40    managed_roles: &BTreeSet<Identifier>,
41) {
42    // Key: (qname, arg-type identity hash).  Use a tuple so BTreeMap ordering
43    // is deterministic across runs regardless of insertion order.
44    type Key = (QualifiedName, [u8; 32]);
45
46    let cat: BTreeMap<Key, &Function> = catalog
47        .iter()
48        .map(|f| ((f.qname.clone(), f.arg_types_normalized.canonical_hash), f))
49        .collect();
50    let src: BTreeMap<Key, &Function> = source
51        .iter()
52        .map(|f| ((f.qname.clone(), f.arg_types_normalized.canonical_hash), f))
53        .collect();
54
55    let all_keys: BTreeSet<Key> = cat.keys().chain(src.keys()).cloned().collect();
56
57    for key in all_keys {
58        match (cat.get(&key), src.get(&key)) {
59            (None, Some(s)) => out.push(
60                Change::Function(FunctionChange::Create((*s).clone())),
61                Destructiveness::Safe,
62            ),
63            (Some(c), None) => out.push(
64                Change::Function(FunctionChange::Drop {
65                    qname: c.qname.clone(),
66                    args: c.arg_types_normalized.clone(),
67                }),
68                Destructiveness::RequiresApprovalAndDataLossWarning {
69                    reason: format!("drops function {}", c.qname),
70                },
71            ),
72            (Some(c), Some(s)) => {
73                diff_same_function(c, s, out);
74                diff_function_owner_grants(c, s, out, managed_roles);
75            }
76            (None, None) => unreachable!(),
77        }
78    }
79}
80
81/// Diff owner and grants for a function pair that shares the same identity key.
82fn diff_function_owner_grants(
83    catalog: &Function,
84    source: &Function,
85    out: &mut ChangeSet,
86    managed_roles: &BTreeSet<Identifier>,
87) {
88    // Build a human-readable label for the routine (for observations).
89    let args_label = NormalizedArgTypes::from_args(&source.args)
90        .types
91        .iter()
92        .map(crate::ir::column_type::ColumnType::render_sql)
93        .collect::<Vec<_>>()
94        .join(", ");
95    let signature = format!("({args_label})");
96
97    diff_owner_and_grants(
98        &CatalogObjectRef::Function {
99            name: source.qname.clone(),
100            signature: RoutineSignature::new(signature),
101        },
102        catalog.owner.as_ref(),
103        source.owner.as_ref(),
104        &catalog.grants,
105        &source.grants,
106        managed_roles,
107        ColumnGrantMode::ObjectOnly,
108        out,
109    );
110}
111
112/// Diff two function overloads that share the same identity key.
113fn diff_same_function(catalog: &Function, source: &Function, out: &mut ChangeSet) {
114    let body_changed = catalog.body.canonical_hash() != source.body.canonical_hash();
115    let attrs_changed = catalog.return_type != source.return_type
116        || catalog.language != source.language
117        || catalog.volatility != source.volatility
118        || catalog.strict != source.strict
119        || catalog.security != source.security
120        || catalog.parallel != source.parallel
121        || catalog.leakproof != source.leakproof
122        || catalog.cost.map(f32::to_bits) != source.cost.map(f32::to_bits)
123        || catalog.rows.map(f32::to_bits) != source.rows.map(f32::to_bits)
124        || catalog.args != source.args;
125
126    if !body_changed && !attrs_changed {
127        // Only emit a SetComment if the comment changed.
128        if catalog.comment != source.comment {
129            out.push(
130                Change::Function(FunctionChange::SetComment {
131                    qname: source.qname.clone(),
132                    args: source.arg_types_normalized.clone(),
133                    comment: source.comment.clone(),
134                }),
135                Destructiveness::Safe,
136            );
137        }
138        return;
139    }
140
141    if function_can_or_replace(catalog, source) {
142        let dest = if arg_default_removed(catalog, source) {
143            Destructiveness::RequiresApproval {
144                reason: format!(
145                    "function {} removes an argument default (may break callers passing fewer args)",
146                    source.qname
147                ),
148            }
149        } else {
150            Destructiveness::Safe
151        };
152        out.push(
153            Change::Function(FunctionChange::CreateOrReplace(source.clone())),
154            dest,
155        );
156    } else {
157        out.push(
158            Change::Function(FunctionChange::ReplaceViaDropCreate {
159                catalog: catalog.clone(),
160                source: source.clone(),
161            }),
162            Destructiveness::RequiresApprovalAndDataLossWarning {
163                reason: format!(
164                    "function {} return-type or language change requires DROP+CREATE CASCADE",
165                    source.qname
166                ),
167            },
168        );
169    }
170}
171
172/// Return `true` when PG's `CREATE OR REPLACE FUNCTION` can be used to update
173/// this function in-place.
174///
175/// PG rejects `CREATE OR REPLACE FUNCTION` when:
176/// - The **language** changes (e.g., `sql` → `plpgsql`).
177/// - The **return type kind or inner type** changes (e.g., scalar ↔ setof ↔
178///   table ↔ trigger; or the underlying scalar type changes).
179/// - The **OUT / INOUT parameter count or names** change (because they are
180///   part of the effective return type).
181pub(crate) fn function_can_or_replace(catalog: &Function, source: &Function) -> bool {
182    if catalog.language != source.language {
183        return false;
184    }
185    if !return_type_compatible(&catalog.return_type, &source.return_type) {
186        return false;
187    }
188    // OUT / INOUT params form part of the implicit return type.
189    let cat_outs: Vec<_> = catalog
190        .args
191        .iter()
192        .filter(|a| matches!(a.mode, ArgMode::Out | ArgMode::InOut))
193        .map(|a| (a.name.clone(), a.ty.clone()))
194        .collect();
195    let src_outs: Vec<_> = source
196        .args
197        .iter()
198        .filter(|a| matches!(a.mode, ArgMode::Out | ArgMode::InOut))
199        .map(|a| (a.name.clone(), a.ty.clone()))
200        .collect();
201    if cat_outs != src_outs {
202        return false;
203    }
204    true
205}
206
207/// Two return types are compatible for `CREATE OR REPLACE FUNCTION` iff they
208/// are exactly equal.
209///
210/// For v0.2 this is a strict equality check: any change to the return type
211/// (kind or inner type) forces a `DROP + CREATE CASCADE` path. A future
212/// version could allow appending columns to a `RETURNS TABLE` return type
213/// (analogous to view-body compatibility) but that is out of scope here.
214fn return_type_compatible(a: &ReturnType, b: &ReturnType) -> bool {
215    a == b
216}
217
218/// Return `true` if any IN argument had a default in `catalog` that is absent
219/// in `source`. Removing a default may break callers that relied on positional
220/// omission and is therefore classified as `RequiresApproval`.
221fn arg_default_removed(catalog: &Function, source: &Function) -> bool {
222    catalog
223        .args
224        .iter()
225        .zip(source.args.iter())
226        .any(|(c, s)| c.default.is_some() && s.default.is_none())
227}
228
229/// Compute `Procedure`-level changes needed to converge `catalog` toward `source`.
230///
231/// Procedures are paired by `qname` only (v0.2 does not support procedure
232/// overloads).
233pub fn diff_procedures(
234    catalog: &[Procedure],
235    source: &[Procedure],
236    out: &mut ChangeSet,
237    managed_roles: &BTreeSet<Identifier>,
238) {
239    let cat: BTreeMap<QualifiedName, &Procedure> =
240        catalog.iter().map(|p| (p.qname.clone(), p)).collect();
241    let src: BTreeMap<QualifiedName, &Procedure> =
242        source.iter().map(|p| (p.qname.clone(), p)).collect();
243    let all: BTreeSet<QualifiedName> = cat.keys().chain(src.keys()).cloned().collect();
244
245    for key in all {
246        match (cat.get(&key), src.get(&key)) {
247            (None, Some(s)) => out.push(
248                Change::Procedure(ProcedureChange::Create((*s).clone())),
249                Destructiveness::Safe,
250            ),
251            (Some(c), None) => out.push(
252                Change::Procedure(ProcedureChange::Drop(c.qname.clone())),
253                Destructiveness::RequiresApprovalAndDataLossWarning {
254                    reason: format!("drops procedure {}", c.qname),
255                },
256            ),
257            (Some(c), Some(s)) => {
258                diff_same_procedure(c, s, out);
259                diff_procedure_owner_grants(c, s, out, managed_roles);
260            }
261            (None, None) => unreachable!(),
262        }
263    }
264}
265
266/// Diff owner and grants for a procedure pair.
267fn diff_procedure_owner_grants(
268    catalog: &Procedure,
269    source: &Procedure,
270    out: &mut ChangeSet,
271    managed_roles: &BTreeSet<Identifier>,
272) {
273    // Build the argument signature for SQL rendering (procedures use the same
274    // IN/INOUT/VARIADIC identity subset as functions).
275    let args_label = NormalizedArgTypes::from_args(&source.args)
276        .types
277        .iter()
278        .map(crate::ir::column_type::ColumnType::render_sql)
279        .collect::<Vec<_>>()
280        .join(", ");
281    let signature = format!("({args_label})");
282
283    diff_owner_and_grants(
284        &CatalogObjectRef::Procedure {
285            name: source.qname.clone(),
286            signature: RoutineSignature::new(signature),
287        },
288        catalog.owner.as_ref(),
289        source.owner.as_ref(),
290        &catalog.grants,
291        &source.grants,
292        managed_roles,
293        ColumnGrantMode::ObjectOnly,
294        out,
295    );
296}
297
298/// Diff two procedures that share the same qualified name.
299fn diff_same_procedure(catalog: &Procedure, source: &Procedure, out: &mut ChangeSet) {
300    let body_changed = catalog.body.canonical_hash() != source.body.canonical_hash();
301    let attrs_changed = catalog.language != source.language
302        || catalog.security != source.security
303        || catalog.args != source.args
304        || catalog.commits_in_body != source.commits_in_body;
305
306    if !body_changed && !attrs_changed {
307        if catalog.comment != source.comment {
308            out.push(
309                Change::Procedure(ProcedureChange::SetComment {
310                    qname: source.qname.clone(),
311                    comment: source.comment.clone(),
312                }),
313                Destructiveness::Safe,
314            );
315        }
316        return;
317    }
318
319    // Procedures always support CREATE OR REPLACE PROCEDURE in PG 11+.
320    out.push(
321        Change::Procedure(ProcedureChange::CreateOrReplace(source.clone())),
322        Destructiveness::Safe,
323    );
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use std::collections::BTreeSet;
330
331    use crate::diff::change::{Change, FunctionChange, ProcedureChange};
332    use crate::diff::changeset::ChangeSet;
333    use crate::diff::destructiveness::Destructiveness;
334    use crate::identifier::{Identifier, QualifiedName};
335    use crate::ir::column_type::ColumnType;
336    use crate::ir::function::{
337        ArgMode, FunctionArg, FunctionLanguage, NormalizedArgTypes, ParallelSafety, ReturnType,
338        SecurityMode, Volatility,
339    };
340    use crate::ir::procedure::Procedure;
341    use crate::parse::normalize_body::NormalizedBody;
342
343    fn ident(s: &str) -> Identifier {
344        Identifier::from_unquoted(s).unwrap()
345    }
346
347    fn qn(schema: &str, name: &str) -> QualifiedName {
348        QualifiedName::new(ident(schema), ident(name))
349    }
350
351    fn make_func_no_args(qname: QualifiedName, body: &str) -> Function {
352        let args: Vec<FunctionArg> = vec![];
353        let arg_types_normalized = NormalizedArgTypes::from_args(&args);
354        Function {
355            qname,
356            args,
357            arg_types_normalized,
358            return_type: ReturnType::Scalar {
359                ty: ColumnType::Integer,
360            },
361            language: FunctionLanguage::Sql,
362            body: NormalizedBody::from_sql(body).unwrap(),
363            body_dependencies: vec![],
364            volatility: Volatility::Immutable,
365            strict: false,
366            security: SecurityMode::Invoker,
367            parallel: ParallelSafety::Safe,
368            leakproof: false,
369            cost: None,
370            rows: None,
371            comment: None,
372            owner: None,
373            grants: vec![],
374        }
375    }
376
377    fn make_func_with_arg(
378        qname: QualifiedName,
379        arg_ty: ColumnType,
380        ret_ty: ColumnType,
381    ) -> Function {
382        let args = vec![FunctionArg {
383            name: Some(ident("x")),
384            mode: ArgMode::In,
385            ty: arg_ty,
386            default: None,
387        }];
388        let arg_types_normalized = NormalizedArgTypes::from_args(&args);
389        Function {
390            qname,
391            args,
392            arg_types_normalized,
393            return_type: ReturnType::Scalar { ty: ret_ty },
394            language: FunctionLanguage::Sql,
395            body: NormalizedBody::from_sql("SELECT $1").unwrap(),
396            body_dependencies: vec![],
397            volatility: Volatility::Immutable,
398            strict: false,
399            security: SecurityMode::Invoker,
400            parallel: ParallelSafety::Safe,
401            leakproof: false,
402            cost: None,
403            rows: None,
404            comment: None,
405            owner: None,
406            grants: vec![],
407        }
408    }
409
410    fn make_procedure(qname: QualifiedName, body_text: &str) -> Procedure {
411        // Use from_raw_canonical so tests can provide distinguishable bodies
412        // without depending on a PL/pgSQL parser.
413        let body = NormalizedBody::from_raw_canonical(body_text.to_string());
414        Procedure {
415            qname,
416            args: vec![],
417            language: FunctionLanguage::PlPgSql,
418            body,
419            body_dependencies: vec![],
420            security: SecurityMode::Invoker,
421            commits_in_body: false,
422            comment: None,
423            owner: None,
424            grants: vec![],
425        }
426    }
427
428    // ── Function tests ────────────────────────────────────────────────────────
429
430    #[test]
431    fn function_create_emits_create() {
432        let f = make_func_no_args(qn("app", "add_one"), "SELECT 1");
433        let mut cs = ChangeSet::new();
434        diff_functions(&[], std::slice::from_ref(&f), &mut cs, &BTreeSet::new());
435        assert_eq!(cs.len(), 1);
436        assert!(matches!(
437            &cs.entries[0].change,
438            Change::Function(FunctionChange::Create(ff)) if ff.qname == f.qname
439        ));
440        assert!(matches!(
441            cs.entries[0].destructiveness,
442            Destructiveness::Safe
443        ));
444    }
445
446    #[test]
447    fn function_drop_is_data_loss() {
448        let f = make_func_no_args(qn("app", "old_fn"), "SELECT 99");
449        let mut cs = ChangeSet::new();
450        diff_functions(std::slice::from_ref(&f), &[], &mut cs, &BTreeSet::new());
451        assert_eq!(cs.len(), 1);
452        assert!(matches!(
453            &cs.entries[0].change,
454            Change::Function(FunctionChange::Drop { qname, .. }) if *qname == f.qname
455        ));
456        assert!(matches!(
457            cs.entries[0].destructiveness,
458            Destructiveness::RequiresApprovalAndDataLossWarning { .. }
459        ));
460    }
461
462    #[test]
463    fn function_body_change_emits_create_or_replace() {
464        let f_cat = make_func_no_args(qn("app", "fn1"), "SELECT 1");
465        let f_src = make_func_no_args(qn("app", "fn1"), "SELECT 2");
466        let mut cs = ChangeSet::new();
467        diff_functions(
468            std::slice::from_ref(&f_cat),
469            std::slice::from_ref(&f_src),
470            &mut cs,
471            &BTreeSet::new(),
472        );
473        assert_eq!(cs.len(), 1);
474        assert!(matches!(
475            &cs.entries[0].change,
476            Change::Function(FunctionChange::CreateOrReplace(ff)) if ff.qname == f_src.qname
477        ));
478        assert!(matches!(
479            cs.entries[0].destructiveness,
480            Destructiveness::Safe
481        ));
482    }
483
484    #[test]
485    fn function_return_type_kind_change_emits_cascade() {
486        let mut f_cat = make_func_no_args(qn("app", "fn2"), "SELECT 1");
487        f_cat.return_type = ReturnType::Scalar {
488            ty: ColumnType::Integer,
489        };
490        let mut f_src = make_func_no_args(qn("app", "fn2"), "SELECT 1");
491        f_src.return_type = ReturnType::SetOf {
492            ty: ColumnType::Integer,
493        };
494        let mut cs = ChangeSet::new();
495        diff_functions(
496            std::slice::from_ref(&f_cat),
497            std::slice::from_ref(&f_src),
498            &mut cs,
499            &BTreeSet::new(),
500        );
501        assert_eq!(cs.len(), 1);
502        assert!(matches!(
503            &cs.entries[0].change,
504            Change::Function(FunctionChange::ReplaceViaDropCreate { source, .. }) if source.qname == f_src.qname
505        ));
506        assert!(matches!(
507            cs.entries[0].destructiveness,
508            Destructiveness::RequiresApprovalAndDataLossWarning { .. }
509        ));
510    }
511
512    #[test]
513    fn function_overloads_treated_independently() {
514        // Two overloads of the same name but different arg types.
515        let f_int = make_func_with_arg(
516            qn("app", "process"),
517            ColumnType::Integer,
518            ColumnType::Integer,
519        );
520        let f_txt = make_func_with_arg(qn("app", "process"), ColumnType::Text, ColumnType::Text);
521
522        // catalog has only the int overload; source has both.
523        let src = [f_int.clone(), f_txt];
524        let mut cs = ChangeSet::new();
525        diff_functions(
526            std::slice::from_ref(&f_int),
527            &src,
528            &mut cs,
529            &BTreeSet::new(),
530        );
531
532        // Only the text overload should be emitted as a Create.
533        assert_eq!(cs.len(), 1);
534        assert!(matches!(
535            &cs.entries[0].change,
536            Change::Function(FunctionChange::Create(ff)) if ff.arg_types_normalized.types == [ColumnType::Text]
537        ));
538    }
539
540    #[test]
541    fn function_comment_only_change_is_safe() {
542        let f_cat = make_func_no_args(qn("app", "fn3"), "SELECT 1");
543        let mut f_src = make_func_no_args(qn("app", "fn3"), "SELECT 1");
544        f_src.comment = Some("new comment".to_string());
545        let mut cs = ChangeSet::new();
546        diff_functions(
547            std::slice::from_ref(&f_cat),
548            std::slice::from_ref(&f_src),
549            &mut cs,
550            &BTreeSet::new(),
551        );
552        assert_eq!(cs.len(), 1);
553        assert!(matches!(
554            &cs.entries[0].change,
555            Change::Function(FunctionChange::SetComment { .. })
556        ));
557        assert!(matches!(
558            cs.entries[0].destructiveness,
559            Destructiveness::Safe
560        ));
561    }
562
563    #[test]
564    fn function_arg_default_removed_requires_approval() {
565        use crate::ir::default_expr::NormalizedExpr;
566
567        let make_with_default = |has_default: bool| {
568            let default = if has_default {
569                Some(NormalizedExpr::from_text("42"))
570            } else {
571                None
572            };
573            let args = vec![FunctionArg {
574                name: Some(ident("x")),
575                mode: ArgMode::In,
576                ty: ColumnType::Integer,
577                default,
578            }];
579            let norm = NormalizedArgTypes::from_args(&args);
580            Function {
581                qname: qn("app", "fn_default"),
582                args,
583                arg_types_normalized: norm,
584                return_type: ReturnType::Scalar {
585                    ty: ColumnType::Integer,
586                },
587                language: FunctionLanguage::Sql,
588                body: NormalizedBody::from_sql("SELECT $1").unwrap(),
589                body_dependencies: vec![],
590                volatility: Volatility::Immutable,
591                strict: false,
592                security: SecurityMode::Invoker,
593                parallel: ParallelSafety::Safe,
594                leakproof: false,
595                cost: None,
596                rows: None,
597                comment: None,
598                owner: None,
599                grants: vec![],
600            }
601        };
602
603        let f_cat = make_with_default(true);
604        let f_src = make_with_default(false);
605        let mut cs = ChangeSet::new();
606        // Body is the same, but the default was removed — attrs_changed=true because args differ.
607        diff_functions(
608            std::slice::from_ref(&f_cat),
609            std::slice::from_ref(&f_src),
610            &mut cs,
611            &BTreeSet::new(),
612        );
613        assert_eq!(cs.len(), 1);
614        assert!(matches!(
615            &cs.entries[0].change,
616            Change::Function(FunctionChange::CreateOrReplace(_))
617        ));
618        assert!(matches!(
619            cs.entries[0].destructiveness,
620            Destructiveness::RequiresApproval { .. }
621        ));
622    }
623
624    #[test]
625    fn function_identical_emits_nothing() {
626        let f = make_func_no_args(qn("app", "unchanged"), "SELECT 42");
627        let mut cs = ChangeSet::new();
628        diff_functions(
629            std::slice::from_ref(&f),
630            std::slice::from_ref(&f),
631            &mut cs,
632            &BTreeSet::new(),
633        );
634        assert!(cs.is_empty());
635    }
636
637    // ── Procedure tests ───────────────────────────────────────────────────────
638
639    #[test]
640    fn procedure_create_emits_create() {
641        let p = make_procedure(qn("app", "do_work"), "BEGIN NULL; END");
642        let mut cs = ChangeSet::new();
643        diff_procedures(&[], std::slice::from_ref(&p), &mut cs, &BTreeSet::new());
644        assert_eq!(cs.len(), 1);
645        assert!(matches!(
646            &cs.entries[0].change,
647            Change::Procedure(ProcedureChange::Create(pp)) if pp.qname == p.qname
648        ));
649        assert!(matches!(
650            cs.entries[0].destructiveness,
651            Destructiveness::Safe
652        ));
653    }
654
655    #[test]
656    fn procedure_drop_is_data_loss() {
657        let p = make_procedure(qn("app", "old_proc"), "BEGIN NULL; END");
658        let mut cs = ChangeSet::new();
659        diff_procedures(std::slice::from_ref(&p), &[], &mut cs, &BTreeSet::new());
660        assert_eq!(cs.len(), 1);
661        assert!(matches!(
662            &cs.entries[0].change,
663            Change::Procedure(ProcedureChange::Drop(q)) if *q == p.qname
664        ));
665        assert!(matches!(
666            cs.entries[0].destructiveness,
667            Destructiveness::RequiresApprovalAndDataLossWarning { .. }
668        ));
669    }
670
671    #[test]
672    fn procedure_body_change_emits_create_or_replace() {
673        let p_cat = make_procedure(qn("app", "proc1"), "BEGIN NULL; END");
674        let p_src = make_procedure(qn("app", "proc1"), "BEGIN RAISE NOTICE 'hi'; END");
675        let mut cs = ChangeSet::new();
676        diff_procedures(
677            std::slice::from_ref(&p_cat),
678            std::slice::from_ref(&p_src),
679            &mut cs,
680            &BTreeSet::new(),
681        );
682        assert_eq!(cs.len(), 1);
683        assert!(matches!(
684            &cs.entries[0].change,
685            Change::Procedure(ProcedureChange::CreateOrReplace(pp)) if pp.qname == p_src.qname
686        ));
687        assert!(matches!(
688            cs.entries[0].destructiveness,
689            Destructiveness::Safe
690        ));
691    }
692
693    #[test]
694    fn procedure_identical_emits_nothing() {
695        let p = make_procedure(qn("app", "stable_proc"), "BEGIN NULL; END");
696        let mut cs = ChangeSet::new();
697        diff_procedures(
698            std::slice::from_ref(&p),
699            std::slice::from_ref(&p),
700            &mut cs,
701            &BTreeSet::new(),
702        );
703        assert!(cs.is_empty());
704    }
705
706    // ── Grant signature tests ─────────────────────────────────────────────────
707
708    #[test]
709    fn function_grant_change_carries_signature() {
710        use crate::ir::grant::{Grant, GrantTarget, Privilege};
711
712        // Function with one IN arg: signature should be "(integer)".
713        let f_cat = make_func_with_arg(qn("app", "foo"), ColumnType::Integer, ColumnType::Integer);
714        let mut f_src = f_cat.clone();
715
716        // Add a grant only in source → differ must emit GrantObjectPrivilege.
717        let managed_role = ident("app_user");
718        f_src.grants = vec![Grant {
719            grantee: GrantTarget::Role(managed_role.clone()),
720            privilege: Privilege::Execute,
721            with_grant_option: false,
722            columns: None,
723        }];
724
725        let mut managed = BTreeSet::new();
726        managed.insert(managed_role);
727
728        let mut cs = ChangeSet::new();
729        diff_functions(
730            std::slice::from_ref(&f_cat),
731            std::slice::from_ref(&f_src),
732            &mut cs,
733            &managed,
734        );
735
736        // Expect exactly one GrantObjectPrivilege change.
737        let grant_entry = cs
738            .entries
739            .iter()
740            .find(|e| matches!(&e.change, Change::GrantObjectPrivilege { .. }));
741        assert!(
742            grant_entry.is_some(),
743            "expected GrantObjectPrivilege change"
744        );
745
746        if let Change::GrantObjectPrivilege { object, .. } = &grant_entry.unwrap().change {
747            assert!(
748                matches!(
749                    object,
750                    CatalogObjectRef::Function { signature, .. }
751                        if signature == &RoutineSignature::new("(integer)".to_string())
752                ),
753                "signature must carry the IN arg type on a Function variant"
754            );
755        }
756    }
757
758    #[test]
759    fn function_grant_change_no_args_has_empty_parens_signature() {
760        use crate::ir::grant::{Grant, GrantTarget, Privilege};
761
762        let f_cat = make_func_no_args(qn("app", "bar"), "SELECT 1");
763        let mut f_src = f_cat.clone();
764
765        let managed_role = ident("app_user");
766        f_src.grants = vec![Grant {
767            grantee: GrantTarget::Role(managed_role.clone()),
768            privilege: Privilege::Execute,
769            with_grant_option: false,
770            columns: None,
771        }];
772
773        let mut managed = BTreeSet::new();
774        managed.insert(managed_role);
775
776        let mut cs = ChangeSet::new();
777        diff_functions(
778            std::slice::from_ref(&f_cat),
779            std::slice::from_ref(&f_src),
780            &mut cs,
781            &managed,
782        );
783
784        let grant_entry = cs
785            .entries
786            .iter()
787            .find(|e| matches!(&e.change, Change::GrantObjectPrivilege { .. }));
788        assert!(
789            grant_entry.is_some(),
790            "expected GrantObjectPrivilege change"
791        );
792
793        if let Change::GrantObjectPrivilege { object, .. } = &grant_entry.unwrap().change {
794            assert!(
795                matches!(
796                    object,
797                    CatalogObjectRef::Function { signature, .. }
798                        if signature == &RoutineSignature::new("()".to_string())
799                ),
800                "no-arg function signature must be ()"
801            );
802        }
803    }
804
805    #[test]
806    fn procedure_grant_change_carries_signature() {
807        use crate::ir::function::{ArgMode, FunctionArg};
808        use crate::ir::grant::{Grant, GrantTarget, Privilege};
809
810        // Procedure with one IN arg of type integer.
811        let mut p_cat = make_procedure(qn("app", "do_work"), "BEGIN NULL; END");
812        p_cat.args = vec![FunctionArg {
813            name: Some(ident("n")),
814            mode: ArgMode::In,
815            ty: ColumnType::Integer,
816            default: None,
817        }];
818        let mut p_src = p_cat.clone();
819
820        let managed_role = ident("app_user");
821        p_src.grants = vec![Grant {
822            grantee: GrantTarget::Role(managed_role.clone()),
823            privilege: Privilege::Execute,
824            with_grant_option: false,
825            columns: None,
826        }];
827
828        let mut managed = BTreeSet::new();
829        managed.insert(managed_role);
830
831        let mut cs = ChangeSet::new();
832        diff_procedures(
833            std::slice::from_ref(&p_cat),
834            std::slice::from_ref(&p_src),
835            &mut cs,
836            &managed,
837        );
838
839        let grant_entry = cs
840            .entries
841            .iter()
842            .find(|e| matches!(&e.change, Change::GrantObjectPrivilege { .. }));
843        assert!(
844            grant_entry.is_some(),
845            "expected GrantObjectPrivilege change"
846        );
847
848        if let Change::GrantObjectPrivilege { object, .. } = &grant_entry.unwrap().change {
849            assert!(
850                matches!(
851                    object,
852                    CatalogObjectRef::Procedure { signature, .. }
853                        if signature == &RoutineSignature::new("(integer)".to_string())
854                ),
855                "procedure signature must carry the IN arg type on a Procedure variant"
856            );
857        }
858    }
859}