Skip to main content

pgevolve_core/diff/
owner_op.rs

1//! Catalog-object reference ([`CatalogObjectRef`]) and the uniform
2//! owner-change op ([`AlterObjectOwner`]) shared by the grant and owner diffs.
3
4use serde::{Deserialize, Serialize};
5
6use crate::identifier::{Identifier, QualifiedName};
7
8/// A routine's argument-type signature, e.g. `(integer, text)`.
9///
10/// Rendered verbatim — the leading/trailing parens are part of the stored
11/// string (constructed as `format!("({args_label})")` at the diff sites),
12/// matching the old `signature` field exactly so rendered SQL is unchanged.
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct RoutineSignature(String);
15
16impl RoutineSignature {
17    /// Construct from the parenthesized argument-type signature, e.g.
18    /// `(integer, text)`. The string is stored verbatim — no paren-wrapping
19    /// or normalization is applied.
20    #[must_use]
21    pub const fn new(s: String) -> Self {
22        Self(s)
23    }
24
25    /// Returns the inner signature string, parens included.
26    #[must_use]
27    pub const fn as_str(&self) -> &str {
28        self.0.as_str()
29    }
30}
31
32/// A grantable / ownable schema object, carrying exactly the data its kind
33/// needs.
34///
35/// A routine argument-signature is representable ONLY on
36/// [`CatalogObjectRef::Function`] / [`CatalogObjectRef::Procedure`], so the old
37/// `signature: String` field (documented as "empty for non-routine kinds") —
38/// an illegal state — is gone.
39///
40/// This subsumes the former `OwnerObjectKind` (the SQL keyword discriminant)
41/// and `OwnedObjectId` (the name shape): schema / publication / subscription
42/// render a bare [`Identifier`]; every other kind renders a [`QualifiedName`].
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(tag = "kind", rename_all = "snake_case")]
45pub enum CatalogObjectRef {
46    /// `SCHEMA x` — rendered as the bare schema name.
47    Schema(Identifier),
48    /// `SEQUENCE x`.
49    Sequence(QualifiedName),
50    /// `TABLE x`.
51    Table(QualifiedName),
52    /// `VIEW x`.
53    View(QualifiedName),
54    /// `MATERIALIZED VIEW x`.
55    MaterializedView(QualifiedName),
56    /// `TYPE x`.
57    UserType(QualifiedName),
58    /// `FUNCTION x(<signature>)`.
59    Function {
60        /// Qualified routine name.
61        name: QualifiedName,
62        /// Argument-type signature suffix (with parens).
63        signature: RoutineSignature,
64    },
65    /// `PROCEDURE x(<signature>)`.
66    Procedure {
67        /// Qualified routine name.
68        name: QualifiedName,
69        /// Argument-type signature suffix (with parens).
70        signature: RoutineSignature,
71    },
72    /// `STATISTICS x`.
73    Statistic(QualifiedName),
74    /// `COLLATION x`.
75    Collation(QualifiedName),
76    /// `PUBLICATION x` — cluster-level, rendered as the bare name.
77    Publication(Identifier),
78    /// `SUBSCRIPTION x` — cluster-level, rendered as the bare name.
79    Subscription(Identifier),
80}
81
82impl CatalogObjectRef {
83    /// The SQL keyword(s) used in `GRANT ... ON <keyword> <name>` /
84    /// `ALTER <keyword> <name> OWNER TO <role>`.
85    #[must_use]
86    pub const fn sql_keyword(&self) -> &'static str {
87        match self {
88            Self::Schema(_) => "SCHEMA",
89            Self::Sequence(_) => "SEQUENCE",
90            Self::Table(_) => "TABLE",
91            Self::View(_) => "VIEW",
92            Self::MaterializedView(_) => "MATERIALIZED VIEW",
93            Self::UserType(_) => "TYPE",
94            Self::Function { .. } => "FUNCTION",
95            Self::Procedure { .. } => "PROCEDURE",
96            Self::Statistic(_) => "STATISTICS",
97            Self::Collation(_) => "COLLATION",
98            Self::Publication(_) => "PUBLICATION",
99            Self::Subscription(_) => "SUBSCRIPTION",
100        }
101    }
102
103    /// Render the object's target name for use in
104    /// `ALTER <kw> <here> OWNER TO <role>;` / `GRANT ... ON <kw> <here> ...`.
105    ///
106    /// For routines this includes the signature suffix exactly as before
107    /// (`format!("{}{}", name.render_sql(), signature.as_str())`). Schema /
108    /// publication / subscription render the bare identifier; the rest render
109    /// the [`QualifiedName`].
110    #[must_use]
111    pub fn render_target(&self) -> String {
112        match self {
113            Self::Schema(name) | Self::Publication(name) | Self::Subscription(name) => {
114                name.render_sql()
115            }
116            Self::Sequence(q)
117            | Self::Table(q)
118            | Self::View(q)
119            | Self::MaterializedView(q)
120            | Self::UserType(q)
121            | Self::Statistic(q)
122            | Self::Collation(q) => q.render_sql(),
123            Self::Function { name, signature } | Self::Procedure { name, signature } => {
124                format!("{}{}", name.render_sql(), signature.as_str())
125            }
126        }
127    }
128
129    /// Human-readable label used in the grant observation side-channels
130    /// (`UnmanagedGrantObservation` / `RevokeWithOwnerObservation`), e.g.
131    /// `"table app.users"` or `"function app.f(integer)"`.
132    ///
133    /// The name is rendered via the identifier `Display` impl (raw, *not*
134    /// SQL-quoted) to byte-match the pre-dedup inline labels that built this
135    /// string with `format!("table {qname}")` and friends. This is distinct
136    /// from [`CatalogObjectRef::render_target`], which SQL-quotes for emitted
137    /// statements.
138    #[must_use]
139    pub fn observation_label(&self) -> String {
140        let kind = self.sql_keyword().to_lowercase();
141        match self {
142            Self::Schema(name) | Self::Publication(name) | Self::Subscription(name) => {
143                format!("{kind} {name}")
144            }
145            Self::Sequence(q)
146            | Self::Table(q)
147            | Self::View(q)
148            | Self::MaterializedView(q)
149            | Self::UserType(q)
150            | Self::Statistic(q)
151            | Self::Collation(q) => format!("{kind} {q}"),
152            Self::Function { name, signature } | Self::Procedure { name, signature } => {
153                format!("{kind} {name}{}", signature.as_str())
154            }
155        }
156    }
157}
158
159impl std::fmt::Display for CatalogObjectRef {
160    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161        f.write_str(&self.render_target())
162    }
163}
164
165/// An `ALTER <kind> <object> OWNER TO <to>` statement, paired with the previous
166/// owner for audit / rollback purposes.
167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
168pub struct AlterObjectOwner {
169    /// The object being re-owned (kind + name + optional routine signature).
170    pub object: CatalogObjectRef,
171    /// Previous owner (taken from the target catalog; `None` when the
172    /// catalog did not record an owner).
173    pub from: Option<Identifier>,
174    /// Desired new owner (taken from the source catalog).
175    pub to: Identifier,
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    fn id(s: &str) -> Identifier {
183        Identifier::from_unquoted(s).unwrap()
184    }
185
186    fn qn(schema: &str, name: &str) -> QualifiedName {
187        QualifiedName::new(id(schema), id(name))
188    }
189
190    #[test]
191    fn sql_keywords_match_pg() {
192        assert_eq!(CatalogObjectRef::Schema(id("s")).sql_keyword(), "SCHEMA");
193        assert_eq!(
194            CatalogObjectRef::Sequence(qn("s", "x")).sql_keyword(),
195            "SEQUENCE"
196        );
197        assert_eq!(CatalogObjectRef::Table(qn("s", "x")).sql_keyword(), "TABLE");
198        assert_eq!(CatalogObjectRef::View(qn("s", "x")).sql_keyword(), "VIEW");
199        assert_eq!(
200            CatalogObjectRef::MaterializedView(qn("s", "x")).sql_keyword(),
201            "MATERIALIZED VIEW"
202        );
203        assert_eq!(
204            CatalogObjectRef::Function {
205                name: qn("s", "x"),
206                signature: RoutineSignature::new("()".to_string()),
207            }
208            .sql_keyword(),
209            "FUNCTION"
210        );
211        assert_eq!(
212            CatalogObjectRef::Procedure {
213                name: qn("s", "x"),
214                signature: RoutineSignature::new("()".to_string()),
215            }
216            .sql_keyword(),
217            "PROCEDURE"
218        );
219        assert_eq!(
220            CatalogObjectRef::UserType(qn("s", "x")).sql_keyword(),
221            "TYPE"
222        );
223        assert_eq!(
224            CatalogObjectRef::Publication(id("p")).sql_keyword(),
225            "PUBLICATION"
226        );
227        assert_eq!(
228            CatalogObjectRef::Subscription(id("p")).sql_keyword(),
229            "SUBSCRIPTION"
230        );
231        assert_eq!(
232            CatalogObjectRef::Statistic(qn("s", "x")).sql_keyword(),
233            "STATISTICS"
234        );
235        assert_eq!(
236            CatalogObjectRef::Collation(qn("s", "x")).sql_keyword(),
237            "COLLATION"
238        );
239    }
240
241    #[test]
242    fn render_target_each_shape() {
243        assert_eq!(
244            CatalogObjectRef::Table(qn("app", "users")).render_target(),
245            "app.users"
246        );
247        assert_eq!(
248            CatalogObjectRef::Schema(id("billing")).render_target(),
249            "billing"
250        );
251        assert_eq!(
252            CatalogObjectRef::Publication(id("my_pub")).render_target(),
253            "my_pub"
254        );
255        assert_eq!(
256            CatalogObjectRef::Function {
257                name: qn("app", "do_thing"),
258                signature: RoutineSignature::new("(integer, text)".to_string()),
259            }
260            .render_target(),
261            "app.do_thing(integer, text)"
262        );
263        assert_eq!(
264            CatalogObjectRef::Procedure {
265                name: qn("app", "do_work"),
266                signature: RoutineSignature::new("(integer)".to_string()),
267            }
268            .render_target(),
269            "app.do_work(integer)"
270        );
271    }
272
273    #[test]
274    fn observation_label_each_shape() {
275        assert_eq!(
276            CatalogObjectRef::Table(qn("app", "users")).observation_label(),
277            "table app.users"
278        );
279        assert_eq!(
280            CatalogObjectRef::MaterializedView(qn("app", "mv")).observation_label(),
281            "materialized view app.mv"
282        );
283        assert_eq!(
284            CatalogObjectRef::Schema(id("billing")).observation_label(),
285            "schema billing"
286        );
287        assert_eq!(
288            CatalogObjectRef::UserType(qn("app", "status")).observation_label(),
289            "type app.status"
290        );
291        assert_eq!(
292            CatalogObjectRef::Function {
293                name: qn("app", "do_thing"),
294                signature: RoutineSignature::new("(integer, text)".to_string()),
295            }
296            .observation_label(),
297            "function app.do_thing(integer, text)"
298        );
299    }
300
301    /// The observation label uses the identifier `Display` impl (raw, unquoted),
302    /// matching the pre-dedup inline `format!("type {qname}")` labels — *not*
303    /// the SQL-quoting `render_target`. A name needing quotes must NOT be quoted
304    /// in the observation label.
305    #[test]
306    fn observation_label_is_unquoted_unlike_render_target() {
307        // "select" is a reserved keyword → render_sql quotes it.
308        let q = qn("app", "select");
309        let obj = CatalogObjectRef::Table(q);
310        assert_eq!(obj.render_target(), "app.\"select\"");
311        assert_eq!(obj.observation_label(), "table app.select");
312    }
313}