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, EdgeSpec, GraphSpec, VertexSpec,
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;
67pub mod path_search;
68pub mod pattern;
69mod relation;
70mod session;
71pub mod traversal;
72
73pub use catalog_table::UrsaFunctions;
74pub use exec::{
75    GraphAlgorithmExec, GraphPlanner, GraphTraversalExec, KeepEdgeOrder, UrsaQueryPlanner,
76};
77pub use index::{index_builds, index_loads, FileIdentity, GraphIndex, IndexCache, Weights};
78pub use node::{GraphAlgorithmNode, GraphTraversalNode, Output};
79pub(crate) use session::{edge_order_rule, query_planner, register_functions, with_algorithms};
80
81/// One graph, as `CREATE PROPERTY GRAPH` defined it: its vertex and edge
82/// tables (SQL/PGQ's element tables), with their keys resolved (#295).
83///
84/// `MATCH` spans every table. An algorithm runs on one vertex table and one
85/// edge table, a [`GraphDef`], as in DuckPGQ: see [`PropertyGraph::pair`].
86#[derive(Debug, Clone, PartialEq, Eq)]
87pub struct PropertyGraph {
88    /// The graph's name: also the name of the schema holding its `nodes` and
89    /// `edges` tables (when it has one implied pair; see [`Self::pair`]).
90    pub name: String,
91    pub vertex_tables: Vec<VertexTable>,
92    pub edge_tables: Vec<EdgeTable>,
93}
94
95/// A graph as an algorithm sees it: one edge table, and the vertex table its
96/// endpoints refer to, if any. A whole graph before #295; now one pair of a
97/// [`PropertyGraph`].
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct GraphDef {
100    /// The graph's name.
101    pub name: String,
102    pub edges: EdgeTable,
103    /// The vertex table, if the pair has one.
104    pub vertices: Option<VertexTable>,
105}
106
107/// An edge table: each row is an edge from `source` to `destination`.
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct EdgeTable {
110    /// Its element name: the `AS` name, or else the table's name.
111    pub name: String,
112    pub table: TableReference,
113    /// `SOURCE KEY (column)`.
114    pub source: String,
115    /// `DESTINATION KEY (column)`.
116    pub destination: String,
117    /// The vertex table (element name) `SOURCE … REFERENCES`, if any.
118    pub source_vertex: Option<String>,
119    /// The vertex table (element name) `DESTINATION … REFERENCES`, if any.
120    pub destination_vertex: Option<String>,
121    pub labels: Vec<Label>,
122}
123
124/// A vertex table: each row is a node, keyed by `key`.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct VertexTable {
127    /// Its element name: the `AS` name, or else the table's name.
128    pub name: String,
129    pub table: TableReference,
130    /// `KEY (column)`, or the key inferred when `KEY` was left out.
131    pub key: String,
132    pub labels: Vec<Label>,
133}
134
135/// A label of an element table, and the properties it exposes.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct Label {
138    pub name: String,
139    pub properties: Properties,
140}
141
142/// The properties a label exposes.
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub enum Properties {
145    /// Every column but `except`: no properties clause, or `PROPERTIES ARE
146    /// ALL COLUMNS [EXCEPT (…)]`.
147    AllColumns { except: Vec<String> },
148    /// `PROPERTIES (column [AS name], …)`: (column, property name) pairs.
149    Columns(Vec<(String, String)>),
150    /// `NO PROPERTIES`.
151    None,
152}
153
154impl Label {
155    /// The default label of an element table: its name, with every column.
156    pub fn default_for(element: &str) -> Self {
157        Label {
158            name: element.to_string(),
159            properties: Properties::AllColumns { except: Vec::new() },
160        }
161    }
162}
163
164/// An element table's labels, or its default label when none were written.
165fn labels_or_default<'a>(labels: &'a [Label], name: &str) -> std::borrow::Cow<'a, [Label]> {
166    if labels.is_empty() {
167        std::borrow::Cow::Owned(vec![Label::default_for(name)])
168    } else {
169        std::borrow::Cow::Borrowed(labels)
170    }
171}
172
173impl VertexTable {
174    /// A vertex table named after its table, with its default label.
175    pub fn new(table: impl Into<TableReference>, key: impl Into<String>) -> Self {
176        let table = table.into();
177        VertexTable {
178            name: table.table().to_string(),
179            table,
180            key: key.into(),
181            labels: Vec::new(),
182        }
183    }
184
185    /// Its labels, the default one if none were written.
186    pub fn labels(&self) -> std::borrow::Cow<'_, [Label]> {
187        labels_or_default(&self.labels, &self.name)
188    }
189
190    /// Whether `label` names this table: its element name or one of its labels.
191    pub fn answers_to(&self, label: &str) -> bool {
192        self.name.eq_ignore_ascii_case(label)
193            || self
194                .labels
195                .iter()
196                .any(|l| l.name.eq_ignore_ascii_case(label))
197    }
198}
199
200impl EdgeTable {
201    /// An edge table named after its table, with its default label and no
202    /// `REFERENCES`.
203    pub fn new(
204        table: impl Into<TableReference>,
205        source: impl Into<String>,
206        destination: impl Into<String>,
207    ) -> Self {
208        let table = table.into();
209        EdgeTable {
210            name: table.table().to_string(),
211            table,
212            source: source.into(),
213            destination: destination.into(),
214            source_vertex: None,
215            destination_vertex: None,
216            labels: Vec::new(),
217        }
218    }
219
220    /// Its labels, the default one if none were written.
221    pub fn labels(&self) -> std::borrow::Cow<'_, [Label]> {
222        labels_or_default(&self.labels, &self.name)
223    }
224
225    /// Whether `label` names this table: its element name or one of its labels.
226    pub fn answers_to(&self, label: &str) -> bool {
227        self.name.eq_ignore_ascii_case(label)
228            || self
229                .labels
230                .iter()
231                .any(|l| l.name.eq_ignore_ascii_case(label))
232    }
233}
234
235/// A graph of one pair: what every graph was before #295.
236impl From<GraphDef> for PropertyGraph {
237    fn from(def: GraphDef) -> Self {
238        PropertyGraph {
239            name: def.name,
240            vertex_tables: def.vertices.into_iter().collect(),
241            edge_tables: vec![def.edges],
242        }
243    }
244}
245
246impl PropertyGraph {
247    /// Every table the graph reads, vertex tables first.
248    pub fn tables(&self) -> impl Iterator<Item = &TableReference> {
249        self.vertex_tables
250            .iter()
251            .map(|v| &v.table)
252            .chain(self.edge_tables.iter().map(|e| &e.table))
253    }
254
255    /// The vertex table called (or labelled) `label`.
256    pub fn vertex_table(&self, label: &str) -> Option<&VertexTable> {
257        self.vertex_tables
258            .iter()
259            .find(|v| v.name.eq_ignore_ascii_case(label))
260            .or_else(|| self.vertex_tables.iter().find(|v| v.answers_to(label)))
261    }
262
263    /// The edge table called (or labelled) `label`.
264    pub fn edge_table(&self, label: &str) -> Option<&EdgeTable> {
265        self.edge_tables
266            .iter()
267            .find(|e| e.name.eq_ignore_ascii_case(label))
268            .or_else(|| self.edge_tables.iter().find(|e| e.answers_to(label)))
269    }
270
271    /// The vertex table an endpoint of `edge` refers to: its `REFERENCES`, or
272    /// without one, the graph's only vertex table (`None` if it has none).
273    pub fn endpoint_vertex(&self, edge: &EdgeTable, source: bool) -> Option<&VertexTable> {
274        let named = if source {
275            &edge.source_vertex
276        } else {
277            &edge.destination_vertex
278        };
279        match named {
280            Some(name) => self.vertex_tables.iter().find(|v| &v.name == name),
281            None if self.vertex_tables.len() == 1 => self.vertex_tables.first(),
282            None => None,
283        }
284    }
285
286    /// The pair an algorithm runs on: an edge table, and the vertex table both
287    /// its endpoints refer to (if any), as DuckPGQ's `pagerank(g, vertex,
288    /// edge)`. With no labels the pair is implied when the graph has one edge
289    /// table: every graph before #295 has one.
290    pub fn pair(
291        &self,
292        vertex: Option<&str>,
293        edge: Option<&str>,
294    ) -> datafusion::error::Result<GraphDef> {
295        use datafusion::error::DataFusionError;
296        let plan_err = |m: String| Err(DataFusionError::Plan(m));
297        let edges = match edge {
298            Some(label) => match self.edge_table(label) {
299                Some(e) => e,
300                None => {
301                    let names: Vec<&str> =
302                        self.edge_tables.iter().map(|e| e.name.as_str()).collect();
303                    return plan_err(format!(
304                        "graph '{}' has no edge table '{label}'; its edge tables are {}",
305                        self.name,
306                        if names.is_empty() {
307                            "none".to_string()
308                        } else {
309                            names.join(", ")
310                        }
311                    ));
312                }
313            },
314            None => match self.edge_tables.as_slice() {
315                [only] => only,
316                [] => {
317                    return plan_err(format!(
318                        "graph '{}' has no edge tables, so there is nothing for an algorithm to \
319                         run on",
320                        self.name
321                    ))
322                }
323                several => {
324                    let names: Vec<&str> = several.iter().map(|e| e.name.as_str()).collect();
325                    return plan_err(format!(
326                        "graph '{}' has {} edge tables ({}); name the vertex and edge table to \
327                         run on, as in pagerank({}, <vertex table>, <edge table>)",
328                        self.name,
329                        several.len(),
330                        names.join(", "),
331                        self.name
332                    ));
333                }
334            },
335        };
336        let source = self.endpoint_vertex(edges, true);
337        let destination = self.endpoint_vertex(edges, false);
338        if let (Some(s), Some(d)) = (source, destination) {
339            if s.name != d.name {
340                return plan_err(format!(
341                    "edge table '{}' of graph '{}' goes from vertex table '{}' to '{}'; an \
342                     algorithm runs on edges between one vertex table",
343                    edges.name, self.name, s.name, d.name
344                ));
345            }
346        }
347        let vertices = source.or(destination);
348        if let Some(label) = vertex {
349            let Some(asked) = self.vertex_table(label) else {
350                let names: Vec<&str> = self.vertex_tables.iter().map(|v| v.name.as_str()).collect();
351                return plan_err(format!(
352                    "graph '{}' has no vertex table '{label}'; its vertex tables are {}",
353                    self.name,
354                    if names.is_empty() {
355                        "none".to_string()
356                    } else {
357                        names.join(", ")
358                    }
359                ));
360            };
361            if vertices.is_some_and(|v| v.name != asked.name) {
362                return plan_err(format!(
363                    "edge table '{}' of graph '{}' connects vertex table '{}', not '{}'",
364                    edges.name,
365                    self.name,
366                    vertices.map(|v| v.name.as_str()).unwrap_or_default(),
367                    asked.name
368                ));
369            }
370            return Ok(GraphDef {
371                name: self.name.clone(),
372                edges: edges.clone(),
373                vertices: Some(asked.clone()),
374            });
375        }
376        Ok(GraphDef {
377            name: self.name.clone(),
378            edges: edges.clone(),
379            vertices: vertices.cloned(),
380        })
381    }
382
383    /// The implied pair, when there is one: the graph's `nodes` and `edges`
384    /// tables are this pair's.
385    pub fn default_pair(&self) -> Option<GraphDef> {
386        if self.vertex_tables.len() > 1 {
387            return None;
388        }
389        self.pair(None, None).ok()
390    }
391}
392
393/// A table a graph opened from a file path or URL when it was created.
394#[derive(Debug, Clone)]
395pub struct FileTable {
396    /// The table reference the definition names: the path, as written.
397    pub table: TableReference,
398    pub provider: Arc<dyn TableProvider>,
399}
400
401/// The graphs defined in one session, by name. Names are compared as given
402/// (the DDL normalizes identifiers the way DataFusion does for tables).
403#[derive(Debug, Default)]
404pub struct GraphRegistry {
405    graphs: RwLock<BTreeMap<String, Arc<PropertyGraph>>>,
406    /// Per graph, the tables it opened from file paths (see the module docs).
407    files: RwLock<BTreeMap<String, Vec<FileTable>>>,
408    /// The session's catalogs before the file-table wrapper: graph schemas are
409    /// registered and dropped there (the wrapper cannot drop a schema).
410    catalogs: OnceLock<Arc<dyn CatalogProviderList>>,
411}
412
413impl GraphRegistry {
414    /// The graph called `name`.
415    pub fn get(&self, name: &str) -> Option<Arc<PropertyGraph>> {
416        self.graphs
417            .read()
418            .unwrap_or_else(|e| e.into_inner())
419            .get(name)
420            .cloned()
421    }
422
423    /// Every graph, sorted by name.
424    pub fn all(&self) -> Vec<Arc<PropertyGraph>> {
425        self.graphs
426            .read()
427            .unwrap_or_else(|e| e.into_inner())
428            .values()
429            .cloned()
430            .collect()
431    }
432
433    /// Add or replace `def`; returns the definition it replaced.
434    pub fn put(&self, def: impl Into<PropertyGraph>) -> Option<Arc<PropertyGraph>> {
435        self.put_with_files(def.into(), Vec::new())
436    }
437
438    /// Add or replace `def`, with the tables it opened from file paths.
439    pub fn put_with_files(
440        &self,
441        def: PropertyGraph,
442        files: Vec<FileTable>,
443    ) -> Option<Arc<PropertyGraph>> {
444        let mut graphs = self.graphs.write().unwrap_or_else(|e| e.into_inner());
445        let mut all_files = self.files.write().unwrap_or_else(|e| e.into_inner());
446        if files.is_empty() {
447            all_files.remove(&def.name);
448        } else {
449            all_files.insert(def.name.clone(), files);
450        }
451        graphs.insert(def.name.clone(), Arc::new(def))
452    }
453
454    /// Remove the graph called `name`; returns it if it existed.
455    pub fn remove(&self, name: &str) -> Option<Arc<PropertyGraph>> {
456        let mut graphs = self.graphs.write().unwrap_or_else(|e| e.into_inner());
457        self.files
458            .write()
459            .unwrap_or_else(|e| e.into_inner())
460            .remove(name);
461        graphs.remove(name)
462    }
463
464    /// The table graph `graph` opened from the file path `table`, if `table`
465    /// is one of its file tables.
466    pub fn file_table(
467        &self,
468        graph: &str,
469        table: &TableReference,
470    ) -> Option<Arc<dyn TableProvider>> {
471        self.files
472            .read()
473            .unwrap_or_else(|e| e.into_inner())
474            .get(graph)?
475            .iter()
476            .find(|f| &f.table == table)
477            .map(|f| Arc::clone(&f.provider))
478    }
479
480    /// Record the session's unwrapped catalog list (done by `new_session`).
481    pub(crate) fn set_catalogs(&self, catalogs: Arc<dyn CatalogProviderList>) {
482        let _ = self.catalogs.set(catalogs);
483    }
484
485    pub(crate) fn catalogs(&self) -> Option<&Arc<dyn CatalogProviderList>> {
486        self.catalogs.get()
487    }
488
489    /// The names of all graphs, sorted: for "did you mean" suggestions.
490    pub fn names(&self) -> Vec<String> {
491        self.graphs
492            .read()
493            .unwrap_or_else(|e| e.into_inner())
494            .keys()
495            .cloned()
496            .collect()
497    }
498}
499
500/// Install an empty registry on `config` (done by `new_session`).
501pub(crate) fn with_registry(config: SessionConfig) -> SessionConfig {
502    config.with_extension(Arc::new(GraphRegistry::default()))
503}
504
505/// The session's graph registry.
506pub fn registry(state: &SessionState) -> Arc<GraphRegistry> {
507    state
508        .config()
509        .get_extension::<GraphRegistry>()
510        .expect("an Ursa session has a graph registry (built by ursa_engine::new_session)")
511}
512
513/// The graph registry of `ctx`.
514pub fn registry_of(ctx: &SessionContext) -> Arc<GraphRegistry> {
515    registry(&ctx.state())
516}
517
518#[cfg(test)]
519mod tests {
520    use super::*;
521
522    #[test]
523    fn a_session_has_an_empty_registry() {
524        let ctx = crate::new_session();
525        let graphs = registry_of(&ctx);
526        assert!(graphs.all().is_empty());
527        graphs.put(PropertyGraph {
528            name: "g".into(),
529            vertex_tables: Vec::new(),
530            edge_tables: vec![EdgeTable::new(TableReference::bare("e"), "a", "b")],
531        });
532        // The registry is shared by every handle on the session.
533        assert_eq!(registry_of(&ctx).names(), vec!["g".to_string()]);
534    }
535}