Skip to main content

selene_graph/mutator/
composite_property_index.rs

1//! Composite property-index mutation methods for the transaction mutator.
2
3use selene_core::{Change, DbString, SchemaChange, SchemaPropertyIndexKind};
4use smallvec::SmallVec;
5
6use crate::graph::{CompositePropertyIndexEntry, composite_property_key};
7use crate::schema_index_kind::schema_kind_from;
8use crate::{GraphError, GraphResult, Mutator, TypedIndexKind};
9
10impl<'tx, 'g> Mutator<'tx, 'g> {
11    /// Register a durable node composite-property index with optional catalog name.
12    ///
13    /// # Errors
14    ///
15    /// Returns [`GraphError::CompositePropertyIndexAlreadyExists`] if the
16    /// canonical property set already exists.
17    pub fn create_composite_property_index_named(
18        &mut self,
19        label: DbString,
20        properties: SmallVec<[DbString; 4]>,
21        kinds: SmallVec<[TypedIndexKind; 4]>,
22        name: Option<DbString>,
23    ) -> GraphResult<()> {
24        validate_shape(&properties, &kinds)?;
25        let key = composite_property_key(&properties);
26        if self
27            .txn
28            .read()
29            .composite_property_index
30            .contains_key(&(label.clone(), key.clone()))
31        {
32            return Err(GraphError::CompositePropertyIndexAlreadyExists {
33                label,
34                properties: Box::new(properties),
35            });
36        }
37        let graph_id = self.txn.read().graph_id();
38        let index = crate::composite_property_index::build_composite_property_index(
39            self.txn.read(),
40            label.clone(),
41            properties.clone(),
42            kinds.clone(),
43        )?;
44        self.txn.guard_mut().composite_property_index.insert(
45            (label.clone(), key),
46            CompositePropertyIndexEntry::new(index, properties.clone(), name.clone()),
47        );
48        self.txn.changes.push(Change::SchemaChanged {
49            graph: graph_id,
50            change: SchemaChange::CompositePropertyIndexCreated {
51                label,
52                properties,
53                kinds: schema_kinds_from(&kinds),
54                name,
55            },
56        });
57        Ok(())
58    }
59
60    /// Drop a durable node composite-property index from the active write transaction.
61    ///
62    /// The operation is idempotent. Dropping an absent index succeeds and emits
63    /// no WAL change.
64    pub fn drop_composite_property_index(
65        &mut self,
66        label: DbString,
67        properties: SmallVec<[DbString; 4]>,
68    ) -> GraphResult<()> {
69        let key = composite_property_key(&properties);
70        if !self
71            .txn
72            .read()
73            .composite_property_index
74            .contains_key(&(label.clone(), key.clone()))
75        {
76            return Ok(());
77        }
78        let graph_id = self.txn.read().graph_id();
79        self.txn
80            .guard_mut()
81            .composite_property_index
82            .remove(&(label.clone(), key));
83        self.txn.changes.push(Change::SchemaChanged {
84            graph: graph_id,
85            change: SchemaChange::CompositePropertyIndexDropped { label, properties },
86        });
87        Ok(())
88    }
89}
90
91fn validate_shape(properties: &[DbString], kinds: &[TypedIndexKind]) -> Result<(), GraphError> {
92    if properties.len() < 2 {
93        return Err(GraphError::Inconsistent {
94            reason: "composite index requires at least two properties".to_owned(),
95        });
96    }
97    if properties.len() != kinds.len() {
98        return Err(GraphError::Inconsistent {
99            reason: format!(
100                "composite index has {} properties but {} kinds",
101                properties.len(),
102                kinds.len()
103            ),
104        });
105    }
106    let mut key = properties.to_vec();
107    key.sort();
108    key.dedup();
109    if key.len() != properties.len() {
110        return Err(GraphError::Inconsistent {
111            reason: "composite index property list contains duplicates".to_owned(),
112        });
113    }
114    Ok(())
115}
116
117fn schema_kinds_from(kinds: &[TypedIndexKind]) -> SmallVec<[SchemaPropertyIndexKind; 4]> {
118    kinds.iter().copied().map(schema_kind_from).collect()
119}
120
121#[cfg(test)]
122mod tests {
123    use selene_core::{GraphId, db_string};
124    use smallvec::smallvec;
125
126    use crate::{GraphError, SharedGraph, TypedIndexKind};
127
128    #[test]
129    fn create_composite_property_index_rejects_empty_property_list() {
130        let shared = SharedGraph::new(GraphId::new(140_201));
131        let mut txn = shared.begin_write();
132        let err = txn
133            .mutator()
134            .create_composite_property_index_named(
135                db_string("CompositeShape").unwrap(),
136                smallvec![],
137                smallvec![],
138                None,
139            )
140            .unwrap_err();
141
142        assert!(matches!(
143            err,
144            GraphError::Inconsistent { reason }
145                if reason == "composite index requires at least two properties"
146        ));
147    }
148
149    #[test]
150    fn create_composite_property_index_rejects_single_property_list() {
151        let shared = SharedGraph::new(GraphId::new(140_202));
152        let mut txn = shared.begin_write();
153        let err = txn
154            .mutator()
155            .create_composite_property_index_named(
156                db_string("CompositeShape").unwrap(),
157                smallvec![db_string("only").unwrap()],
158                smallvec![TypedIndexKind::I64],
159                None,
160            )
161            .unwrap_err();
162
163        assert!(matches!(
164            err,
165            GraphError::Inconsistent { reason }
166                if reason == "composite index requires at least two properties"
167        ));
168    }
169}