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