Skip to main content

pgevolve_core/ir/
index.rs

1//! `Index` and related types.
2
3use serde::{Deserialize, Serialize};
4
5use crate::identifier::{Identifier, QualifiedName};
6use crate::ir::default_expr::NormalizedExpr;
7use crate::ir::difference::Difference;
8use crate::ir::eq::{Equiv, field_difference};
9
10/// The parent object of an [`Index`]: either a table or a materialized view.
11///
12/// Introduced in v0.2 to support MV indexes alongside table indexes.
13#[derive(Clone, Debug, Eq, PartialEq, Hash, Serialize, Deserialize)]
14#[serde(tag = "kind", rename_all = "snake_case")]
15pub enum IndexParent {
16    /// The index targets a regular table.
17    Table(QualifiedName),
18    /// The index targets a materialized view.
19    Mv(QualifiedName),
20}
21
22impl IndexParent {
23    /// The qualified name of the parent (table or MV).
24    pub const fn qname(&self) -> &QualifiedName {
25        match self {
26            Self::Table(q) | Self::Mv(q) => q,
27        }
28    }
29
30    /// True if the parent is a materialized view.
31    pub const fn is_mv(&self) -> bool {
32        matches!(self, Self::Mv(_))
33    }
34}
35
36/// A Postgres index.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38pub struct Index {
39    /// Schema-qualified index name.
40    pub qname: QualifiedName,
41    /// The parent table or materialized view this index is defined on.
42    pub on: IndexParent,
43    /// Index access method.
44    pub method: IndexMethod,
45    /// Indexed columns / expressions; order is significant.
46    pub columns: Vec<IndexColumn>,
47    /// `INCLUDE (cols)` covering columns.
48    pub include: Vec<Identifier>,
49    /// True for `UNIQUE` indexes.
50    pub unique: bool,
51    /// PG 15+ `NULLS NOT DISTINCT`.
52    pub nulls_not_distinct: bool,
53    /// Optional partial-index predicate.
54    pub predicate: Option<NormalizedExpr>,
55    /// Optional tablespace.
56    pub tablespace: Option<Identifier>,
57    /// Optional comment.
58    pub comment: Option<String>,
59    /// Storage parameters (`WITH (fillfactor = …, fastupdate = …, …)`).
60    /// Valid keys depend on `method`; parser enforces per-AM ranges.
61    pub storage: crate::ir::reloptions::IndexStorageOptions,
62}
63
64impl Equiv for Index {
65    fn differences(&self, other: &Self) -> Vec<Difference> {
66        let Self {
67            qname: _,
68            on: _,
69            method: _,
70            columns: _,
71            include: _,
72            unique: _,
73            nulls_not_distinct: _,
74            predicate: _,
75            tablespace: _,
76            comment: _,
77            storage: _,
78        } = self;
79        let mut out = Vec::new();
80        out.extend(field_difference("qname", &self.qname, &other.qname));
81        out.extend(field_difference(
82            "on",
83            &format!("{:?}", self.on),
84            &format!("{:?}", other.on),
85        ));
86        out.extend(field_difference(
87            "method",
88            &format!("{:?}", self.method),
89            &format!("{:?}", other.method),
90        ));
91        out.extend(field_difference(
92            "columns",
93            &format!("{:?}", self.columns),
94            &format!("{:?}", other.columns),
95        ));
96        out.extend(field_difference(
97            "include",
98            &format!("{:?}", self.include),
99            &format!("{:?}", other.include),
100        ));
101        out.extend(field_difference("unique", &self.unique, &other.unique));
102        out.extend(field_difference(
103            "nulls_not_distinct",
104            &self.nulls_not_distinct,
105            &other.nulls_not_distinct,
106        ));
107        out.extend(field_difference(
108            "predicate",
109            &format!("{:?}", self.predicate),
110            &format!("{:?}", other.predicate),
111        ));
112        out.extend(field_difference(
113            "tablespace",
114            &format!("{:?}", self.tablespace),
115            &format!("{:?}", other.tablespace),
116        ));
117        out.extend(field_difference(
118            "comment",
119            &format!("{:?}", self.comment),
120            &format!("{:?}", other.comment),
121        ));
122        out.extend(field_difference(
123            "storage",
124            &format!("{:?}", self.storage),
125            &format!("{:?}", other.storage),
126        ));
127        out
128    }
129}
130
131impl Index {
132    /// Returns `true` if `self` and `other` are equal in every field **except**
133    /// `storage` (reloptions). Used by the differ to distinguish a
134    /// storage-only change (→ `ALTER INDEX SET`) from a structural change
135    /// (→ `DROP + CREATE`).
136    #[must_use]
137    pub fn structurally_eq(&self, other: &Self) -> bool {
138        self.qname == other.qname
139            && self.on == other.on
140            && self.method == other.method
141            && self.columns == other.columns
142            && self.include == other.include
143            && self.unique == other.unique
144            && self.nulls_not_distinct == other.nulls_not_distinct
145            && self.predicate == other.predicate
146            && self.tablespace == other.tablespace
147            && self.comment == other.comment
148        // `storage` is intentionally excluded.
149    }
150}
151
152/// One indexed column or expression.
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
154pub struct IndexColumn {
155    /// Column name or expression.
156    pub expr: IndexColumnExpr,
157    /// Optional collation.
158    pub collation: Option<QualifiedName>,
159    /// Optional operator class.
160    pub opclass: Option<QualifiedName>,
161    /// `ASC` or `DESC`.
162    pub sort_order: SortOrder,
163    /// `NULLS FIRST` or `NULLS LAST`.
164    pub nulls_order: NullsOrder,
165}
166
167/// Either a bare column name or an expression.
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(rename_all = "snake_case")]
170pub enum IndexColumnExpr {
171    /// Bare column reference.
172    Column(Identifier),
173    /// Computed expression.
174    Expression(NormalizedExpr),
175}
176
177/// Index access method.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
179#[serde(rename_all = "snake_case")]
180pub enum IndexMethod {
181    /// `btree`.
182    BTree,
183    /// `hash`.
184    Hash,
185    /// `gin`.
186    Gin,
187    /// `gist`.
188    Gist,
189    /// `brin`.
190    Brin,
191    /// `spgist`.
192    Spgist,
193}
194
195/// Sort direction.
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
197#[serde(rename_all = "snake_case")]
198pub enum SortOrder {
199    /// `ASC` (default for B-tree).
200    Asc,
201    /// `DESC`.
202    Desc,
203}
204
205/// Null ordering within an index.
206#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
207#[serde(rename_all = "snake_case")]
208pub enum NullsOrder {
209    /// `NULLS FIRST`.
210    NullsFirst,
211    /// `NULLS LAST`.
212    NullsLast,
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use crate::ir::eq::Equiv;
219
220    fn id(s: &str) -> Identifier {
221        Identifier::from_unquoted(s).unwrap()
222    }
223
224    fn qn(schema: &str, name: &str) -> QualifiedName {
225        QualifiedName::new(id(schema), id(name))
226    }
227
228    fn col(name: &str) -> IndexColumn {
229        IndexColumn {
230            expr: IndexColumnExpr::Column(id(name)),
231            collation: None,
232            opclass: None,
233            sort_order: SortOrder::Asc,
234            nulls_order: NullsOrder::NullsLast,
235        }
236    }
237
238    fn base() -> Index {
239        Index {
240            qname: qn("app", "users_email_idx"),
241            on: IndexParent::Table(qn("app", "users")),
242            method: IndexMethod::BTree,
243            columns: vec![col("email")],
244            include: vec![],
245            unique: true,
246            nulls_not_distinct: false,
247            predicate: None,
248            tablespace: None,
249            comment: None,
250            storage: crate::ir::reloptions::IndexStorageOptions::default(),
251        }
252    }
253
254    #[test]
255    fn equal_indexes_have_no_diff() {
256        assert!(base().canonical_eq(&base()));
257    }
258
259    #[test]
260    fn unique_change_diffs() {
261        let mut b = base();
262        b.unique = false;
263        assert!(base().differences(&b).iter().any(|x| x.path == "unique"));
264    }
265
266    #[test]
267    fn include_columns_diff() {
268        let mut b = base();
269        b.include = vec![id("name")];
270        assert!(base().differences(&b).iter().any(|x| x.path == "include"));
271    }
272
273    #[test]
274    fn predicate_change_diffs() {
275        let mut b = base();
276        b.predicate = Some(NormalizedExpr::from_text("deleted_at is null"));
277        assert!(base().differences(&b).iter().any(|x| x.path == "predicate"));
278    }
279
280    #[test]
281    fn opclass_change_diffs() {
282        let mut b = base();
283        b.columns[0].opclass = Some(qn("pg_catalog", "text_pattern_ops"));
284        assert!(base().differences(&b).iter().any(|x| x.path == "columns"));
285    }
286
287    #[test]
288    fn column_order_matters() {
289        let a = Index {
290            columns: vec![col("a"), col("b")],
291            ..base()
292        };
293        let b = Index {
294            columns: vec![col("b"), col("a")],
295            ..base()
296        };
297        assert!(!a.canonical_eq(&b));
298    }
299
300    #[test]
301    fn storage_change_diffs() {
302        let mut b = base();
303        b.storage = crate::ir::reloptions::IndexStorageOptions {
304            fillfactor: Some(70),
305            ..Default::default()
306        };
307        assert!(base().differences(&b).iter().any(|x| x.path == "storage"));
308    }
309
310    #[test]
311    fn index_can_target_a_materialized_view() {
312        let mv_idx = Index {
313            qname: qn("app", "mv_email_idx"),
314            on: IndexParent::Mv(qn("app", "users_mv")),
315            method: IndexMethod::BTree,
316            columns: vec![col("email")],
317            include: vec![],
318            unique: true,
319            nulls_not_distinct: false,
320            predicate: None,
321            tablespace: None,
322            comment: None,
323            storage: crate::ir::reloptions::IndexStorageOptions::default(),
324        };
325        assert!(mv_idx.on.is_mv());
326        assert_eq!(mv_idx.on.qname().to_string(), "app.users_mv");
327        assert!(mv_idx.canonical_eq(&mv_idx));
328
329        // A table-parent index does not report is_mv.
330        let tbl_idx = base();
331        assert!(!tbl_idx.on.is_mv());
332        assert_eq!(tbl_idx.on.qname().to_string(), "app.users");
333
334        // An MV-parent index differs from a Table-parent index.
335        let tbl_idx_same_name = Index {
336            qname: qn("app", "mv_email_idx"),
337            on: IndexParent::Table(qn("app", "users_mv")),
338            ..mv_idx.clone()
339        };
340        assert!(!mv_idx.canonical_eq(&tbl_idx_same_name));
341        let diffs = mv_idx.differences(&tbl_idx_same_name);
342        assert!(diffs.iter().any(|d| d.path == "on"));
343    }
344}