Skip to main content

core_api/
schema.rs

1//! Declarative schema-as-code for mushroomdb.
2//!
3//! [`Schema`] is a serde-JSON-round-trippable description of the fulltext
4//! indexes, materialized views, and linking rules that should exist in a
5//! database.  [`GraphDb::apply_schema`] applies the schema idempotently:
6//! items already matching the live database are left untouched (no WAL write),
7//! items that differ are replaced (delete + create), and items absent from the
8//! schema are left in place (no pruning — destructive removal is out of scope
9//! for this plan).
10//!
11//! # Application order
12//!
13//! 1. **Fulltext** indexes are applied first: rules may later benefit from
14//!    freshly-enabled fulltext state during backfill, even though the current
15//!    rule predicates do not require it.
16//! 2. **Views** are applied second: they are cheaper to backfill than rules
17//!    and logically independent of rules.
18//! 3. **Rules** are applied last.  Creating a rule triggers a full backfill
19//!    of derived edges, which can be expensive for large graphs — document the
20//!    cost at the call site.
21//!
22//! # Update semantics
23//!
24//! When a schema item (rule or view) exists in the database but its definition
25//! differs from the one in the schema, it is replaced via `delete_X` +
26//! `create_X`.  For rules, the `create_rule` call triggers a full backfill of
27//! derived edges — this can be expensive for large graphs.  The re-backfill
28//! cost is inherent to any definition change (the old edge set may not be
29//! valid under the new predicate), so there is no cheaper path in v1.
30//!
31//! # No pruning
32//!
33//! Items that live in the database but are absent from the schema are left
34//! untouched.  Destructive removal ("prune items not in the schema") waits for
35//! explicit demand — YAGNI for this plan.
36
37use crate::roles::RoleDef;
38use crate::{GraphDb, Result, RuleDef, ViewDef};
39use core_storage::{fs::Fs, GraphError};
40use serde::{Deserialize, Serialize};
41
42// ---------------------------------------------------------------------------
43// Public types
44// ---------------------------------------------------------------------------
45
46/// A declarative description of the schema that should exist in a database.
47///
48/// All lists default to empty when absent from JSON, so a partial schema
49/// that names only rules (for example) is valid.
50#[derive(Serialize, Deserialize, Clone, Default, Debug)]
51pub struct Schema {
52    /// Fulltext index declarations as `(label, field)` pairs.
53    #[serde(default)]
54    pub fulltext: Vec<(String, String)>,
55    /// Equality-index declarations as `(label, field)` pairs. Each enables an
56    /// exact-match index so `WHERE n.field = value` becomes an indexed lookup.
57    #[serde(default)]
58    pub indexes: Vec<(String, String)>,
59    /// Linking rule definitions.
60    #[serde(default)]
61    pub rules: Vec<RuleDef>,
62    /// Materialized view definitions.
63    #[serde(default)]
64    pub views: Vec<ViewDef>,
65    /// RBAC role definitions. Persisted as `roles.json` sidecar on apply.
66    #[serde(default)]
67    pub roles: Vec<RoleDef>,
68}
69
70/// The outcome of a single [`GraphDb::apply_schema`] call.
71///
72/// Entry names are namespaced: `"rule:NAME"`, `"view:NAME"`,
73/// `"fulltext:LABEL.FIELD"`.
74#[derive(Debug, PartialEq)]
75pub struct SchemaDiff {
76    /// Items that did not exist and were created.
77    pub created: Vec<String>,
78    /// Items that existed but whose definition differed; replaced via
79    /// delete + create.
80    pub updated: Vec<String>,
81    /// Items that already matched the live database; no WAL writes made.
82    pub unchanged: Vec<String>,
83}
84
85// ---------------------------------------------------------------------------
86// apply_schema implementation
87// ---------------------------------------------------------------------------
88
89impl<F: Fs> GraphDb<F> {
90    /// Apply `schema` to the database idempotently.
91    ///
92    /// Returns a [`SchemaDiff`] describing what was created, updated, or left
93    /// unchanged.  The diff is in application order: fulltext, then views,
94    /// then rules.
95    ///
96    /// Items absent from `schema` but present in the database are left
97    /// untouched (no pruning).
98    ///
99    /// # Atomicity of validation
100    ///
101    /// All rules and views that would be created or updated are validated
102    /// **before** any mutation is made.  If any definition is invalid, the
103    /// function returns `Err` without touching the database.  This prevents
104    /// the partial-application hazard where the old item is already deleted
105    /// before the invalid replacement fails.
106    ///
107    /// # Update cost
108    ///
109    /// Updating a rule (definition differs) triggers `delete_rule` +
110    /// `create_rule`.  The `create_rule` call runs a full backfill of derived
111    /// edges.  This can be expensive for large graphs; prefer stable rule
112    /// definitions in production.
113    pub fn apply_schema(&mut self, schema: &Schema) -> Result<SchemaDiff> {
114        // Pre-validation pass: validate every rule, view, and role that would be
115        // created or updated, before touching the database.  Unchanged items
116        // are already valid (they passed validation when first created).
117        let live_views = self.views();
118        let live_rules = self.rules();
119
120        // Reject duplicate names within the submitted schema before any mutation.
121        {
122            let mut seen_rules = std::collections::HashSet::new();
123            for rule_def in &schema.rules {
124                if !seen_rules.insert(rule_def.name.as_str()) {
125                    return Err(GraphError::RuleInvalid {
126                        detail: format!("duplicate rule name in schema: {}", rule_def.name),
127                    });
128                }
129            }
130            let mut seen_views = std::collections::HashSet::new();
131            for view_def in &schema.views {
132                if !seen_views.insert(view_def.name.as_str()) {
133                    return Err(GraphError::RuleInvalid {
134                        detail: format!("duplicate view name in schema: {}", view_def.name),
135                    });
136                }
137            }
138        }
139
140        for view_def in &schema.views {
141            let would_mutate = live_views
142                .iter()
143                .find(|v| v.name == view_def.name)
144                .is_none_or(|live| live != view_def);
145            if would_mutate {
146                view_def
147                    .validate()
148                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
149            }
150        }
151
152        for rule_def in &schema.rules {
153            let would_mutate = live_rules
154                .iter()
155                .find(|r| r.name == rule_def.name)
156                .is_none_or(|live| live != rule_def);
157            if would_mutate {
158                rule_def
159                    .validate()
160                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
161            }
162        }
163
164        // Validate roles: non-empty names, unique names within the schema,
165        // and write-scope subset rule (§7.1 ruling: create/update/delete_labels
166        // must each be a subset of the role's read labels).
167        {
168            let mut seen = std::collections::HashSet::new();
169            for role_def in &schema.roles {
170                if role_def.name.is_empty() {
171                    return Err(GraphError::RuleInvalid {
172                        detail: "role name must not be empty".into(),
173                    });
174                }
175                if !seen.insert(role_def.name.as_str()) {
176                    return Err(GraphError::RuleInvalid {
177                        detail: format!("duplicate role name: {}", role_def.name),
178                    });
179                }
180                if let Some(pred) = &role_def.visible_where {
181                    pred.validate().map_err(|e| GraphError::RuleInvalid {
182                        detail: format!("role '{}': {e}", role_def.name),
183                    })?;
184                    // A predicate narrows the label leg. With no labels there is
185                    // nothing to narrow, so the role would quietly be its `keys`
186                    // alone under a name that reads like a restriction.
187                    if role_def.labels.is_empty() {
188                        return Err(GraphError::RuleInvalid {
189                            detail: format!(
190                                "role '{}': visible_where narrows the labels leg and the role \
191                                 declares no labels",
192                                role_def.name
193                            ),
194                        });
195                    }
196                }
197                if let Some(list) = &role_def.namespaces {
198                    if list.is_empty() {
199                        return Err(GraphError::RuleInvalid {
200                            detail: format!(
201                                "role '{}': namespaces: [] would make the role see nothing; \
202                                 omit keys and labels instead",
203                                role_def.name
204                            ),
205                        });
206                    }
207                    for name in list {
208                        if !core_storage::valid_namespace(name) {
209                            return Err(GraphError::RuleInvalid {
210                                detail: format!(
211                                    "role '{}': {name:?} is not a valid namespace name — 1 to \
212                                     {} characters of [A-Za-z0-9_.-]",
213                                    role_def.name,
214                                    core_storage::NS_MAX_LEN
215                                ),
216                            });
217                        }
218                    }
219                    // The namespace leg intersects `keys`, so a key naming a
220                    // live node in another namespace would silently resolve to
221                    // nothing. Loud instead: a key list that disagrees with the
222                    // namespace binding is a mistake, not a grant. A key naming
223                    // no live node is still ignored, as it is without a binding.
224                    for key in &role_def.keys {
225                        if let Some(key_ns) = self.namespace_of(key) {
226                            if !role_def.sees_namespace(&key_ns) {
227                                return Err(GraphError::RuleInvalid {
228                                    detail: format!(
229                                        "role '{}': key '{key}' is in namespace '{key_ns}', \
230                                         which is outside the role's namespaces [{}]",
231                                        role_def.name,
232                                        list.join(", ")
233                                    ),
234                                });
235                            }
236                        }
237                    }
238                }
239                if let Some(write) = &role_def.write {
240                    let read_labels: std::collections::HashSet<&str> =
241                        role_def.labels.iter().map(String::as_str).collect();
242                    // Check create_labels, update_labels, delete_labels — each must
243                    // be a subset of the role's read labels (§7.1 subset ruling).
244                    // create_edge_types and delete_edge_types are exempt (no read-scope
245                    // analog for edge types).
246                    for (field, labels) in [
247                        ("create_labels", &write.create_labels),
248                        ("update_labels", &write.update_labels),
249                        ("delete_labels", &write.delete_labels),
250                    ] {
251                        for label in labels {
252                            if !read_labels.contains(label.as_str()) {
253                                return Err(GraphError::RuleInvalid {
254                                    detail: format!(
255                                        "role '{}': write scope {field} contains label '{}' \
256                                         that is not in the role's read labels (subset rule)",
257                                        role_def.name, label
258                                    ),
259                                });
260                            }
261                        }
262                    }
263                }
264            }
265        }
266
267        // Mutation pass — all definitions are known-valid from here.
268        let mut created = Vec::new();
269        let mut updated = Vec::new();
270        let mut unchanged = Vec::new();
271
272        // 1. Fulltext indexes.
273        for (label, field) in &schema.fulltext {
274            let key = format!("fulltext:{label}.{field}");
275            if self.is_fulltext_enabled(label, field) {
276                unchanged.push(key);
277            } else {
278                self.enable_fulltext(label, field)?;
279                created.push(key);
280            }
281        }
282
283        // 1b. Equality (property) indexes.
284        for (label, field) in &schema.indexes {
285            let key = format!("index:{label}.{field}");
286            if self.is_index_enabled(label, field) {
287                unchanged.push(key);
288            } else {
289                self.enable_index(label, field)?;
290                created.push(key);
291            }
292        }
293
294        // 2. Views.
295        for view_def in &schema.views {
296            let key = format!("view:{}", view_def.name);
297            if let Some(live) = live_views.iter().find(|v| v.name == view_def.name) {
298                if live == view_def {
299                    unchanged.push(key);
300                } else {
301                    // Delete + create to pick up the new definition.
302                    self.delete_view(&view_def.name)?;
303                    self.create_view(view_def.clone())?;
304                    updated.push(key);
305                }
306            } else {
307                self.create_view(view_def.clone())?;
308                created.push(key);
309            }
310        }
311
312        // 3. Rules — creating a rule triggers a full backfill (see module doc).
313        for rule_def in &schema.rules {
314            let key = format!("rule:{}", rule_def.name);
315            if let Some(live) = live_rules.iter().find(|r| r.name == rule_def.name) {
316                if live == rule_def {
317                    unchanged.push(key);
318                } else {
319                    // Delete + create; create_rule backfills all derived edges.
320                    self.delete_rule(&rule_def.name)?;
321                    self.create_rule(rule_def.clone())?;
322                    updated.push(key);
323                }
324            } else {
325                self.create_rule(rule_def.clone())?;
326                created.push(key);
327            }
328        }
329
330        // 4. Roles — sidecar only (no WAL records). Written atomically when
331        // any role is new or changed; unchanged roles leave the file untouched
332        // (byte-identical idempotency guarantee).
333        {
334            let live_roles = self.roles();
335            let mut new_roles: Vec<RoleDef> = live_roles.clone();
336            let mut roles_changed = false;
337
338            for role_def in &schema.roles {
339                let key = format!("role:{}", role_def.name);
340                if let Some(live) = live_roles.iter().find(|r| r.name == role_def.name) {
341                    if live == role_def {
342                        unchanged.push(key);
343                    } else {
344                        // Update the entry in new_roles.
345                        if let Some(slot) = new_roles.iter_mut().find(|r| r.name == role_def.name) {
346                            *slot = role_def.clone();
347                        }
348                        roles_changed = true;
349                        updated.push(key);
350                    }
351                } else {
352                    new_roles.push(role_def.clone());
353                    roles_changed = true;
354                    created.push(key);
355                }
356            }
357
358            if roles_changed {
359                self.commit_roles(new_roles)?;
360            }
361        }
362
363        Ok(SchemaDiff {
364            created,
365            updated,
366            unchanged,
367        })
368    }
369}