Skip to main content

uqa_graph/
sqlite_store.rs

1//
2// Unified Query Algebra
3//
4// Copyright (c) 2023-2026 Cognica, Inc.
5//
6
7//! `SQLite`-backed graph store with write-through persistence.
8//!
9//! A [`MemoryGraphStore`] serves
10//! the in-memory query path. Each fallible mutation is first applied to a
11//! candidate snapshot, persisted atomically in one `SQLite` savepoint, and
12//! published to memory only after the savepoint commits. Reopened catalogs
13//! therefore replay the same vertex, edge, membership, and label state.
14//!
15//! Tables (per optional `table_name` qualifier):
16//!
17//! ```sql
18//! CREATE TABLE _graph_vertices_{tbl} (
19//!     vertex_id        INTEGER PRIMARY KEY,
20//!     label            TEXT NOT NULL DEFAULT '',
21//!     properties_json  TEXT NOT NULL
22//! );
23//! CREATE TABLE _graph_edges_{tbl} (
24//!     edge_id          INTEGER PRIMARY KEY,
25//!     source_id        INTEGER NOT NULL,
26//!     target_id        INTEGER NOT NULL,
27//!     label            TEXT NOT NULL,
28//!     properties_json  TEXT NOT NULL
29//! );
30//! CREATE TABLE _graph_membership_{tbl} (
31//!     graph        TEXT NOT NULL,
32//!     entity_kind  TEXT NOT NULL CHECK (entity_kind IN ('v', 'e')),
33//!     entity_id    INTEGER NOT NULL,
34//!     PRIMARY KEY (graph, entity_kind, entity_id)
35//! );
36//! CREATE TABLE _graph_catalog_{tbl} (
37//!     name TEXT PRIMARY KEY
38//! );
39//! ```
40
41use std::collections::{BTreeMap, BTreeSet};
42
43use rusqlite::params;
44use uqa_core::{Edge, EdgeId, Value, Vertex, VertexId};
45use uqa_storage::{ManagedConnection, SQLiteError};
46
47use crate::memory_store::MemoryGraphStore;
48use crate::store::{GraphStore, GraphStoreError, GraphStoreResult};
49use crate::types::Direction;
50
51const LEGACY_PROPERTIES_FORMAT: i64 = 1;
52const TAGGED_PROPERTIES_FORMAT: i64 = 2;
53
54pub struct SQLiteGraphStore {
55    inner: MemoryGraphStore,
56    conn: ManagedConnection,
57    vtx_table: String,
58    edge_table: String,
59    member_table: String,
60    catalog_table: String,
61}
62
63fn graph_store_error(error: &GraphStoreError) -> SQLiteError {
64    SQLiteError::StorageBackend(error.to_string())
65}
66
67impl SQLiteGraphStore {
68    /// Open (or create) the per-table graph tables on `conn` and
69    /// rehydrate the in-memory store from any existing rows.
70    pub fn open(conn: ManagedConnection, table_name: Option<&str>) -> Result<Self, SQLiteError> {
71        let suffix = table_name.unwrap_or("");
72        if !suffix
73            .chars()
74            .all(|character| character.is_ascii_alphanumeric() || character == '_')
75        {
76            return Err(SQLiteError::StorageBackend(format!(
77                "invalid graph table suffix {suffix:?}"
78            )));
79        }
80        let (vtx, edg, mem, cat) = if suffix.is_empty() {
81            (
82                "_graph_vertices".to_string(),
83                "_graph_edges".to_string(),
84                "_graph_membership".to_string(),
85                "_graph_catalog".to_string(),
86            )
87        } else {
88            (
89                format!("_graph_vertices_{suffix}"),
90                format!("_graph_edges_{suffix}"),
91                format!("_graph_membership_{suffix}"),
92                format!("_graph_catalog_{suffix}"),
93            )
94        };
95
96        let mut store = Self {
97            inner: MemoryGraphStore::new(),
98            conn,
99            vtx_table: vtx,
100            edge_table: edg,
101            member_table: mem,
102            catalog_table: cat,
103        };
104        store.ensure_tables()?;
105        store.load_from_sqlite()?;
106        Ok(store)
107    }
108
109    fn ensure_tables(&self) -> Result<(), SQLiteError> {
110        let v = &self.vtx_table;
111        let e = &self.edge_table;
112        let m = &self.member_table;
113        let c = &self.catalog_table;
114        self.conn.with(|conn| {
115            conn.execute_batch(&format!(
116                r#"
117                CREATE TABLE IF NOT EXISTS "{v}" (
118                    vertex_id INTEGER PRIMARY KEY,
119                    label TEXT NOT NULL DEFAULT '',
120                    properties_json TEXT NOT NULL,
121                    properties_format INTEGER NOT NULL DEFAULT 2
122                        CHECK (properties_format IN (1, 2))
123                );
124                CREATE TABLE IF NOT EXISTS "{e}" (
125                    edge_id INTEGER PRIMARY KEY,
126                    source_id INTEGER NOT NULL,
127                    target_id INTEGER NOT NULL,
128                    label TEXT NOT NULL,
129                    properties_json TEXT NOT NULL,
130                    properties_format INTEGER NOT NULL DEFAULT 2
131                        CHECK (properties_format IN (1, 2))
132                );
133                CREATE TABLE IF NOT EXISTS "{m}" (
134                    graph TEXT NOT NULL,
135                    entity_kind TEXT NOT NULL CHECK (entity_kind IN ('v', 'e')),
136                    entity_id INTEGER NOT NULL,
137                    PRIMARY KEY (graph, entity_kind, entity_id)
138                );
139                CREATE TABLE IF NOT EXISTS "{c}" (
140                    name TEXT PRIMARY KEY,
141                    registry_json TEXT NOT NULL DEFAULT '{{}}'
142                );
143                CREATE INDEX IF NOT EXISTS "{e}_source_idx" ON "{e}" (source_id);
144                CREATE INDEX IF NOT EXISTS "{e}_target_idx" ON "{e}" (target_id);
145                CREATE INDEX IF NOT EXISTS "{m}_entity_idx" ON "{m}" (entity_kind, entity_id);
146                "#
147            ))?;
148            let mut columns = conn.prepare(&format!("PRAGMA table_info(\"{c}\")"))?;
149            let has_registry = columns
150                .query_map([], |row| row.get::<_, String>(1))?
151                .collect::<Result<Vec<_>, _>>()?
152                .iter()
153                .any(|column| column == "registry_json");
154            if !has_registry {
155                conn.execute(
156                    &format!(
157                        "ALTER TABLE \"{c}\" ADD COLUMN registry_json TEXT NOT NULL DEFAULT '{{}}'"
158                    ),
159                    [],
160                )?;
161            }
162            for table in [&v, &e] {
163                let mut columns = conn.prepare(&format!("PRAGMA table_info(\"{table}\")"))?;
164                let has_properties_format = columns
165                    .query_map([], |row| row.get::<_, String>(1))?
166                    .collect::<Result<Vec<_>, _>>()?
167                    .iter()
168                    .any(|column| column == "properties_format");
169                if !has_properties_format {
170                    // Rows written before the tagged Value encoding used a
171                    // raw JSON byte array. Mark those existing records as
172                    // legacy; every engine write below explicitly stores v2.
173                    conn.execute(
174                        &format!(
175                            "ALTER TABLE \"{table}\" ADD COLUMN properties_format \
176                             INTEGER NOT NULL DEFAULT {LEGACY_PROPERTIES_FORMAT} \
177                             CHECK (properties_format IN (1, 2))"
178                        ),
179                        [],
180                    )?;
181                }
182            }
183            Ok(())
184        })
185    }
186
187    #[expect(
188        clippy::too_many_lines,
189        reason = "one read transaction reconstructs a mutually consistent graph snapshot"
190    )]
191    fn load_from_sqlite(&mut self) -> Result<(), SQLiteError> {
192        let v = self.vtx_table.clone();
193        let e = self.edge_table.clone();
194        let m = self.member_table.clone();
195        let c = self.catalog_table.clone();
196        self.conn.with(|conn| {
197            // Catalog: every named graph
198            let mut stmt = conn.prepare(&format!("SELECT name, registry_json FROM \"{c}\""))?;
199            let graphs: Vec<(String, String)> = stmt
200                .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
201                .collect::<Result<_, _>>()?;
202            for (name, registry_json) in &graphs {
203                self.inner.create_graph(name);
204                let registry = serde_json::from_str(registry_json).map_err(SQLiteError::from)?;
205                self.inner.import_label_registry(name, &registry);
206            }
207
208            // Vertices
209            let mut stmt = conn.prepare(&format!(
210                "SELECT vertex_id, label, properties_json, properties_format FROM \"{v}\""
211            ))?;
212            for row in stmt.query_map([], |r| {
213                Ok((
214                    r.get::<_, i64>(0)?,
215                    r.get::<_, String>(1)?,
216                    r.get::<_, String>(2)?,
217                    r.get::<_, i64>(3)?,
218                ))
219            })? {
220                let (vid, label, props_json, properties_format) = row?;
221                let properties = decode_properties(&props_json, properties_format)?;
222                let vertex = Vertex {
223                    vertex_id: decode_graph_id("vertex", vid)?,
224                    label,
225                    properties,
226                };
227                // Insert into the inner store. Membership is restored
228                // separately so the vertex initially lives in graph 0
229                // and we re-membership it below.
230                self.inner
231                    .insert_raw_vertex(vertex)
232                    .map_err(|error| SQLiteError::StorageBackend(error.to_string()))?;
233            }
234
235            // Edges
236            let mut stmt = conn.prepare(&format!(
237                "SELECT edge_id, source_id, target_id, label, properties_json, \
238                 properties_format FROM \"{e}\""
239            ))?;
240            for row in stmt.query_map([], |r| {
241                Ok((
242                    r.get::<_, i64>(0)?,
243                    r.get::<_, i64>(1)?,
244                    r.get::<_, i64>(2)?,
245                    r.get::<_, String>(3)?,
246                    r.get::<_, String>(4)?,
247                    r.get::<_, i64>(5)?,
248                ))
249            })? {
250                let (eid, src, tgt, label, props_json, properties_format) = row?;
251                let properties = decode_properties(&props_json, properties_format)?;
252                let edge = Edge {
253                    edge_id: decode_graph_id("edge", eid)?,
254                    source_id: decode_graph_id("edge source", src)?,
255                    target_id: decode_graph_id("edge target", tgt)?,
256                    label,
257                    properties,
258                };
259                self.inner
260                    .insert_raw_edge(edge)
261                    .map_err(|error| SQLiteError::StorageBackend(error.to_string()))?;
262            }
263
264            // Membership
265            let mut stmt = conn.prepare(&format!(
266                "SELECT graph, entity_kind, entity_id FROM \"{m}\""
267            ))?;
268            let memberships = stmt
269                .query_map([], |r| {
270                    Ok((
271                        r.get::<_, String>(0)?,
272                        r.get::<_, String>(1)?,
273                        r.get::<_, i64>(2)?,
274                    ))
275                })?
276                .collect::<Result<Vec<_>, _>>()?;
277            let mut decoded_memberships = Vec::with_capacity(memberships.len());
278            for (graph, kind, id) in memberships {
279                if !self.inner.has_graph(&graph) {
280                    return Err(SQLiteError::StorageBackend(format!(
281                        "membership references unknown graph {graph:?}"
282                    )));
283                }
284                let id = decode_graph_id("membership", id)?;
285                match kind.as_str() {
286                    "v" if self.inner.get_vertex(id).is_some() => {}
287                    "v" => {
288                        return Err(SQLiteError::StorageBackend(format!(
289                            "graph {graph:?} references missing vertex {id}"
290                        )));
291                    }
292                    "e" if self.inner.get_edge(id).is_some() => {}
293                    "e" => {
294                        return Err(SQLiteError::StorageBackend(format!(
295                            "graph {graph:?} references missing edge {id}"
296                        )));
297                    }
298                    _ => {
299                        return Err(SQLiteError::StorageBackend(format!(
300                            "invalid graph membership kind {kind:?}"
301                        )));
302                    }
303                }
304                decoded_memberships.push((graph, kind, id));
305            }
306            // SQLite does not promise row order without ORDER BY. Restore
307            // every vertex membership first so edge attachment can enforce
308            // that both endpoints belong to the same graph partition.
309            for (graph, kind, id) in &decoded_memberships {
310                if kind == "v" {
311                    self.inner
312                        .attach_vertex(*id, graph)
313                        .map_err(|error| SQLiteError::StorageBackend(error.to_string()))?;
314                }
315            }
316            for (graph, kind, id) in &decoded_memberships {
317                if kind == "e" {
318                    self.inner
319                        .attach_edge(*id, graph)
320                        .map_err(|error| SQLiteError::StorageBackend(error.to_string()))?;
321                }
322            }
323            for (name, _) in &graphs {
324                self.inner.rebuild_label_registry_from_ids(name);
325            }
326            Ok(())
327        })
328    }
329
330    /// Persist a complete, internally consistent graph snapshot in one
331    /// savepoint. The in-memory candidate is published only after this
332    /// transaction commits, so a storage failure cannot create a state that
333    /// appears successful until the next reopen.
334    #[expect(
335        clippy::too_many_lines,
336        reason = "one savepoint writes every graph registry before memory publication"
337    )]
338    fn persist_snapshot(&self, snapshot: &MemoryGraphStore) -> Result<(), SQLiteError> {
339        let graph_rows: Vec<(String, String)> = snapshot
340            .graph_names()
341            .into_iter()
342            .map(|name| {
343                let registry = serde_json::to_string(&snapshot.label_registry(&name))?;
344                Ok((name, registry))
345            })
346            .collect::<Result<_, SQLiteError>>()?;
347        let vertex_rows: Vec<(i64, String, String)> = snapshot
348            .vertices()
349            .into_values()
350            .map(|vertex| {
351                Ok((
352                    encode_graph_id("vertex", vertex.vertex_id)?,
353                    vertex.label,
354                    serde_json::to_string(&vertex.properties)?,
355                ))
356            })
357            .collect::<Result<_, SQLiteError>>()?;
358        let edge_rows: Vec<(i64, i64, i64, String, String)> = snapshot
359            .edges()
360            .into_values()
361            .map(|edge| {
362                Ok((
363                    encode_graph_id("edge", edge.edge_id)?,
364                    encode_graph_id("edge source", edge.source_id)?,
365                    encode_graph_id("edge target", edge.target_id)?,
366                    edge.label,
367                    serde_json::to_string(&edge.properties)?,
368                ))
369            })
370            .collect::<Result<_, SQLiteError>>()?;
371        let mut memberships = Vec::new();
372        for (graph, _) in &graph_rows {
373            for vertex_id in snapshot
374                .vertex_ids_in_graph(graph)
375                .map_err(|error| graph_store_error(&error))?
376            {
377                memberships.push((
378                    graph.clone(),
379                    "v",
380                    encode_graph_id("vertex membership", vertex_id)?,
381                ));
382            }
383            for edge_id in snapshot
384                .out_edge_ids_for_graph(graph)
385                .map_err(|error| SQLiteError::StorageBackend(error.to_string()))?
386            {
387                memberships.push((
388                    graph.clone(),
389                    "e",
390                    encode_graph_id("edge membership", edge_id)?,
391                ));
392            }
393        }
394
395        let vertex_table = self.vtx_table.clone();
396        let edge_table = self.edge_table.clone();
397        let member_table = self.member_table.clone();
398        let catalog_table = self.catalog_table.clone();
399        self.conn.with_mut(|connection| {
400            let savepoint = connection.savepoint()?;
401            savepoint.execute(&format!("DELETE FROM \"{member_table}\""), [])?;
402            savepoint.execute(&format!("DELETE FROM \"{catalog_table}\""), [])?;
403            savepoint.execute(&format!("DELETE FROM \"{edge_table}\""), [])?;
404            savepoint.execute(&format!("DELETE FROM \"{vertex_table}\""), [])?;
405            for (name, registry_json) in &graph_rows {
406                savepoint.execute(
407                    &format!(
408                        "INSERT INTO \"{catalog_table}\" (name, registry_json) VALUES (?1, ?2)"
409                    ),
410                    params![name, registry_json],
411                )?;
412            }
413            for (vertex_id, label, properties_json) in &vertex_rows {
414                savepoint.execute(
415                    &format!(
416                        "INSERT INTO \"{vertex_table}\" \
417                         (vertex_id, label, properties_json, properties_format) \
418                         VALUES (?1, ?2, ?3, ?4)"
419                    ),
420                    params![
421                        vertex_id,
422                        label,
423                        properties_json,
424                        TAGGED_PROPERTIES_FORMAT
425                    ],
426                )?;
427            }
428            for (edge_id, source_id, target_id, label, properties_json) in &edge_rows {
429                savepoint.execute(
430                    &format!(
431                        "INSERT INTO \"{edge_table}\" \
432                         (edge_id, source_id, target_id, label, properties_json, properties_format) \
433                         VALUES (?1, ?2, ?3, ?4, ?5, ?6)"
434                    ),
435                    params![
436                        edge_id,
437                        source_id,
438                        target_id,
439                        label,
440                        properties_json,
441                        TAGGED_PROPERTIES_FORMAT
442                    ],
443                )?;
444            }
445            for (graph, kind, entity_id) in &memberships {
446                savepoint.execute(
447                    &format!(
448                        "INSERT INTO \"{member_table}\" \
449                         (graph, entity_kind, entity_id) VALUES (?1, ?2, ?3)"
450                    ),
451                    params![graph, kind, entity_id],
452                )?;
453            }
454            savepoint.commit()?;
455            Ok(())
456        })
457    }
458
459    fn apply_mutation(
460        &mut self,
461        mutation: impl FnOnce(&mut MemoryGraphStore) -> GraphStoreResult<()>,
462    ) -> Result<(), SQLiteError> {
463        let mut candidate = self.inner.clone();
464        mutation(&mut candidate).map_err(|error| graph_store_error(&error))?;
465        self.persist_snapshot(&candidate)?;
466        self.inner = candidate;
467        Ok(())
468    }
469
470    fn require_graph(&self, graph: &str) -> Result<(), SQLiteError> {
471        if self.inner.has_graph(graph) {
472            Ok(())
473        } else {
474            Err(SQLiteError::StorageBackend(format!(
475                "unknown graph {graph:?}"
476            )))
477        }
478    }
479
480    pub fn as_memory_store(&self) -> &MemoryGraphStore {
481        &self.inner
482    }
483
484    pub fn create_graph(&mut self, name: &str) -> Result<(), SQLiteError> {
485        self.apply_mutation(|store| {
486            store.create_graph(name);
487            Ok(())
488        })
489    }
490
491    pub fn drop_graph(&mut self, name: &str) -> Result<(), SQLiteError> {
492        self.apply_mutation(|store| {
493            store.drop_graph(name);
494            Ok(())
495        })
496    }
497
498    pub fn union_graphs(
499        &mut self,
500        left: &str,
501        right: &str,
502        target: &str,
503    ) -> Result<(), SQLiteError> {
504        self.require_graph(left)?;
505        self.require_graph(right)?;
506        self.apply_mutation(|store| store.union_graphs(left, right, target))
507    }
508
509    pub fn intersect_graphs(
510        &mut self,
511        left: &str,
512        right: &str,
513        target: &str,
514    ) -> Result<(), SQLiteError> {
515        self.require_graph(left)?;
516        self.require_graph(right)?;
517        self.apply_mutation(|store| store.intersect_graphs(left, right, target))
518    }
519
520    pub fn difference_graphs(
521        &mut self,
522        left: &str,
523        right: &str,
524        target: &str,
525    ) -> Result<(), SQLiteError> {
526        self.require_graph(left)?;
527        self.require_graph(right)?;
528        self.apply_mutation(|store| store.difference_graphs(left, right, target))
529    }
530
531    pub fn copy_graph(&mut self, source: &str, target: &str) -> Result<(), SQLiteError> {
532        self.require_graph(source)?;
533        self.apply_mutation(|store| store.copy_graph(source, target))
534    }
535
536    pub fn add_vertex(&mut self, vertex: Vertex, graph: &str) -> Result<(), SQLiteError> {
537        self.apply_mutation(|store| store.add_vertex(vertex, graph))
538    }
539
540    pub fn add_edge(&mut self, edge: Edge, graph: &str) -> Result<(), SQLiteError> {
541        self.apply_mutation(|store| store.add_edge(edge, graph))
542    }
543
544    pub fn remove_vertex(&mut self, vertex_id: VertexId, graph: &str) -> Result<(), SQLiteError> {
545        self.apply_mutation(|store| store.remove_vertex(vertex_id, graph))
546    }
547
548    pub fn remove_edge(&mut self, edge_id: EdgeId, graph: &str) -> Result<(), SQLiteError> {
549        self.apply_mutation(|store| store.remove_edge(edge_id, graph))
550    }
551
552    pub fn allocate_vertex_id(
553        &mut self,
554        label: &str,
555        graph: &str,
556    ) -> Result<VertexId, SQLiteError> {
557        self.require_graph(graph)?;
558        let mut candidate = self.inner.clone();
559        let id = candidate
560            .allocate_vertex_id(label, graph)
561            .map_err(|error| SQLiteError::StorageBackend(error.to_string()))?;
562        self.persist_snapshot(&candidate)?;
563        self.inner = candidate;
564        Ok(id)
565    }
566
567    pub fn allocate_edge_id(&mut self, label: &str, graph: &str) -> Result<EdgeId, SQLiteError> {
568        self.require_graph(graph)?;
569        let mut candidate = self.inner.clone();
570        let id = candidate
571            .allocate_edge_id(label, graph)
572            .map_err(|error| SQLiteError::StorageBackend(error.to_string()))?;
573        self.persist_snapshot(&candidate)?;
574        self.inner = candidate;
575        Ok(id)
576    }
577
578    pub fn clear(&mut self) -> Result<(), SQLiteError> {
579        self.apply_mutation(|store| {
580            store.clear();
581            Ok(())
582        })
583    }
584
585    pub fn graph_names(&self) -> Vec<String> {
586        self.inner.graph_names()
587    }
588
589    pub fn has_graph(&self, name: &str) -> bool {
590        self.inner.has_graph(name)
591    }
592
593    pub fn neighbors(
594        &self,
595        vertex_id: VertexId,
596        label: Option<&str>,
597        direction: Direction,
598        graph: &str,
599    ) -> Result<Vec<VertexId>, SQLiteError> {
600        self.require_graph(graph)?;
601        self.inner
602            .neighbors(vertex_id, label, direction, graph)
603            .map_err(|error| graph_store_error(&error))
604    }
605
606    pub fn vertices_by_label(&self, label: &str, graph: &str) -> Result<Vec<Vertex>, SQLiteError> {
607        self.require_graph(graph)?;
608        self.inner
609            .vertices_by_label(label, graph)
610            .map_err(|error| graph_store_error(&error))
611    }
612
613    pub fn vertex_ids_by_label(
614        &self,
615        label: &str,
616        graph: &str,
617    ) -> Result<Vec<VertexId>, SQLiteError> {
618        self.require_graph(graph)?;
619        self.inner
620            .vertex_ids_by_label(label, graph)
621            .map_err(|error| graph_store_error(&error))
622    }
623
624    pub fn vertices_in_graph(&self, graph: &str) -> Result<Vec<Vertex>, SQLiteError> {
625        self.require_graph(graph)?;
626        self.inner
627            .vertices_in_graph(graph)
628            .map_err(|error| graph_store_error(&error))
629    }
630
631    pub fn edges_in_graph(&self, graph: &str) -> Result<Vec<Edge>, SQLiteError> {
632        self.require_graph(graph)?;
633        self.inner
634            .edges_in_graph(graph)
635            .map_err(|error| graph_store_error(&error))
636    }
637
638    pub fn vertex_graphs(&self, vertex_id: VertexId) -> BTreeSet<String> {
639        self.inner.vertex_graphs(vertex_id)
640    }
641
642    pub fn get_vertex(&self, vertex_id: VertexId) -> Option<&Vertex> {
643        self.inner.get_vertex(vertex_id)
644    }
645
646    pub fn get_edge(&self, edge_id: EdgeId) -> Option<&Edge> {
647        self.inner.get_edge(edge_id)
648    }
649
650    pub fn vertices(&self) -> BTreeMap<VertexId, Vertex> {
651        self.inner.vertices()
652    }
653
654    pub fn edges(&self) -> BTreeMap<EdgeId, Edge> {
655        self.inner.edges()
656    }
657}
658
659fn decode_graph_id(kind: &str, id: i64) -> Result<u64, SQLiteError> {
660    u64::try_from(id).map_err(|_| {
661        SQLiteError::StorageBackend(format!("invalid negative {kind} id {id} in graph store"))
662    })
663}
664
665fn decode_properties(
666    properties_json: &str,
667    properties_format: i64,
668) -> Result<BTreeMap<String, Value>, SQLiteError> {
669    match properties_format {
670        LEGACY_PROPERTIES_FORMAT => {
671            let raw: BTreeMap<String, serde_json::Value> =
672                serde_json::from_str(properties_json).map_err(SQLiteError::from)?;
673            raw.into_iter()
674                .map(|(key, value)| decode_legacy_value(value).map(|value| (key, value)))
675                .collect()
676        }
677        TAGGED_PROPERTIES_FORMAT => {
678            serde_json::from_str(properties_json).map_err(SQLiteError::from)
679        }
680        other => Err(SQLiteError::StorageBackend(format!(
681            "unsupported graph properties format version {other}"
682        ))),
683    }
684}
685
686/// Decode records written by the original untagged `Value` serializer.
687/// Its `Bytes(Vec<u8>)` variant preceded `List`, so a JSON array made solely
688/// from byte-range integers (including `[]`) represented bytes at every
689/// nesting depth. New records use an explicit bytes tag and reserve raw JSON
690/// arrays for `Value::List`.
691fn decode_legacy_value(raw: serde_json::Value) -> Result<Value, SQLiteError> {
692    match raw {
693        serde_json::Value::Array(items) => {
694            let bytes = items
695                .iter()
696                .map(|item| item.as_u64().and_then(|number| u8::try_from(number).ok()))
697                .collect::<Option<Vec<_>>>();
698            if let Some(bytes) = bytes {
699                return Ok(Value::Bytes(bytes));
700            }
701            items
702                .into_iter()
703                .map(decode_legacy_value)
704                .collect::<Result<Vec<_>, _>>()
705                .map(Value::List)
706        }
707        serde_json::Value::Object(map) => {
708            if map
709                .get("$uqa_type")
710                .and_then(serde_json::Value::as_str)
711                .is_some()
712            {
713                let tagged: Value = serde_json::from_value(serde_json::Value::Object(map.clone()))
714                    .map_err(SQLiteError::from)?;
715                if !matches!(tagged, Value::Map(_)) {
716                    return Ok(tagged);
717                }
718            }
719            map.into_iter()
720                .map(|(key, value)| decode_legacy_value(value).map(|value| (key, value)))
721                .collect::<Result<BTreeMap<_, _>, _>>()
722                .map(Value::Map)
723        }
724        scalar => serde_json::from_value(scalar).map_err(SQLiteError::from),
725    }
726}
727
728fn encode_graph_id(kind: &str, id: u64) -> Result<i64, SQLiteError> {
729    i64::try_from(id).map_err(|_| {
730        SQLiteError::StorageBackend(format!("{kind} id {id} exceeds SQLite INTEGER range"))
731    })
732}
733
734#[cfg(test)]
735mod tests {
736    use super::*;
737    use uqa_core::{Value, Vertex};
738
739    #[test]
740    fn round_trip_through_sqlite() {
741        let conn = ManagedConnection::open_in_memory().unwrap();
742        let mut store = SQLiteGraphStore::open(conn.clone(), None).unwrap();
743        store.create_graph("g").unwrap();
744        store.add_vertex(Vertex::new(1, "person"), "g").unwrap();
745        store.add_vertex(Vertex::new(2, "person"), "g").unwrap();
746        store.add_edge(Edge::new(1, 1, 2, "knows"), "g").unwrap();
747        let other = SQLiteGraphStore::open(conn, None).unwrap();
748        assert!(other.has_graph("g"));
749        let vs = other.vertices_in_graph("g").unwrap();
750        assert_eq!(vs.len(), 2);
751        let es = other.edges_in_graph("g").unwrap();
752        assert_eq!(es.len(), 1);
753    }
754
755    #[test]
756    fn legacy_raw_bytes_and_edge_first_memberships_survive_reopen() {
757        let conn = ManagedConnection::open_in_memory().unwrap();
758        conn.with(|connection| {
759            // Exact pre-versioning table shapes. Insert the edge membership
760            // first to prove hydration is independent of SQLite row order.
761            connection.execute_batch(
762                r#"
763                CREATE TABLE _graph_vertices (
764                    vertex_id INTEGER PRIMARY KEY,
765                    label TEXT NOT NULL DEFAULT '',
766                    properties_json TEXT NOT NULL
767                );
768                CREATE TABLE _graph_edges (
769                    edge_id INTEGER PRIMARY KEY,
770                    source_id INTEGER NOT NULL,
771                    target_id INTEGER NOT NULL,
772                    label TEXT NOT NULL,
773                    properties_json TEXT NOT NULL
774                );
775                CREATE TABLE _graph_membership (
776                    graph TEXT NOT NULL,
777                    entity_kind TEXT NOT NULL,
778                    entity_id INTEGER NOT NULL,
779                    PRIMARY KEY (graph, entity_kind, entity_id)
780                );
781                CREATE TABLE _graph_catalog (name TEXT PRIMARY KEY);
782                INSERT INTO _graph_catalog (name) VALUES ('g');
783                INSERT INTO _graph_vertices
784                    (vertex_id, label, properties_json)
785                    VALUES
786                    (1, 'person', '{"bytes":[1,2],"nested":[[3,4]],"list":[256]}'),
787                    (2, 'person', '{}');
788                INSERT INTO _graph_edges
789                    (edge_id, source_id, target_id, label, properties_json)
790                    VALUES (10, 1, 2, 'knows', '{"bytes":[5,6]}');
791                INSERT INTO _graph_membership
792                    (graph, entity_kind, entity_id) VALUES ('g', 'e', 10);
793                INSERT INTO _graph_membership
794                    (graph, entity_kind, entity_id) VALUES ('g', 'v', 1);
795                INSERT INTO _graph_membership
796                    (graph, entity_kind, entity_id) VALUES ('g', 'v', 2);
797                "#,
798            )?;
799            Ok(())
800        })
801        .unwrap();
802
803        let reopened = SQLiteGraphStore::open(conn, None).unwrap();
804        let vertex = reopened.get_vertex(1).unwrap();
805        assert_eq!(vertex.properties["bytes"], Value::Bytes(vec![1, 2]));
806        assert_eq!(
807            vertex.properties["nested"],
808            Value::List(vec![Value::Bytes(vec![3, 4])])
809        );
810        assert_eq!(
811            vertex.properties["list"],
812            Value::List(vec![Value::Int(256)])
813        );
814        assert_eq!(
815            reopened.get_edge(10).unwrap().properties["bytes"],
816            Value::Bytes(vec![5, 6])
817        );
818    }
819
820    #[test]
821    fn list_and_explicit_bytes_properties_remain_distinct_after_reopen() {
822        let conn = ManagedConnection::open_in_memory().unwrap();
823        let mut store = SQLiteGraphStore::open(conn.clone(), None).unwrap();
824        store.create_graph("g").unwrap();
825        let mut vertex = Vertex::new(1, "payload");
826        vertex.properties.insert(
827            "list".into(),
828            Value::List(vec![Value::Int(1), Value::Int(2)]),
829        );
830        vertex
831            .properties
832            .insert("bytes".into(), Value::Bytes(vec![1, 2]));
833        store.add_vertex(vertex, "g").unwrap();
834        drop(store);
835
836        let reopened = SQLiteGraphStore::open(conn, None).unwrap();
837        let restored = reopened.get_vertex(1).unwrap();
838        assert_eq!(
839            restored.properties["list"],
840            Value::List(vec![Value::Int(1), Value::Int(2)])
841        );
842        assert_eq!(restored.properties["bytes"], Value::Bytes(vec![1, 2]));
843    }
844
845    #[test]
846    fn failed_persistence_does_not_publish_memory_or_partial_disk_state() {
847        let conn = ManagedConnection::open_in_memory().unwrap();
848        let mut store = SQLiteGraphStore::open(conn.clone(), None).unwrap();
849        store.create_graph("g").unwrap();
850        store.add_vertex(Vertex::new(1, "person"), "g").unwrap();
851        conn.with(|connection| {
852            connection.execute_batch(
853                r#"
854                CREATE TRIGGER fail_graph_membership
855                BEFORE INSERT ON "_graph_membership"
856                WHEN NEW.entity_id = 2
857                BEGIN
858                    SELECT RAISE(ABORT, 'forced graph persistence failure');
859                END;
860                "#,
861            )?;
862            Ok(())
863        })
864        .unwrap();
865
866        assert!(store.add_vertex(Vertex::new(2, "person"), "g").is_err());
867        assert!(store.get_vertex(1).is_some());
868        assert!(store.get_vertex(2).is_none());
869
870        conn.with(|connection| {
871            connection.execute_batch("DROP TRIGGER fail_graph_membership")?;
872            Ok(())
873        })
874        .unwrap();
875        let reopened = SQLiteGraphStore::open(conn, None).unwrap();
876        assert_eq!(reopened.vertices_in_graph("g").unwrap().len(), 1);
877        assert!(reopened.get_vertex(2).is_none());
878    }
879
880    #[test]
881    fn corrupt_property_json_is_an_open_error() {
882        let conn = ManagedConnection::open_in_memory().unwrap();
883        let mut store = SQLiteGraphStore::open(conn.clone(), None).unwrap();
884        store.create_graph("g").unwrap();
885        store.add_vertex(Vertex::new(1, "person"), "g").unwrap();
886        conn.with(|connection| {
887            connection.execute(
888                "UPDATE _graph_vertices SET properties_json = '{' WHERE vertex_id = 1",
889                [],
890            )?;
891            Ok(())
892        })
893        .unwrap();
894
895        assert!(SQLiteGraphStore::open(conn, None).is_err());
896    }
897
898    #[test]
899    fn allocated_label_sequence_survives_reopen() {
900        let conn = ManagedConnection::open_in_memory().unwrap();
901        let mut store = SQLiteGraphStore::open(conn.clone(), None).unwrap();
902        store.create_graph("g").unwrap();
903        let first = store.allocate_vertex_id("person", "g").unwrap();
904        drop(store);
905
906        let mut reopened = SQLiteGraphStore::open(conn, None).unwrap();
907        let second = reopened.allocate_vertex_id("person", "g").unwrap();
908        assert_ne!(first, second);
909        assert!(second > first);
910    }
911}