kernel/config/join.rs
1use std::hash::{Hash, Hasher};
2
3use serde::{Deserialize, Serialize};
4
5use crate::{Field, common};
6
7use super::Filter;
8
9/// Folds rows from a related `table` into the document. The [`kind`](Self::kind)
10/// names the relationship — which side carries the key, and whether one row or
11/// many fold in; `filters`, `order_by`, and `limit` narrow and shape the rows
12/// that come back.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct Join {
15 pub table: common::TableName,
16 pub kind: JoinKind,
17 pub primary_key: common::ColumnName,
18 /// Whether the folded-in object may be absent. Only meaningful for a to-one
19 /// join (`belongs_to`/`has_one`); a to-many join is always a non-null array.
20 /// A to-one join defaults to nullable unless the schema marks it `required`.
21 #[serde(default)]
22 pub nullable: bool,
23 #[serde(default, skip_serializing_if = "Option::is_none")]
24 pub filters: Option<Vec<Filter>>,
25 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub order_by: Option<Vec<OrderBy>>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
28 pub limit: Option<u64>,
29 pub fields: Vec<Field>,
30 /// Extra column equalities on the hop, ANDed with the key. Never set on a
31 /// [`ManyToMany`](JoinKind::ManyToMany) join.
32 #[serde(default, skip_serializing_if = "Vec::is_empty")]
33 pub on: Vec<ColumnPair>,
34 /// Whether a sink indexes the folded-in rows. `false` still builds them
35 /// into the stored document, but maps the whole subtree as one disabled
36 /// object: nothing under it is searchable, filterable or sortable, and it
37 /// costs one mapping field.
38 #[serde(
39 default = "super::field::enabled",
40 skip_serializing_if = "super::field::is_enabled"
41 )]
42 pub indexed: bool,
43}
44
45/// Hashes `indexed` only when turned off and `on` only when non-empty, so a
46/// schema that never opts in keeps the content hash (and so the physical
47/// index) it had before the fields existed.
48impl Hash for Join {
49 fn hash<H: Hasher>(&self, state: &mut H) {
50 let Self {
51 table,
52 kind,
53 primary_key,
54 nullable,
55 filters,
56 order_by,
57 limit,
58 fields,
59 on,
60 indexed,
61 } = self;
62 table.hash(state);
63 kind.hash(state);
64 primary_key.hash(state);
65 nullable.hash(state);
66 filters.hash(state);
67 order_by.hash(state);
68 limit.hash(state);
69 fields.hash(state);
70 if !indexed {
71 "indexed".hash(state);
72 }
73 hash_on("on", on, state);
74 }
75}
76
77/// One extra equality on a relation hop: `local` is a column of the table
78/// before the hop (the parent row), `related` a column of the hop's table.
79/// SQL equality, so a null on either side matches nothing.
80#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
81pub struct ColumnPair {
82 /// The column on the table before the hop.
83 pub local: common::ColumnName,
84 /// The column on the hop's own table.
85 pub related: common::ColumnName,
86}
87
88/// Hashes a hop's `on` pairs only when there are any, so a relation without
89/// them keeps its content hash. `tag` keeps a `via` hop's pairs apart from the
90/// aggregate's own, which would otherwise hash to the same bytes.
91pub(super) fn hash_on<H: Hasher>(tag: &str, on: &[ColumnPair], state: &mut H) {
92 if !on.is_empty() {
93 tag.hash(state);
94 on.hash(state);
95 }
96}
97
98/// The relationship a join expresses. The verb carries both the cardinality
99/// and — the part that matters — **which table holds the key**:
100///
101/// - [`BelongsTo`](Self::BelongsTo): *this* row points at the related row
102/// (`column` is on the parent table) → a single object.
103/// - [`HasOne`](Self::HasOne) / [`HasMany`](Self::HasMany): the related rows
104/// point back at this one (`foreign_key` is on the related table) → a single
105/// object / a nested array.
106/// - [`ManyToMany`](Self::ManyToMany): both sides are keyed through a junction
107/// table → a nested array.
108#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
109#[serde(rename_all = "snake_case")]
110pub enum JoinKind {
111 BelongsTo { column: common::ColumnName },
112 HasOne { foreign_key: common::ColumnName },
113 HasMany { foreign_key: common::ColumnName },
114 ManyToMany { through: Through },
115}
116
117impl JoinKind {
118 /// Whether this join folds in many rows (a nested array) rather than one
119 /// (an object).
120 pub fn is_to_many(&self) -> bool {
121 matches!(self, JoinKind::HasMany { .. } | JoinKind::ManyToMany { .. })
122 }
123}
124
125/// How an aggregate's related rows tie back to the parent — a direct FK on the
126/// aggregated table, or a junction table. (Joins carry their key inside
127/// [`JoinKind`]; aggregates are inherently over-many, so `belongs_to` has no
128/// aggregate counterpart.)
129#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
130#[serde(rename_all = "snake_case")]
131pub enum AggregateKey {
132 Direct(common::ColumnName),
133 Through(Through),
134}
135
136/// A junction table linking two sides of a many-to-many relation.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct Through {
139 pub table: common::TableName,
140 pub left_key: common::ColumnName,
141 pub right_key: common::ColumnName,
142 /// Conditions on the junction rows themselves (e.g. skip cancelled order
143 /// lines), as opposed to the relation's own `filters` on the target table.
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub filters: Option<Vec<Filter>>,
146}
147
148/// Hashes `filters` only when non-empty, so a schema that never opts in keeps
149/// the content hash (and so the physical index) it had before the field existed.
150impl Hash for Through {
151 fn hash<H: Hasher>(&self, state: &mut H) {
152 let Self {
153 table,
154 left_key,
155 right_key,
156 filters,
157 } = self;
158 table.hash(state);
159 left_key.hash(state);
160 right_key.hash(state);
161 if let Some(filters) = filters.as_deref().filter(|f| !f.is_empty()) {
162 filters.hash(state);
163 }
164 }
165}
166
167#[derive(Debug, Clone, Hash, Serialize, Deserialize)]
168pub struct OrderBy {
169 pub column: common::ColumnName,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub direction: Option<Direction>,
172}
173
174#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
175#[serde(rename_all = "snake_case")]
176pub enum Direction {
177 Asc,
178 Desc,
179}