Skip to main content

ursa_engine/graph/
mod.rs

1//! Graphs in a session: their definitions and where they are kept.
2//!
3//! A graph is defined by `CREATE PROPERTY GRAPH` (SQL/PGQ) over tables or views
4//! already in the session: one edge table with a source and a destination
5//! column, and optionally one vertex table keyed by an id column. Without a
6//! vertex table, the nodes are the endpoints of the edges.
7//!
8//! This module is the contract between the graph DDL (which creates and drops
9//! [`GraphDef`]s) and everything that reads graphs: the `<graph>.nodes` and
10//! `<graph>.edges` tables and the algorithm table functions. Definitions are
11//! held per session in a [`GraphRegistry`], stored as a session config
12//! extension so any code holding the session state can reach it.
13//!
14//! A definition names tables; it does not copy or pin their rows. Every query
15//! plans the edge table afresh, so a graph over a view of a file sees the
16//! file's current contents.
17//!
18//! An edge or vertex table may also be a file path or URL, as in `FROM`
19//! (`EDGE TABLES ('edges.csv' SOURCE KEY (src) DESTINATION KEY (dst))`). It is
20//! opened once, when the graph is created, as a listing table that the
21//! registry keeps for that graph ([`GraphRegistry::file_table`]); it is not
22//! registered under any name, so it never shows in a listing. Like a view over
23//! the file, it fixes the file's columns at that point and reads the file's
24//! current rows on every query.
25//!
26//! Tables a graph uses cannot be dropped, or replaced by ones the graph's
27//! definition no longer fits, while the graph exists ([`check_drop`],
28//! [`check_replace`]). If one disappears anyway (through the DataFusion API),
29//! `<graph>.nodes` and `<graph>.edges` fail when queried, and session listings
30//! keep working.
31//!
32//! Algorithms over a graph are table functions, `FROM pagerank(g)`, planned
33//! as a [`GraphAlgorithmNode`] whose child is the edge table ([`functions`],
34//! `relation`, `node`, `exec`), with the built index cached per session
35//! ([`IndexCache`]).
36//!
37//! Traversals (`hop`, `shortest_path`, `random_walk`) are table functions
38//! too, planned as a [`GraphTraversalNode`] over the same edge plan, with one
39//! more child per node-id argument (a list, or a subquery that runs when the
40//! node runs); see [`traversal`].
41
42use std::collections::BTreeMap;
43use std::sync::{Arc, OnceLock, RwLock};
44
45use datafusion::catalog::{CatalogProviderList, TableProvider};
46use datafusion::common::TableReference;
47use datafusion::execution::context::SessionState;
48use datafusion::prelude::{SessionConfig, SessionContext};
49
50mod define;
51mod describe;
52pub mod schema;
53
54pub use define::{
55    check_ddl, check_drop, check_drop_schema, check_replace, closest_name, create_graph,
56    drop_graph, unknown_graph,
57};
58pub use describe::{describe_graph, show_graphs};
59pub use schema::{edges_plan, edges_plan_from, nodes_plan, nodes_plan_from, GraphSchema};
60
61mod catalog_table;
62mod exec;
63pub mod functions;
64mod index;
65mod kernels;
66mod node;
67mod relation;
68mod session;
69pub mod traversal;
70
71pub use catalog_table::UrsaFunctions;
72pub use exec::{
73    GraphAlgorithmExec, GraphPlanner, GraphTraversalExec, KeepEdgeOrder, UrsaQueryPlanner,
74};
75pub use index::{index_builds, index_loads, FileIdentity, GraphIndex, IndexCache, Weights};
76pub use node::{GraphAlgorithmNode, GraphTraversalNode, Output};
77pub(crate) use session::{edge_order_rule, query_planner, register_functions, with_algorithms};
78
79/// One graph, as `CREATE PROPERTY GRAPH` defined it.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct GraphDef {
82    /// The graph's name: also the name of the schema holding its `nodes` and
83    /// `edges` tables.
84    pub name: String,
85    pub edges: EdgeTable,
86    /// The vertex table, if the definition has `VERTEX TABLES`.
87    pub vertices: Option<VertexTable>,
88}
89
90/// The edge table: each row is an edge from `source` to `destination`.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct EdgeTable {
93    pub table: TableReference,
94    /// `SOURCE KEY (column)`.
95    pub source: String,
96    /// `DESTINATION KEY (column)`.
97    pub destination: String,
98}
99
100/// The vertex table: each row is a node, keyed by `key`.
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub struct VertexTable {
103    pub table: TableReference,
104    /// `KEY (column)`.
105    pub key: String,
106}
107
108/// A table a graph opened from a file path or URL when it was created.
109#[derive(Debug, Clone)]
110pub struct FileTable {
111    /// The table reference the definition names: the path, as written.
112    pub table: TableReference,
113    pub provider: Arc<dyn TableProvider>,
114}
115
116/// The graphs defined in one session, by name. Names are compared as given
117/// (the DDL normalizes identifiers the way DataFusion does for tables).
118#[derive(Debug, Default)]
119pub struct GraphRegistry {
120    graphs: RwLock<BTreeMap<String, Arc<GraphDef>>>,
121    /// Per graph, the tables it opened from file paths (see the module docs).
122    files: RwLock<BTreeMap<String, Vec<FileTable>>>,
123    /// The session's catalogs before the file-table wrapper: graph schemas are
124    /// registered and dropped there (the wrapper cannot drop a schema).
125    catalogs: OnceLock<Arc<dyn CatalogProviderList>>,
126}
127
128impl GraphRegistry {
129    /// The graph called `name`.
130    pub fn get(&self, name: &str) -> Option<Arc<GraphDef>> {
131        self.graphs
132            .read()
133            .unwrap_or_else(|e| e.into_inner())
134            .get(name)
135            .cloned()
136    }
137
138    /// Every graph, sorted by name.
139    pub fn all(&self) -> Vec<Arc<GraphDef>> {
140        self.graphs
141            .read()
142            .unwrap_or_else(|e| e.into_inner())
143            .values()
144            .cloned()
145            .collect()
146    }
147
148    /// Add or replace `def`; returns the definition it replaced.
149    pub fn put(&self, def: GraphDef) -> Option<Arc<GraphDef>> {
150        self.put_with_files(def, Vec::new())
151    }
152
153    /// Add or replace `def`, with the tables it opened from file paths.
154    pub fn put_with_files(&self, def: GraphDef, files: Vec<FileTable>) -> Option<Arc<GraphDef>> {
155        let mut graphs = self.graphs.write().unwrap_or_else(|e| e.into_inner());
156        let mut all_files = self.files.write().unwrap_or_else(|e| e.into_inner());
157        if files.is_empty() {
158            all_files.remove(&def.name);
159        } else {
160            all_files.insert(def.name.clone(), files);
161        }
162        graphs.insert(def.name.clone(), Arc::new(def))
163    }
164
165    /// Remove the graph called `name`; returns it if it existed.
166    pub fn remove(&self, name: &str) -> Option<Arc<GraphDef>> {
167        let mut graphs = self.graphs.write().unwrap_or_else(|e| e.into_inner());
168        self.files
169            .write()
170            .unwrap_or_else(|e| e.into_inner())
171            .remove(name);
172        graphs.remove(name)
173    }
174
175    /// The table graph `graph` opened from the file path `table`, if `table`
176    /// is one of its file tables.
177    pub fn file_table(
178        &self,
179        graph: &str,
180        table: &TableReference,
181    ) -> Option<Arc<dyn TableProvider>> {
182        self.files
183            .read()
184            .unwrap_or_else(|e| e.into_inner())
185            .get(graph)?
186            .iter()
187            .find(|f| &f.table == table)
188            .map(|f| Arc::clone(&f.provider))
189    }
190
191    /// Record the session's unwrapped catalog list (done by `new_session`).
192    pub(crate) fn set_catalogs(&self, catalogs: Arc<dyn CatalogProviderList>) {
193        let _ = self.catalogs.set(catalogs);
194    }
195
196    pub(crate) fn catalogs(&self) -> Option<&Arc<dyn CatalogProviderList>> {
197        self.catalogs.get()
198    }
199
200    /// The names of all graphs, sorted: for "did you mean" suggestions.
201    pub fn names(&self) -> Vec<String> {
202        self.graphs
203            .read()
204            .unwrap_or_else(|e| e.into_inner())
205            .keys()
206            .cloned()
207            .collect()
208    }
209}
210
211/// Install an empty registry on `config` (done by `new_session`).
212pub(crate) fn with_registry(config: SessionConfig) -> SessionConfig {
213    config.with_extension(Arc::new(GraphRegistry::default()))
214}
215
216/// The session's graph registry.
217pub fn registry(state: &SessionState) -> Arc<GraphRegistry> {
218    state
219        .config()
220        .get_extension::<GraphRegistry>()
221        .expect("an Ursa session has a graph registry (built by ursa_engine::new_session)")
222}
223
224/// The graph registry of `ctx`.
225pub fn registry_of(ctx: &SessionContext) -> Arc<GraphRegistry> {
226    registry(&ctx.state())
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    #[test]
234    fn a_session_has_an_empty_registry() {
235        let ctx = crate::new_session();
236        let graphs = registry_of(&ctx);
237        assert!(graphs.all().is_empty());
238        graphs.put(GraphDef {
239            name: "g".into(),
240            edges: EdgeTable {
241                table: TableReference::bare("e"),
242                source: "a".into(),
243                destination: "b".into(),
244            },
245            vertices: None,
246        });
247        // The registry is shared by every handle on the session.
248        assert_eq!(registry_of(&ctx).names(), vec!["g".to_string()]);
249    }
250}