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::{GraphDb, Result, RuleDef, ViewDef};
38use core_storage::{fs::Fs, GraphError};
39use serde::{Deserialize, Serialize};
40
41// ---------------------------------------------------------------------------
42// Public types
43// ---------------------------------------------------------------------------
44
45/// A declarative description of the schema that should exist in a database.
46///
47/// All three lists default to empty when absent from JSON, so a partial schema
48/// that names only rules (for example) is valid.
49#[derive(Serialize, Deserialize, Clone, Default, Debug)]
50pub struct Schema {
51    /// Fulltext index declarations as `(label, field)` pairs.
52    #[serde(default)]
53    pub fulltext: Vec<(String, String)>,
54    /// Linking rule definitions.
55    #[serde(default)]
56    pub rules: Vec<RuleDef>,
57    /// Materialized view definitions.
58    #[serde(default)]
59    pub views: Vec<ViewDef>,
60}
61
62/// The outcome of a single [`GraphDb::apply_schema`] call.
63///
64/// Entry names are namespaced: `"rule:NAME"`, `"view:NAME"`,
65/// `"fulltext:LABEL.FIELD"`.
66#[derive(Debug, PartialEq)]
67pub struct SchemaDiff {
68    /// Items that did not exist and were created.
69    pub created: Vec<String>,
70    /// Items that existed but whose definition differed; replaced via
71    /// delete + create.
72    pub updated: Vec<String>,
73    /// Items that already matched the live database; no WAL writes made.
74    pub unchanged: Vec<String>,
75}
76
77// ---------------------------------------------------------------------------
78// apply_schema implementation
79// ---------------------------------------------------------------------------
80
81impl<F: Fs> GraphDb<F> {
82    /// Apply `schema` to the database idempotently.
83    ///
84    /// Returns a [`SchemaDiff`] describing what was created, updated, or left
85    /// unchanged.  The diff is in application order: fulltext, then views,
86    /// then rules.
87    ///
88    /// Items absent from `schema` but present in the database are left
89    /// untouched (no pruning).
90    ///
91    /// # Atomicity of validation
92    ///
93    /// All rules and views that would be created or updated are validated
94    /// **before** any mutation is made.  If any definition is invalid, the
95    /// function returns `Err` without touching the database.  This prevents
96    /// the partial-application hazard where the old item is already deleted
97    /// before the invalid replacement fails.
98    ///
99    /// # Update cost
100    ///
101    /// Updating a rule (definition differs) triggers `delete_rule` +
102    /// `create_rule`.  The `create_rule` call runs a full backfill of derived
103    /// edges.  This can be expensive for large graphs; prefer stable rule
104    /// definitions in production.
105    pub fn apply_schema(&mut self, schema: &Schema) -> Result<SchemaDiff> {
106        // Pre-validation pass: validate every rule and view that would be
107        // created or updated, before touching the database.  Unchanged items
108        // are already valid (they passed validation when first created).
109        let live_views = self.views();
110        let live_rules = self.rules();
111
112        for view_def in &schema.views {
113            let would_mutate = live_views
114                .iter()
115                .find(|v| v.name == view_def.name)
116                .is_none_or(|live| live != view_def);
117            if would_mutate {
118                view_def
119                    .validate()
120                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
121            }
122        }
123
124        for rule_def in &schema.rules {
125            let would_mutate = live_rules
126                .iter()
127                .find(|r| r.name == rule_def.name)
128                .is_none_or(|live| live != rule_def);
129            if would_mutate {
130                rule_def
131                    .validate()
132                    .map_err(|e| GraphError::RuleInvalid { detail: e })?;
133            }
134        }
135
136        // Mutation pass — all definitions are known-valid from here.
137        let mut created = Vec::new();
138        let mut updated = Vec::new();
139        let mut unchanged = Vec::new();
140
141        // 1. Fulltext indexes.
142        for (label, field) in &schema.fulltext {
143            let key = format!("fulltext:{label}.{field}");
144            if self.is_fulltext_enabled(label, field) {
145                unchanged.push(key);
146            } else {
147                self.enable_fulltext(label, field)?;
148                created.push(key);
149            }
150        }
151
152        // 2. Views.
153        for view_def in &schema.views {
154            let key = format!("view:{}", view_def.name);
155            if let Some(live) = live_views.iter().find(|v| v.name == view_def.name) {
156                if live == view_def {
157                    unchanged.push(key);
158                } else {
159                    // Delete + create to pick up the new definition.
160                    self.delete_view(&view_def.name)?;
161                    self.create_view(view_def.clone())?;
162                    updated.push(key);
163                }
164            } else {
165                self.create_view(view_def.clone())?;
166                created.push(key);
167            }
168        }
169
170        // 3. Rules — creating a rule triggers a full backfill (see module doc).
171        for rule_def in &schema.rules {
172            let key = format!("rule:{}", rule_def.name);
173            if let Some(live) = live_rules.iter().find(|r| r.name == rule_def.name) {
174                if live == rule_def {
175                    unchanged.push(key);
176                } else {
177                    // Delete + create; create_rule backfills all derived edges.
178                    self.delete_rule(&rule_def.name)?;
179                    self.create_rule(rule_def.clone())?;
180                    updated.push(key);
181                }
182            } else {
183                self.create_rule(rule_def.clone())?;
184                created.push(key);
185            }
186        }
187
188        Ok(SchemaDiff {
189            created,
190            updated,
191            unchanged,
192        })
193    }
194}