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