Skip to main content

rudb_graph/
rel.rs

1//! What a relationship is, and how one gets declared.
2//!
3//! A relationship is a named triple: a child table with a column list, a parent table with a column
4//! list, and a cardinality. It is always many-to-one from child to parent, per section 2.3, and
5//! many-to-many is two of them through the link table rather than a fourth case: `partsupp` is the
6//! child of both `part` and `supplier` and there is no `part` to `supplier` relationship anywhere.
7//!
8//! Section 2.5 gives three declaration paths. This module holds the second, the session setting,
9//! because it is the one TPC-H needs: the tables arrive from Parquet and Parquet has no foreign
10//! keys, so there is no constraint in the catalog for the first path to read and nothing for the
11//! third path to have inferred yet. The setting takes a list of `child(col) -> parent(col)` and
12//! that is the whole grammar.
13//!
14//! Nothing here is trusted. A declaration says what the author believes, and section 2.3 says the
15//! cardinality is observed at build time instead: a declared many-to-one whose parent side turns
16//! out not to be unique becomes [`Cardinality::Unverified`], the link is not built, and the planner
17//! is told rather than left to produce a wrong answer from a wrong declaration.
18
19use std::fmt;
20
21use rudb_common::{Error, Result};
22
23/// How many parent rows a child row can match.
24///
25/// The three cases are not decoration. Each licenses a different rewrite in section 5, and the
26/// difference between the first two is the difference between a LEFT JOIN that can be turned into
27/// an inner one and one that cannot.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum Cardinality {
30    /// Every child row matches exactly one parent row: the key is not null anywhere and every value
31    /// is present in the parent. This licenses pushing an aggregate through the relationship and
32    /// turns a LEFT JOIN into an inner join.
33    ExactlyOne,
34    /// Every child row matches at most one parent row, which is the ordinary case: a null key or a
35    /// key with no parent row matches nothing.
36    AtMostOne,
37    /// The parent side was not unique, or verification did not run. No link is built. The planner
38    /// is told so that it plans an ordinary hash join rather than waiting for a structure that is
39    /// never going to arrive.
40    Unverified,
41}
42
43impl Cardinality {
44    /// Whether a link may be built for a relationship of this cardinality.
45    #[must_use]
46    pub fn links(self) -> bool {
47        matches!(self, Self::ExactlyOne | Self::AtMostOne)
48    }
49
50    /// Whether every child row is guaranteed a parent row, which is what licenses the aggregate
51    /// push-through and the LEFT JOIN rewrite.
52    #[must_use]
53    pub fn total(self) -> bool {
54        matches!(self, Self::ExactlyOne)
55    }
56
57    /// The tag this cardinality takes in a section header.
58    #[must_use]
59    pub fn tag(self) -> u8 {
60        match self {
61            Self::ExactlyOne => 0,
62            Self::AtMostOne => 1,
63            Self::Unverified => 2,
64        }
65    }
66
67    /// What this cardinality is called where a person reads it, which is `rudb_links()`.
68    #[must_use]
69    pub fn label(self) -> &'static str {
70        match self {
71            Self::ExactlyOne => "exactly one",
72            Self::AtMostOne => "at most one",
73            Self::Unverified => "unverified",
74        }
75    }
76
77    /// The cardinality a header tag names.
78    ///
79    /// # Errors
80    ///
81    /// If the tag is not one of the three, which means a file from a later build. The caller drops
82    /// the section, per the rule in section 3.2 that a reader ignores what it does not know.
83    pub fn from_tag(tag: u8) -> Result<Self> {
84        match tag {
85            0 => Ok(Self::ExactlyOne),
86            1 => Ok(Self::AtMostOne),
87            2 => Ok(Self::Unverified),
88            _ => Err(malformed(format!("cardinality {tag} is not one this build knows"))),
89        }
90    }
91}
92
93impl fmt::Display for Cardinality {
94    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
95        formatter.write_str(self.label())
96    }
97}
98
99/// One side of a relationship: a table and the columns of its key.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct Side {
102    /// The table's name as the catalog holds it.
103    pub table: String,
104    /// The key columns, in order. More than one is a composite key, resolved by the same key map
105    /// over a folded composite, a column at a time, the way `rudb-exec` already folds a group key.
106    pub columns: Vec<String>,
107}
108
109impl Side {
110    /// A side over a single column, which is every relationship TPC-H needs.
111    pub fn new(table: impl Into<String>, column: impl Into<String>) -> Self {
112        Self { table: table.into(), columns: vec![column.into()] }
113    }
114
115    /// A side over a composite key.
116    ///
117    /// # Errors
118    ///
119    /// If the column list is empty. A key over no columns is not a key, and catching it here is
120    /// what keeps it from becoming a relationship that matches every row against every row.
121    pub fn composite(table: impl Into<String>, columns: Vec<String>) -> Result<Self> {
122        if columns.is_empty() {
123            return Err(malformed("a relationship side needs at least one key column"));
124        }
125        Ok(Self { table: table.into(), columns })
126    }
127}
128
129impl fmt::Display for Side {
130    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
131        write!(formatter, "{}({})", self.table, self.columns.join(", "))
132    }
133}
134
135/// A declared many-to-one relationship from a child table to a parent table.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct Relationship {
138    /// The many side, which is where the forward link lives.
139    pub child: Side,
140    /// The one side, which is where the key map lives.
141    pub parent: Side,
142    /// What the build observed, or [`Cardinality::Unverified`] before it ran.
143    pub cardinality: Cardinality,
144}
145
146impl Relationship {
147    /// A relationship not yet verified, which is what a declaration produces.
148    ///
149    /// # Errors
150    ///
151    /// If the two sides have different numbers of key columns, or if a side names a table as its
152    /// own parent through the same columns. The first is a declaration that cannot be equated
153    /// column by column. The second is a self relationship on identical columns, which is the
154    /// identity and carries no information.
155    pub fn declare(child: Side, parent: Side) -> Result<Self> {
156        if child.columns.len() != parent.columns.len() {
157            return Err(malformed(format!(
158                "the child key {child} has {} columns and the parent key {parent} has {}",
159                child.columns.len(),
160                parent.columns.len()
161            )));
162        }
163        if child.table == parent.table && child.columns == parent.columns {
164            return Err(malformed(format!("{child} references itself through its own columns")));
165        }
166        Ok(Self { child, parent, cardinality: Cardinality::Unverified })
167    }
168
169    /// The name this relationship reports under in `rudb_links()`.
170    ///
171    /// Derived rather than given, because section 2.3 names a relationship by what it relates and
172    /// there is nowhere in the `graph_links` grammar for an author to supply a name. A derived name
173    /// is also stable across a reload, which is what lets a query log entry from yesterday still
174    /// name a relationship today.
175    #[must_use]
176    pub fn name(&self) -> String {
177        format!("{} -> {}", self.child, self.parent)
178    }
179
180    /// Whether the two sides are keyed on one column each.
181    #[must_use]
182    pub fn single_column(&self) -> bool {
183        self.child.columns.len() == 1
184    }
185}
186
187impl fmt::Display for Relationship {
188    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
189        write!(formatter, "{} -> {}", self.child, self.parent)
190    }
191}
192
193/// Parses the `graph_links` session setting.
194///
195/// The grammar is a comma separated list of `child(col) -> parent(col)`, with a composite key
196/// written as `child(a, b) -> parent(c, d)`. Whitespace between tokens is free. A trailing comma is
197/// allowed because a setting long enough to need one line per link is a setting somebody will edit,
198/// and refusing the trailing comma would make every edit a two line diff.
199///
200/// TPC-H's eight relationships in this grammar, which is the string the G1 measurement runs with:
201///
202/// ```text
203/// nation(n_regionkey) -> region(r_regionkey),
204/// supplier(s_nationkey) -> nation(n_nationkey),
205/// customer(c_nationkey) -> nation(n_nationkey),
206/// partsupp(ps_partkey) -> part(p_partkey),
207/// partsupp(ps_suppkey) -> supplier(s_suppkey),
208/// orders(o_custkey) -> customer(c_custkey),
209/// lineitem(l_orderkey) -> orders(o_orderkey),
210/// lineitem(l_partkey) -> part(p_partkey)
211/// ```
212///
213/// # Errors
214///
215/// If a link is malformed. A setting is typed by a person, so the error names what was expected at
216/// the position it stopped rather than reporting that the setting is invalid: a link list that
217/// silently dropped the one entry with a typo in it would be a setting that looks like it worked.
218pub fn parse_links(setting: &str) -> Result<Vec<Relationship>> {
219    let mut links = Vec::new();
220    for entry in setting.split(',').map(str::trim) {
221        // `split(',')` also splits the inside of a composite key, so an entry with no arrow in it
222        // is either a continuation of the previous one or blank. Rejoining is done by the arrow: an
223        // entry without one attaches to whichever side of the previous link is still open.
224        if entry.is_empty() {
225            continue;
226        }
227        links.push(entry.to_owned());
228    }
229    // Rejoin the pieces a composite key was split into. An entry is complete when it holds an
230    // arrow and its parentheses balance; until then the next piece belongs to it.
231    let mut joined: Vec<String> = Vec::new();
232    for piece in links {
233        match joined.last_mut() {
234            Some(open) if !balanced(open) => {
235                open.push_str(", ");
236                open.push_str(&piece);
237            }
238            _ => joined.push(piece),
239        }
240    }
241
242    let mut parsed = Vec::with_capacity(joined.len());
243    for entry in &joined {
244        parsed.push(parse_link(entry)?);
245    }
246    Ok(parsed)
247}
248
249fn balanced(entry: &str) -> bool {
250    let opens = entry.matches('(').count();
251    let closes = entry.matches(')').count();
252    opens == closes && entry.contains("->") && closes == 2
253}
254
255fn parse_link(entry: &str) -> Result<Relationship> {
256    let Some((child, parent)) = entry.split_once("->") else {
257        return Err(malformed(format!(
258            "expected `child(column) -> parent(column)` and found `{entry}`"
259        )));
260    };
261    Relationship::declare(parse_side(child.trim())?, parse_side(parent.trim())?)
262}
263
264fn parse_side(side: &str) -> Result<Side> {
265    let Some((table, rest)) = side.split_once('(') else {
266        return Err(malformed(format!("expected `table(column)` and found `{side}`")));
267    };
268    let Some(columns) = rest.strip_suffix(')') else {
269        return Err(malformed(format!("`{side}` is missing its closing parenthesis")));
270    };
271    let table = table.trim();
272    if table.is_empty() {
273        return Err(malformed(format!("`{side}` names no table")));
274    }
275    let columns: Vec<String> = columns
276        .split(',')
277        .map(str::trim)
278        .filter(|column| !column.is_empty())
279        .map(str::to_owned)
280        .collect();
281    Side::composite(table, columns)
282}
283
284fn malformed(message: impl Into<String>) -> Error {
285    Error::invalid_input(format!("invalid rudb relationship: {}", message.into()))
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    #[test]
293    fn the_eight_tpch_relationships_parse_into_eight_relationships() {
294        // The string the G1 measurement runs with, so a change to the grammar that broke it would
295        // fail here rather than in a benchmark two milestones later.
296        let setting = "nation(n_regionkey) -> region(r_regionkey), \
297             supplier(s_nationkey) -> nation(n_nationkey), \
298             customer(c_nationkey) -> nation(n_nationkey), \
299             partsupp(ps_partkey) -> part(p_partkey), \
300             partsupp(ps_suppkey) -> supplier(s_suppkey), \
301             orders(o_custkey) -> customer(c_custkey), \
302             lineitem(l_orderkey) -> orders(o_orderkey), \
303             lineitem(l_partkey) -> part(p_partkey)";
304        let links = parse_links(setting).expect("parse");
305        assert_eq!(links.len(), 8);
306        assert_eq!(links[0].child, Side::new("nation", "n_regionkey"));
307        assert_eq!(links[0].parent, Side::new("region", "r_regionkey"));
308        assert_eq!(links[7].name(), "lineitem(l_partkey) -> part(p_partkey)");
309        assert!(links.iter().all(Relationship::single_column));
310        assert!(
311            links.iter().all(|link| link.cardinality == Cardinality::Unverified),
312            "a declaration is unverified until a build has looked at the column"
313        );
314    }
315
316    #[test]
317    fn a_composite_key_survives_the_comma_that_separates_links() {
318        // The one real ambiguity in the grammar: a comma separates two links and also separates two
319        // columns of the same key, so a parser that split on commas and stopped would produce four
320        // broken links out of these two.
321        let setting = "child(a, b) -> parent(c, d), other(e) -> parent2(f)";
322        let links = parse_links(setting).expect("parse");
323        assert_eq!(links.len(), 2);
324        assert_eq!(links[0].child.columns, vec!["a".to_owned(), "b".to_owned()]);
325        assert_eq!(links[0].parent.columns, vec!["c".to_owned(), "d".to_owned()]);
326        assert!(!links[0].single_column());
327        assert_eq!(links[1].child.columns, vec!["e".to_owned()]);
328    }
329
330    #[test]
331    fn whitespace_and_a_trailing_comma_are_free() {
332        let setting = "  orders( o_custkey )   ->   customer( c_custkey )  ,  ";
333        let links = parse_links(setting).expect("parse");
334        assert_eq!(links.len(), 1);
335        assert_eq!(links[0].child, Side::new("orders", "o_custkey"));
336    }
337
338    #[test]
339    fn an_empty_setting_declares_nothing_rather_than_failing() {
340        assert!(parse_links("").expect("parse").is_empty());
341        assert!(parse_links("   ").expect("parse").is_empty());
342    }
343
344    #[test]
345    fn a_link_with_no_arrow_is_refused_and_says_what_was_expected() {
346        let error = parse_links("orders(o_custkey) customer(c_custkey)").expect_err("refused");
347        let text = error.to_string();
348        assert!(text.contains("child(column) -> parent(column)"), "{text}");
349    }
350
351    #[test]
352    fn a_side_with_no_parenthesis_is_refused() {
353        assert!(parse_links("orders -> customer(c_custkey)").is_err());
354        assert!(parse_links("orders(o_custkey -> customer(c_custkey)").is_err());
355    }
356
357    #[test]
358    fn a_side_with_no_table_is_refused() {
359        assert!(parse_links("(o_custkey) -> customer(c_custkey)").is_err());
360    }
361
362    #[test]
363    fn a_side_with_no_columns_is_refused() {
364        assert!(parse_links("orders() -> customer(c_custkey)").is_err());
365    }
366
367    #[test]
368    fn a_declaration_whose_sides_have_different_widths_is_refused() {
369        // The equality is column by column, so two sides of different widths is a declaration with
370        // no meaning rather than one that happens to be wrong.
371        let error = parse_links("child(a, b) -> parent(c)").expect_err("refused");
372        assert!(error.to_string().contains("columns"), "{error}");
373    }
374
375    #[test]
376    fn a_table_referencing_itself_through_its_own_columns_is_refused() {
377        let error = parse_links("orders(o_orderkey) -> orders(o_orderkey)").expect_err("refused");
378        assert!(error.to_string().contains("itself"), "{error}");
379    }
380
381    #[test]
382    fn a_table_referencing_itself_through_a_different_column_is_allowed() {
383        // A manager column against an employee key is a real relationship and section 2.3 does not
384        // exclude it, so the self check is about identical columns and nothing more.
385        let links = parse_links("employee(manager) -> employee(id)").expect("parse");
386        assert_eq!(links.len(), 1);
387    }
388
389    #[test]
390    fn only_exactly_one_licenses_the_rewrites_that_need_a_parent_for_every_child() {
391        assert!(Cardinality::ExactlyOne.links());
392        assert!(Cardinality::ExactlyOne.total());
393        assert!(Cardinality::AtMostOne.links());
394        assert!(!Cardinality::AtMostOne.total(), "a null key matches no parent row");
395        assert!(!Cardinality::Unverified.links(), "an unverified side takes an ordinary join");
396        assert!(!Cardinality::Unverified.total());
397    }
398
399    #[test]
400    fn the_cardinality_tag_round_trips_and_an_unknown_one_is_refused() {
401        for cardinality in
402            [Cardinality::ExactlyOne, Cardinality::AtMostOne, Cardinality::Unverified]
403        {
404            assert_eq!(Cardinality::from_tag(cardinality.tag()).expect("a known tag"), cardinality);
405        }
406        assert!(Cardinality::from_tag(7).is_err());
407    }
408
409    #[test]
410    fn many_to_many_is_declared_as_two_many_to_one_through_the_link_table() {
411        // Section 2.3, checked as documentation as much as as behaviour: there is no way to write a
412        // `part` to `supplier` relationship in this grammar, and `partsupp` being the child of both
413        // is the only shape available.
414        let links = parse_links(
415            "partsupp(ps_partkey) -> part(p_partkey), partsupp(ps_suppkey) -> supplier(s_suppkey)",
416        )
417        .expect("parse");
418        assert_eq!(links.len(), 2);
419        assert_eq!(links[0].child.table, "partsupp");
420        assert_eq!(links[1].child.table, "partsupp");
421    }
422}