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