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