use std::collections::{BTreeMap, BTreeSet};
use crate::diff::change::{Change, FunctionChange, ProcedureChange};
use crate::diff::changeset::{ChangeSet, RevokeWithOwnerObservation, UnmanagedGrantObservation};
use crate::diff::destructiveness::Destructiveness;
use crate::diff::grants::diff_grants;
use crate::diff::owner_op::{AlterObjectOwner, OwnerObjectKind};
use crate::identifier::{Identifier, QualifiedName};
use crate::ir::function::{ArgMode, Function, NormalizedArgTypes, ReturnType};
use crate::ir::grant::GrantTarget;
use crate::ir::procedure::Procedure;
pub fn diff_functions(
catalog: &[Function],
source: &[Function],
out: &mut ChangeSet,
managed_roles: &BTreeSet<Identifier>,
) {
type Key = (QualifiedName, [u8; 32]);
let cat: BTreeMap<Key, &Function> = catalog
.iter()
.map(|f| ((f.qname.clone(), f.arg_types_normalized.canonical_hash), f))
.collect();
let src: BTreeMap<Key, &Function> = source
.iter()
.map(|f| ((f.qname.clone(), f.arg_types_normalized.canonical_hash), f))
.collect();
let all_keys: BTreeSet<Key> = cat.keys().chain(src.keys()).cloned().collect();
for key in all_keys {
match (cat.get(&key), src.get(&key)) {
(None, Some(s)) => out.push(
Change::Function(FunctionChange::Create((*s).clone())),
Destructiveness::Safe,
),
(Some(c), None) => out.push(
Change::Function(FunctionChange::Drop {
qname: c.qname.clone(),
args: c.arg_types_normalized.clone(),
}),
Destructiveness::RequiresApprovalAndDataLossWarning {
reason: format!("drops function {}", c.qname),
},
),
(Some(c), Some(s)) => {
diff_same_function(c, s, out);
diff_function_owner_grants(c, s, out, managed_roles);
}
(None, None) => unreachable!(),
}
}
}
fn diff_function_owner_grants(
catalog: &Function,
source: &Function,
out: &mut ChangeSet,
managed_roles: &BTreeSet<Identifier>,
) {
let args_label = NormalizedArgTypes::from_args(&source.args)
.types
.iter()
.map(crate::ir::column_type::ColumnType::render_sql)
.collect::<Vec<_>>()
.join(", ");
let signature = format!("({args_label})");
if let Some(source_owner) = &source.owner
&& catalog.owner.as_ref() != Some(source_owner)
{
out.push(
Change::AlterObjectOwner(AlterObjectOwner {
kind: OwnerObjectKind::Function,
id: crate::diff::owner_op::OwnedObjectId::Qualified(source.qname.clone()),
signature: signature.clone(),
from: catalog.owner.clone(),
to: source_owner.clone(),
}),
Destructiveness::Safe,
);
}
let object_label = format!("function {}{signature}", source.qname);
let (to_add, to_revoke, unmanaged) =
diff_grants(&catalog.grants, &source.grants, managed_roles);
for g in to_revoke {
if let Some(source_owner) = &source.owner {
out.revokes_with_owner.push(RevokeWithOwnerObservation {
object_label: object_label.clone(),
privilege_label: g.privilege.sql_keyword().into(),
grantee: g.grantee.clone(),
owner: source_owner.clone(),
});
}
out.push(
Change::RevokeObjectPrivilege {
qname: source.qname.clone(),
kind: OwnerObjectKind::Function,
signature: signature.clone(),
grant: g,
},
Destructiveness::Safe,
);
}
for g in to_add {
out.push(
Change::GrantObjectPrivilege {
qname: source.qname.clone(),
kind: OwnerObjectKind::Function,
signature: signature.clone(),
grant: g,
},
Destructiveness::Safe,
);
}
for g in unmanaged {
if let GrantTarget::Role(role_name) = &g.grantee {
out.unmanaged_grants.push(UnmanagedGrantObservation {
object_label: object_label.clone(),
privilege_label: g.privilege.sql_keyword().into(),
role_name: role_name.clone(),
});
}
}
}
fn diff_same_function(catalog: &Function, source: &Function, out: &mut ChangeSet) {
let body_changed = catalog.body.canonical_hash() != source.body.canonical_hash();
let attrs_changed = catalog.return_type != source.return_type
|| catalog.language != source.language
|| catalog.volatility != source.volatility
|| catalog.strict != source.strict
|| catalog.security != source.security
|| catalog.parallel != source.parallel
|| catalog.leakproof != source.leakproof
|| catalog.cost.map(f32::to_bits) != source.cost.map(f32::to_bits)
|| catalog.rows.map(f32::to_bits) != source.rows.map(f32::to_bits)
|| catalog.args != source.args;
if !body_changed && !attrs_changed {
if catalog.comment != source.comment {
out.push(
Change::Function(FunctionChange::SetComment {
qname: source.qname.clone(),
args: source.arg_types_normalized.clone(),
comment: source.comment.clone(),
}),
Destructiveness::Safe,
);
}
return;
}
if function_can_or_replace(catalog, source) {
let dest = if arg_default_removed(catalog, source) {
Destructiveness::RequiresApproval {
reason: format!(
"function {} removes an argument default (may break callers passing fewer args)",
source.qname
),
}
} else {
Destructiveness::Safe
};
out.push(
Change::Function(FunctionChange::CreateOrReplace(source.clone())),
dest,
);
} else {
out.push(
Change::Function(FunctionChange::ReplaceWithCascade {
catalog: catalog.clone(),
source: source.clone(),
}),
Destructiveness::RequiresApprovalAndDataLossWarning {
reason: format!(
"function {} return-type or language change requires DROP+CREATE CASCADE",
source.qname
),
},
);
}
}
pub(crate) fn function_can_or_replace(catalog: &Function, source: &Function) -> bool {
if catalog.language != source.language {
return false;
}
if !return_type_compatible(&catalog.return_type, &source.return_type) {
return false;
}
let cat_outs: Vec<_> = catalog
.args
.iter()
.filter(|a| matches!(a.mode, ArgMode::Out | ArgMode::InOut))
.map(|a| (a.name.clone(), a.ty.clone()))
.collect();
let src_outs: Vec<_> = source
.args
.iter()
.filter(|a| matches!(a.mode, ArgMode::Out | ArgMode::InOut))
.map(|a| (a.name.clone(), a.ty.clone()))
.collect();
if cat_outs != src_outs {
return false;
}
true
}
fn return_type_compatible(a: &ReturnType, b: &ReturnType) -> bool {
a == b
}
fn arg_default_removed(catalog: &Function, source: &Function) -> bool {
catalog
.args
.iter()
.zip(source.args.iter())
.any(|(c, s)| c.default.is_some() && s.default.is_none())
}
pub fn diff_procedures(
catalog: &[Procedure],
source: &[Procedure],
out: &mut ChangeSet,
managed_roles: &BTreeSet<Identifier>,
) {
let cat: BTreeMap<QualifiedName, &Procedure> =
catalog.iter().map(|p| (p.qname.clone(), p)).collect();
let src: BTreeMap<QualifiedName, &Procedure> =
source.iter().map(|p| (p.qname.clone(), p)).collect();
let all: BTreeSet<QualifiedName> = cat.keys().chain(src.keys()).cloned().collect();
for key in all {
match (cat.get(&key), src.get(&key)) {
(None, Some(s)) => out.push(
Change::Procedure(ProcedureChange::Create((*s).clone())),
Destructiveness::Safe,
),
(Some(c), None) => out.push(
Change::Procedure(ProcedureChange::Drop(c.qname.clone())),
Destructiveness::RequiresApprovalAndDataLossWarning {
reason: format!("drops procedure {}", c.qname),
},
),
(Some(c), Some(s)) => {
diff_same_procedure(c, s, out);
diff_procedure_owner_grants(c, s, out, managed_roles);
}
(None, None) => unreachable!(),
}
}
}
fn diff_procedure_owner_grants(
catalog: &Procedure,
source: &Procedure,
out: &mut ChangeSet,
managed_roles: &BTreeSet<Identifier>,
) {
let args_label = NormalizedArgTypes::from_args(&source.args)
.types
.iter()
.map(crate::ir::column_type::ColumnType::render_sql)
.collect::<Vec<_>>()
.join(", ");
let signature = format!("({args_label})");
if let Some(source_owner) = &source.owner
&& catalog.owner.as_ref() != Some(source_owner)
{
out.push(
Change::AlterObjectOwner(AlterObjectOwner {
kind: OwnerObjectKind::Procedure,
id: crate::diff::owner_op::OwnedObjectId::Qualified(source.qname.clone()),
signature: signature.clone(),
from: catalog.owner.clone(),
to: source_owner.clone(),
}),
Destructiveness::Safe,
);
}
let object_label = format!("procedure {}{signature}", source.qname);
let (to_add, to_revoke, unmanaged) =
diff_grants(&catalog.grants, &source.grants, managed_roles);
for g in to_revoke {
if let Some(source_owner) = &source.owner {
out.revokes_with_owner.push(RevokeWithOwnerObservation {
object_label: object_label.clone(),
privilege_label: g.privilege.sql_keyword().into(),
grantee: g.grantee.clone(),
owner: source_owner.clone(),
});
}
out.push(
Change::RevokeObjectPrivilege {
qname: source.qname.clone(),
kind: OwnerObjectKind::Procedure,
signature: signature.clone(),
grant: g,
},
Destructiveness::Safe,
);
}
for g in to_add {
out.push(
Change::GrantObjectPrivilege {
qname: source.qname.clone(),
kind: OwnerObjectKind::Procedure,
signature: signature.clone(),
grant: g,
},
Destructiveness::Safe,
);
}
for g in unmanaged {
if let GrantTarget::Role(role_name) = &g.grantee {
out.unmanaged_grants.push(UnmanagedGrantObservation {
object_label: object_label.clone(),
privilege_label: g.privilege.sql_keyword().into(),
role_name: role_name.clone(),
});
}
}
}
fn diff_same_procedure(catalog: &Procedure, source: &Procedure, out: &mut ChangeSet) {
let body_changed = catalog.body.canonical_hash() != source.body.canonical_hash();
let attrs_changed = catalog.language != source.language
|| catalog.security != source.security
|| catalog.args != source.args
|| catalog.commits_in_body != source.commits_in_body;
if !body_changed && !attrs_changed {
if catalog.comment != source.comment {
out.push(
Change::Procedure(ProcedureChange::SetComment {
qname: source.qname.clone(),
comment: source.comment.clone(),
}),
Destructiveness::Safe,
);
}
return;
}
out.push(
Change::Procedure(ProcedureChange::CreateOrReplace(source.clone())),
Destructiveness::Safe,
);
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
use crate::diff::change::{Change, FunctionChange, ProcedureChange};
use crate::diff::changeset::ChangeSet;
use crate::diff::destructiveness::Destructiveness;
use crate::identifier::{Identifier, QualifiedName};
use crate::ir::column_type::ColumnType;
use crate::ir::function::{
ArgMode, FunctionArg, FunctionLanguage, NormalizedArgTypes, ParallelSafety, ReturnType,
SecurityMode, Volatility,
};
use crate::ir::procedure::Procedure;
use crate::parse::normalize_body::NormalizedBody;
fn ident(s: &str) -> Identifier {
Identifier::from_unquoted(s).unwrap()
}
fn qn(schema: &str, name: &str) -> QualifiedName {
QualifiedName::new(ident(schema), ident(name))
}
fn make_func_no_args(qname: QualifiedName, body: &str) -> Function {
let args: Vec<FunctionArg> = vec![];
let arg_types_normalized = NormalizedArgTypes::from_args(&args);
Function {
qname,
args,
arg_types_normalized,
return_type: ReturnType::Scalar {
ty: ColumnType::Integer,
},
language: FunctionLanguage::Sql,
body: NormalizedBody::from_sql(body).unwrap(),
body_dependencies: vec![],
volatility: Volatility::Immutable,
strict: false,
security: SecurityMode::Invoker,
parallel: ParallelSafety::Safe,
leakproof: false,
cost: None,
rows: None,
comment: None,
owner: None,
grants: vec![],
}
}
fn make_func_with_arg(
qname: QualifiedName,
arg_ty: ColumnType,
ret_ty: ColumnType,
) -> Function {
let args = vec![FunctionArg {
name: Some(ident("x")),
mode: ArgMode::In,
ty: arg_ty,
default: None,
}];
let arg_types_normalized = NormalizedArgTypes::from_args(&args);
Function {
qname,
args,
arg_types_normalized,
return_type: ReturnType::Scalar { ty: ret_ty },
language: FunctionLanguage::Sql,
body: NormalizedBody::from_sql("SELECT $1").unwrap(),
body_dependencies: vec![],
volatility: Volatility::Immutable,
strict: false,
security: SecurityMode::Invoker,
parallel: ParallelSafety::Safe,
leakproof: false,
cost: None,
rows: None,
comment: None,
owner: None,
grants: vec![],
}
}
fn make_procedure(qname: QualifiedName, body_text: &str) -> Procedure {
let body = NormalizedBody::from_raw_canonical(body_text.to_string());
Procedure {
qname,
args: vec![],
language: FunctionLanguage::PlPgSql,
body,
body_dependencies: vec![],
security: SecurityMode::Invoker,
commits_in_body: false,
comment: None,
owner: None,
grants: vec![],
}
}
#[test]
fn function_create_emits_create() {
let f = make_func_no_args(qn("app", "add_one"), "SELECT 1");
let mut cs = ChangeSet::new();
diff_functions(&[], std::slice::from_ref(&f), &mut cs, &BTreeSet::new());
assert_eq!(cs.len(), 1);
assert!(matches!(
&cs.entries[0].change,
Change::Function(FunctionChange::Create(ff)) if ff.qname == f.qname
));
assert!(matches!(
cs.entries[0].destructiveness,
Destructiveness::Safe
));
}
#[test]
fn function_drop_is_data_loss() {
let f = make_func_no_args(qn("app", "old_fn"), "SELECT 99");
let mut cs = ChangeSet::new();
diff_functions(std::slice::from_ref(&f), &[], &mut cs, &BTreeSet::new());
assert_eq!(cs.len(), 1);
assert!(matches!(
&cs.entries[0].change,
Change::Function(FunctionChange::Drop { qname, .. }) if *qname == f.qname
));
assert!(matches!(
cs.entries[0].destructiveness,
Destructiveness::RequiresApprovalAndDataLossWarning { .. }
));
}
#[test]
fn function_body_change_emits_create_or_replace() {
let f_cat = make_func_no_args(qn("app", "fn1"), "SELECT 1");
let f_src = make_func_no_args(qn("app", "fn1"), "SELECT 2");
let mut cs = ChangeSet::new();
diff_functions(
std::slice::from_ref(&f_cat),
std::slice::from_ref(&f_src),
&mut cs,
&BTreeSet::new(),
);
assert_eq!(cs.len(), 1);
assert!(matches!(
&cs.entries[0].change,
Change::Function(FunctionChange::CreateOrReplace(ff)) if ff.qname == f_src.qname
));
assert!(matches!(
cs.entries[0].destructiveness,
Destructiveness::Safe
));
}
#[test]
fn function_return_type_kind_change_emits_cascade() {
let mut f_cat = make_func_no_args(qn("app", "fn2"), "SELECT 1");
f_cat.return_type = ReturnType::Scalar {
ty: ColumnType::Integer,
};
let mut f_src = make_func_no_args(qn("app", "fn2"), "SELECT 1");
f_src.return_type = ReturnType::SetOf {
ty: ColumnType::Integer,
};
let mut cs = ChangeSet::new();
diff_functions(
std::slice::from_ref(&f_cat),
std::slice::from_ref(&f_src),
&mut cs,
&BTreeSet::new(),
);
assert_eq!(cs.len(), 1);
assert!(matches!(
&cs.entries[0].change,
Change::Function(FunctionChange::ReplaceWithCascade { source, .. }) if source.qname == f_src.qname
));
assert!(matches!(
cs.entries[0].destructiveness,
Destructiveness::RequiresApprovalAndDataLossWarning { .. }
));
}
#[test]
fn function_overloads_treated_independently() {
let f_int = make_func_with_arg(
qn("app", "process"),
ColumnType::Integer,
ColumnType::Integer,
);
let f_txt = make_func_with_arg(qn("app", "process"), ColumnType::Text, ColumnType::Text);
let src = [f_int.clone(), f_txt];
let mut cs = ChangeSet::new();
diff_functions(
std::slice::from_ref(&f_int),
&src,
&mut cs,
&BTreeSet::new(),
);
assert_eq!(cs.len(), 1);
assert!(matches!(
&cs.entries[0].change,
Change::Function(FunctionChange::Create(ff)) if ff.arg_types_normalized.types == [ColumnType::Text]
));
}
#[test]
fn function_comment_only_change_is_safe() {
let f_cat = make_func_no_args(qn("app", "fn3"), "SELECT 1");
let mut f_src = make_func_no_args(qn("app", "fn3"), "SELECT 1");
f_src.comment = Some("new comment".to_string());
let mut cs = ChangeSet::new();
diff_functions(
std::slice::from_ref(&f_cat),
std::slice::from_ref(&f_src),
&mut cs,
&BTreeSet::new(),
);
assert_eq!(cs.len(), 1);
assert!(matches!(
&cs.entries[0].change,
Change::Function(FunctionChange::SetComment { .. })
));
assert!(matches!(
cs.entries[0].destructiveness,
Destructiveness::Safe
));
}
#[test]
fn function_arg_default_removed_requires_approval() {
use crate::ir::default_expr::NormalizedExpr;
let make_with_default = |has_default: bool| {
let default = if has_default {
Some(NormalizedExpr::from_text("42"))
} else {
None
};
let args = vec![FunctionArg {
name: Some(ident("x")),
mode: ArgMode::In,
ty: ColumnType::Integer,
default,
}];
let norm = NormalizedArgTypes::from_args(&args);
Function {
qname: qn("app", "fn_default"),
args,
arg_types_normalized: norm,
return_type: ReturnType::Scalar {
ty: ColumnType::Integer,
},
language: FunctionLanguage::Sql,
body: NormalizedBody::from_sql("SELECT $1").unwrap(),
body_dependencies: vec![],
volatility: Volatility::Immutable,
strict: false,
security: SecurityMode::Invoker,
parallel: ParallelSafety::Safe,
leakproof: false,
cost: None,
rows: None,
comment: None,
owner: None,
grants: vec![],
}
};
let f_cat = make_with_default(true);
let f_src = make_with_default(false);
let mut cs = ChangeSet::new();
diff_functions(
std::slice::from_ref(&f_cat),
std::slice::from_ref(&f_src),
&mut cs,
&BTreeSet::new(),
);
assert_eq!(cs.len(), 1);
assert!(matches!(
&cs.entries[0].change,
Change::Function(FunctionChange::CreateOrReplace(_))
));
assert!(matches!(
cs.entries[0].destructiveness,
Destructiveness::RequiresApproval { .. }
));
}
#[test]
fn function_identical_emits_nothing() {
let f = make_func_no_args(qn("app", "unchanged"), "SELECT 42");
let mut cs = ChangeSet::new();
diff_functions(
std::slice::from_ref(&f),
std::slice::from_ref(&f),
&mut cs,
&BTreeSet::new(),
);
assert!(cs.is_empty());
}
#[test]
fn procedure_create_emits_create() {
let p = make_procedure(qn("app", "do_work"), "BEGIN NULL; END");
let mut cs = ChangeSet::new();
diff_procedures(&[], std::slice::from_ref(&p), &mut cs, &BTreeSet::new());
assert_eq!(cs.len(), 1);
assert!(matches!(
&cs.entries[0].change,
Change::Procedure(ProcedureChange::Create(pp)) if pp.qname == p.qname
));
assert!(matches!(
cs.entries[0].destructiveness,
Destructiveness::Safe
));
}
#[test]
fn procedure_drop_is_data_loss() {
let p = make_procedure(qn("app", "old_proc"), "BEGIN NULL; END");
let mut cs = ChangeSet::new();
diff_procedures(std::slice::from_ref(&p), &[], &mut cs, &BTreeSet::new());
assert_eq!(cs.len(), 1);
assert!(matches!(
&cs.entries[0].change,
Change::Procedure(ProcedureChange::Drop(q)) if *q == p.qname
));
assert!(matches!(
cs.entries[0].destructiveness,
Destructiveness::RequiresApprovalAndDataLossWarning { .. }
));
}
#[test]
fn procedure_body_change_emits_create_or_replace() {
let p_cat = make_procedure(qn("app", "proc1"), "BEGIN NULL; END");
let p_src = make_procedure(qn("app", "proc1"), "BEGIN RAISE NOTICE 'hi'; END");
let mut cs = ChangeSet::new();
diff_procedures(
std::slice::from_ref(&p_cat),
std::slice::from_ref(&p_src),
&mut cs,
&BTreeSet::new(),
);
assert_eq!(cs.len(), 1);
assert!(matches!(
&cs.entries[0].change,
Change::Procedure(ProcedureChange::CreateOrReplace(pp)) if pp.qname == p_src.qname
));
assert!(matches!(
cs.entries[0].destructiveness,
Destructiveness::Safe
));
}
#[test]
fn procedure_identical_emits_nothing() {
let p = make_procedure(qn("app", "stable_proc"), "BEGIN NULL; END");
let mut cs = ChangeSet::new();
diff_procedures(
std::slice::from_ref(&p),
std::slice::from_ref(&p),
&mut cs,
&BTreeSet::new(),
);
assert!(cs.is_empty());
}
#[test]
fn function_grant_change_carries_signature() {
use crate::ir::grant::{Grant, GrantTarget, Privilege};
let f_cat = make_func_with_arg(qn("app", "foo"), ColumnType::Integer, ColumnType::Integer);
let mut f_src = f_cat.clone();
let managed_role = ident("app_user");
f_src.grants = vec![Grant {
grantee: GrantTarget::Role(managed_role.clone()),
privilege: Privilege::Execute,
with_grant_option: false,
columns: None,
}];
let mut managed = BTreeSet::new();
managed.insert(managed_role);
let mut cs = ChangeSet::new();
diff_functions(
std::slice::from_ref(&f_cat),
std::slice::from_ref(&f_src),
&mut cs,
&managed,
);
let grant_entry = cs
.entries
.iter()
.find(|e| matches!(&e.change, Change::GrantObjectPrivilege { .. }));
assert!(
grant_entry.is_some(),
"expected GrantObjectPrivilege change"
);
if let Change::GrantObjectPrivilege {
signature, kind, ..
} = &grant_entry.unwrap().change
{
assert_eq!(
signature, "(integer)",
"signature must carry the IN arg type"
);
assert!(matches!(
kind,
crate::diff::owner_op::OwnerObjectKind::Function
));
}
}
#[test]
fn function_grant_change_no_args_has_empty_parens_signature() {
use crate::ir::grant::{Grant, GrantTarget, Privilege};
let f_cat = make_func_no_args(qn("app", "bar"), "SELECT 1");
let mut f_src = f_cat.clone();
let managed_role = ident("app_user");
f_src.grants = vec![Grant {
grantee: GrantTarget::Role(managed_role.clone()),
privilege: Privilege::Execute,
with_grant_option: false,
columns: None,
}];
let mut managed = BTreeSet::new();
managed.insert(managed_role);
let mut cs = ChangeSet::new();
diff_functions(
std::slice::from_ref(&f_cat),
std::slice::from_ref(&f_src),
&mut cs,
&managed,
);
let grant_entry = cs
.entries
.iter()
.find(|e| matches!(&e.change, Change::GrantObjectPrivilege { .. }));
assert!(
grant_entry.is_some(),
"expected GrantObjectPrivilege change"
);
if let Change::GrantObjectPrivilege { signature, .. } = &grant_entry.unwrap().change {
assert_eq!(signature, "()", "no-arg function signature must be ()");
}
}
#[test]
fn procedure_grant_change_carries_signature() {
use crate::ir::function::{ArgMode, FunctionArg};
use crate::ir::grant::{Grant, GrantTarget, Privilege};
let mut p_cat = make_procedure(qn("app", "do_work"), "BEGIN NULL; END");
p_cat.args = vec![FunctionArg {
name: Some(ident("n")),
mode: ArgMode::In,
ty: ColumnType::Integer,
default: None,
}];
let mut p_src = p_cat.clone();
let managed_role = ident("app_user");
p_src.grants = vec![Grant {
grantee: GrantTarget::Role(managed_role.clone()),
privilege: Privilege::Execute,
with_grant_option: false,
columns: None,
}];
let mut managed = BTreeSet::new();
managed.insert(managed_role);
let mut cs = ChangeSet::new();
diff_procedures(
std::slice::from_ref(&p_cat),
std::slice::from_ref(&p_src),
&mut cs,
&managed,
);
let grant_entry = cs
.entries
.iter()
.find(|e| matches!(&e.change, Change::GrantObjectPrivilege { .. }));
assert!(
grant_entry.is_some(),
"expected GrantObjectPrivilege change"
);
if let Change::GrantObjectPrivilege {
signature, kind, ..
} = &grant_entry.unwrap().change
{
assert_eq!(
signature, "(integer)",
"procedure signature must carry the IN arg type"
);
assert!(matches!(
kind,
crate::diff::owner_op::OwnerObjectKind::Procedure
));
}
}
}