1use std::collections::HashMap;
7
8use crate::{db, events::GenericEvent};
9
10#[derive(Debug, Clone)]
12pub struct TreeSpec {
13 pub table_name: &'static str,
14 pub events_table_name: &'static str,
15 pub parent_column: Option<&'static str>,
16 pub soft_delete: bool,
17 pub forgettable_table_name: Option<&'static str>,
18 pub event_context: bool,
19 pub children: Vec<TreeSpec>,
20}
21
22pub struct TreeQuerySource<Id> {
24 pub user_sql: &'static str,
25 pub order_by_cols: &'static [&'static str],
26 pub n_user_args: usize,
27 pub decode: fn(&db::Row) -> Result<GenericEvent<Id>, sqlx::Error>,
28}
29
30pub fn decode_tag(row: &db::Row) -> Result<i32, sqlx::Error> {
31 sqlx::Row::try_get(row, "tag")
32}
33
34pub fn decode_tagged_row<Id>(row: &db::Row) -> Result<GenericEvent<Id>, sqlx::Error>
35where
36 Id: for<'r> sqlx::Decode<'r, db::Db> + sqlx::Type<db::Db>,
37{
38 use sqlx::Row;
39 Ok(GenericEvent {
40 entity_id: row.try_get("entity_id")?,
41 sequence: row.try_get("sequence")?,
42 event: row.try_get("event")?,
43 context: row.try_get("context")?,
44 recorded_at: row.try_get("recorded_at")?,
45 forgettable_payload: row.try_get("forgettable_payload")?,
46 })
47}
48
49pub fn partition_by_tag(rows: Vec<db::Row>) -> Result<HashMap<i32, Vec<db::Row>>, sqlx::Error> {
50 let mut by_tag: HashMap<i32, Vec<db::Row>> = HashMap::new();
51 for row in rows {
52 let tag = decode_tag(&row)?;
53 by_tag.entry(tag).or_default().push(row);
54 }
55 Ok(by_tag)
56}
57
58fn branch_sql(
59 tag: i32,
60 cte_name: &str,
61 node: &TreeSpec,
62 is_root: bool,
63 ctx_param_idx: usize,
64) -> String {
65 let context_expr = if is_root {
66 format!("CASE WHEN ${ctx_param_idx} THEN e.context ELSE NULL::jsonb END")
67 } else if node.event_context {
68 "e.context".to_string()
69 } else {
70 "NULL::jsonb".to_string()
71 };
72 let (payload_expr, forgettable_join) = match node.forgettable_table_name {
73 Some(tbl) => (
74 "p.payload".to_string(),
75 format!(" LEFT JOIN {tbl} p ON e.id = p.entity_id AND e.sequence = p.sequence"),
76 ),
77 None => ("NULL::jsonb".to_string(), String::new()),
78 };
79 let ord_expr = if is_root { "i.__ord" } else { "NULL::BIGINT" };
80 format!(
81 "SELECT {tag} AS tag, i.id AS entity_id, e.sequence, e.event, {context_expr} AS context, \
82 e.recorded_at, {payload_expr} AS forgettable_payload, {ord_expr} AS __ord \
83 FROM {cte_name} i JOIN {events_table} e ON i.id = e.id{forgettable_join}",
84 events_table = node.events_table_name,
85 )
86}
87
88pub fn build_tree_query(
89 user_sql: &str,
90 order_by_cols: &[&str],
91 spec: &TreeSpec,
92 include_deleted: bool,
93 ctx_param_idx: usize,
94) -> String {
95 let order_clause = if order_by_cols.is_empty() {
96 "id".to_string()
97 } else {
98 order_by_cols.join(", ")
99 };
100
101 let mut ctes = vec![
102 format!("__user AS ({user_sql})"),
103 format!(
104 "entities AS (SELECT *, ROW_NUMBER() OVER (ORDER BY {order_clause}) AS __ord FROM __user)"
105 ),
106 ];
107 let mut branches = vec![branch_sql(0, "entities", spec, true, ctx_param_idx)];
108 let mut cursor: i32 = 1;
109
110 walk_children(
111 spec,
112 "entities",
113 include_deleted,
114 ctx_param_idx,
115 &mut cursor,
116 &mut ctes,
117 &mut branches,
118 );
119
120 format!(
121 "WITH {} {} ORDER BY tag, __ord, entity_id, sequence",
122 ctes.join(", "),
123 branches.join(" UNION ALL "),
124 )
125}
126
127#[allow(clippy::too_many_arguments)]
128fn walk_children(
129 node: &TreeSpec,
130 parent_cte: &str,
131 include_deleted: bool,
132 ctx_param_idx: usize,
133 cursor: &mut i32,
134 ctes: &mut Vec<String>,
135 branches: &mut Vec<String>,
136) {
137 for child in &node.children {
138 let tag = *cursor;
139 *cursor += 1;
140 let cte_name = format!("n{tag}");
141 let parent_col = child
142 .parent_column
143 .expect("non-root tree node must declare a parent column");
144 let deleted_cond = if child.soft_delete && !include_deleted {
145 " AND deleted = FALSE"
146 } else {
147 ""
148 };
149 ctes.push(format!(
150 "{cte_name} AS (SELECT id FROM {} WHERE {parent_col} IN (SELECT id FROM {parent_cte}){deleted_cond})",
151 child.table_name
152 ));
153 branches.push(branch_sql(tag, &cte_name, child, false, ctx_param_idx));
154 walk_children(
155 child,
156 &cte_name,
157 include_deleted,
158 ctx_param_idx,
159 cursor,
160 ctes,
161 branches,
162 );
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169
170 fn leaf(table: &'static str, events: &'static str, parent_col: &'static str) -> TreeSpec {
171 TreeSpec {
172 table_name: table,
173 events_table_name: events,
174 parent_column: Some(parent_col),
175 soft_delete: false,
176 forgettable_table_name: None,
177 event_context: false,
178 children: Vec::new(),
179 }
180 }
181
182 fn root(table: &'static str, events: &'static str, children: Vec<TreeSpec>) -> TreeSpec {
183 TreeSpec {
184 table_name: table,
185 events_table_name: events,
186 parent_column: None,
187 soft_delete: false,
188 forgettable_table_name: None,
189 event_context: false,
190 children,
191 }
192 }
193
194 #[test]
195 fn leaf_only_root() {
196 let spec = root("subscriptions", "subscription_events", Vec::new());
197 let sql = build_tree_query(
198 "SELECT id FROM subscriptions WHERE id = $1",
199 &[],
200 &spec,
201 false,
202 2,
203 );
204 assert_eq!(
205 sql,
206 "WITH __user AS (SELECT id FROM subscriptions WHERE id = $1), \
207 entities AS (SELECT *, ROW_NUMBER() OVER (ORDER BY id) AS __ord FROM __user) \
208 SELECT 0 AS tag, i.id AS entity_id, e.sequence, e.event, \
209 CASE WHEN $2 THEN e.context ELSE NULL::jsonb END AS context, e.recorded_at, \
210 NULL::jsonb AS forgettable_payload, i.__ord AS __ord \
211 FROM entities i JOIN subscription_events e ON i.id = e.id \
212 ORDER BY tag, __ord, entity_id, sequence"
213 );
214 }
215
216 #[test]
217 fn one_child() {
218 let child = TreeSpec {
219 soft_delete: true,
220 ..leaf(
221 "billing_periods",
222 "billing_period_events",
223 "subscription_id",
224 )
225 };
226 let spec = root("subscriptions", "subscription_events", vec![child]);
227 let sql = build_tree_query(
228 "SELECT id FROM subscriptions WHERE id = $1",
229 &[],
230 &spec,
231 false,
232 2,
233 );
234 assert!(sql.contains(
235 "n1 AS (SELECT id FROM billing_periods WHERE subscription_id IN (SELECT id FROM entities) AND deleted = FALSE)"
236 ));
237 assert!(sql.contains(
238 "SELECT 1 AS tag, i.id AS entity_id, e.sequence, e.event, NULL::jsonb AS context, \
239 e.recorded_at, NULL::jsonb AS forgettable_payload, NULL::BIGINT AS __ord \
240 FROM n1 i JOIN billing_period_events e ON i.id = e.id"
241 ));
242 }
243
244 #[test]
245 fn two_children_first_has_grandchild() {
246 let grandchild = leaf("line_items", "line_item_events", "billing_period_id");
247 let child_a = TreeSpec {
248 children: vec![grandchild],
249 ..leaf(
250 "billing_periods",
251 "billing_period_events",
252 "subscription_id",
253 )
254 };
255 let child_b = leaf("invoices", "invoice_events", "subscription_id");
256 let spec = root(
257 "subscriptions",
258 "subscription_events",
259 vec![child_a, child_b],
260 );
261 let sql = build_tree_query(
262 "SELECT id FROM subscriptions WHERE id = $1",
263 &[],
264 &spec,
265 false,
266 2,
267 );
268 assert!(sql.contains("n1 AS (SELECT id FROM billing_periods WHERE subscription_id IN (SELECT id FROM entities)"));
269 assert!(sql.contains(
270 "n2 AS (SELECT id FROM line_items WHERE billing_period_id IN (SELECT id FROM n1)"
271 ));
272 assert!(sql.contains(
273 "n3 AS (SELECT id FROM invoices WHERE subscription_id IN (SELECT id FROM entities)"
274 ));
275 assert!(sql.contains("SELECT 1 AS tag"));
276 assert!(sql.contains("SELECT 2 AS tag"));
277 assert!(sql.contains("SELECT 3 AS tag"));
278 }
279
280 #[test]
281 fn soft_delete_and_forgettable_and_inlined_context() {
282 let child = TreeSpec {
283 soft_delete: true,
284 forgettable_table_name: Some("order_items_forgettable_payloads"),
285 event_context: true,
286 ..leaf("order_items", "order_item_events", "order_id")
287 };
288 let spec = root("orders", "order_events", vec![child]);
289 let sql = build_tree_query("SELECT id FROM orders WHERE id = $1", &[], &spec, false, 2);
290 assert!(sql.contains("AND deleted = FALSE)"));
291 assert!(sql.contains(
292 "LEFT JOIN order_items_forgettable_payloads p ON e.id = p.entity_id AND e.sequence = p.sequence"
293 ));
294 assert!(sql.contains("p.payload AS forgettable_payload"));
295 assert!(sql.contains(
296 "SELECT 1 AS tag, i.id AS entity_id, e.sequence, e.event, e.context AS context"
297 ));
298 }
299
300 #[test]
301 fn include_deleted_drops_deleted_condition_transitively() {
302 let grandchild = TreeSpec {
303 soft_delete: true,
304 ..leaf("line_items", "line_item_events", "billing_period_id")
305 };
306 let child = TreeSpec {
307 soft_delete: true,
308 children: vec![grandchild],
309 ..leaf(
310 "billing_periods",
311 "billing_period_events",
312 "subscription_id",
313 )
314 };
315 let spec = root("subscriptions", "subscription_events", vec![child]);
316 let sql = build_tree_query(
317 "SELECT id FROM subscriptions WHERE id = $1",
318 &[],
319 &spec,
320 true,
321 2,
322 );
323 assert!(!sql.contains("deleted = FALSE"));
324 }
325
326 #[test]
327 fn empty_order_by_defaults_to_id() {
328 let spec = root("subscriptions", "subscription_events", Vec::new());
329 let sql = build_tree_query(
330 "SELECT id FROM subscriptions WHERE id = $1",
331 &[],
332 &spec,
333 false,
334 2,
335 );
336 assert!(sql.contains("ROW_NUMBER() OVER (ORDER BY id) AS __ord"));
337 }
338
339 #[test]
340 fn explicit_order_by_is_unprefixed_in_the_entities_cte() {
341 let spec = root("entities", "entity_events", Vec::new());
342 let sql = build_tree_query(
343 "SELECT name, id FROM entities ORDER BY name, id LIMIT $1",
344 &["name", "id"],
345 &spec,
346 false,
347 2,
348 );
349 assert!(sql.contains("ROW_NUMBER() OVER (ORDER BY name, id) AS __ord"));
350 }
351}