1use std::collections::HashMap;
7
8use crate::{
9 db,
10 events::{GenericEvent, HydrationRow},
11};
12
13#[derive(Debug, Clone)]
15pub struct TreeSpec {
16 pub table_name: &'static str,
17 pub events_table_name: &'static str,
18 pub parent_column: Option<&'static str>,
19 pub soft_delete: bool,
20 pub forgettable_table_name: Option<&'static str>,
21 pub event_context: bool,
22 pub snapshot_table_name: Option<&'static str>,
24 pub snapshot_fingerprint: i64,
27 pub children: Vec<TreeSpec>,
28}
29
30pub struct TreeQuerySource<Id> {
32 pub user_sql: &'static str,
33 pub order_by_cols: &'static [&'static str],
34 pub n_user_args: usize,
35 pub decode: fn(&db::Row) -> Result<HydrationRow<Id>, sqlx::Error>,
36 pub snapshot_fingerprint: i64,
41}
42
43pub fn decode_tag(row: &db::Row) -> Result<i32, sqlx::Error> {
44 sqlx::Row::try_get(row, "tag")
45}
46
47pub fn decode_tagged_row<Id>(row: &db::Row) -> Result<HydrationRow<Id>, sqlx::Error>
51where
52 Id: for<'r> sqlx::Decode<'r, db::Db> + sqlx::Type<db::Db>,
53{
54 use sqlx::Row;
55 Ok(GenericEvent {
56 entity_id: row.try_get("entity_id")?,
57 sequence: row.try_get("sequence")?,
58 event: row.try_get("event")?,
59 context: row.try_get("context")?,
60 recorded_at: row.try_get("recorded_at")?,
61 forgettable_payload: row.try_get("forgettable_payload")?,
62 }
63 .into())
64}
65
66pub fn decode_tagged_snapshot_row<Id>(row: &db::Row) -> Result<HydrationRow<Id>, sqlx::Error>
70where
71 Id: for<'r> sqlx::Decode<'r, db::Db> + sqlx::Type<db::Db>,
72{
73 use sqlx::Row;
74 Ok(HydrationRow {
75 entity_id: row.try_get("entity_id")?,
76 sequence: row.try_get("sequence")?,
77 event: row.try_get("event")?,
78 context: row.try_get("context")?,
79 recorded_at: row.try_get("recorded_at")?,
80 forgettable_payload: row.try_get("forgettable_payload")?,
81 snapshot: row.try_get("snapshot")?,
82 snapshot_sequence: row.try_get("snapshot_sequence")?,
83 snapshot_recorded_at: row.try_get("snapshot_recorded_at")?,
84 snapshot_first_recorded_at: row.try_get("snapshot_first_recorded_at")?,
85 snapshot_forgettable_payload: row.try_get("snapshot_forgettable_payload")?,
86 })
87}
88
89pub fn tree_has_snapshot(spec: &TreeSpec) -> bool {
94 spec.snapshot_table_name.is_some() || spec.children.iter().any(tree_has_snapshot)
95}
96
97pub fn snapshot_fingerprints(spec: &TreeSpec) -> Vec<i64> {
101 let mut out = Vec::new();
102 collect_snapshot_fingerprints(spec, &mut out);
103 out
104}
105
106fn collect_snapshot_fingerprints(spec: &TreeSpec, out: &mut Vec<i64>) {
107 if spec.snapshot_table_name.is_some() {
108 out.push(spec.snapshot_fingerprint);
109 }
110 for child in &spec.children {
111 collect_snapshot_fingerprints(child, out);
112 }
113}
114
115pub fn partition_by_tag(rows: Vec<db::Row>) -> Result<HashMap<i32, Vec<db::Row>>, sqlx::Error> {
116 let mut by_tag: HashMap<i32, Vec<db::Row>> = HashMap::new();
117 for row in rows {
118 let tag = decode_tag(&row)?;
119 by_tag.entry(tag).or_default().push(row);
120 }
121 Ok(by_tag)
122}
123
124#[allow(clippy::too_many_arguments)]
125fn branch_sql(
126 tag: i32,
127 cte_name: &str,
128 node: &TreeSpec,
129 is_root: bool,
130 ctx_param_idx: usize,
131 fp_idx: Option<usize>,
132 uniform_snapshot_columns: bool,
133) -> String {
134 let context_expr = if is_root {
135 format!("CASE WHEN ${ctx_param_idx} THEN e.context ELSE NULL::jsonb END")
136 } else if node.event_context {
137 "e.context".to_string()
138 } else {
139 "NULL::jsonb".to_string()
140 };
141 let (payload_expr, forgettable_join) = match node.forgettable_table_name {
142 Some(tbl) => (
143 "p.payload".to_string(),
144 format!(" LEFT JOIN {tbl} p ON e.id = p.entity_id AND e.sequence = p.sequence"),
145 ),
146 None => ("NULL::jsonb".to_string(), String::new()),
147 };
148 let ord_expr = if is_root { "i.__ord" } else { "NULL::BIGINT" };
149 let events_table = node.events_table_name;
150
151 if let Some(snap_tbl) = node.snapshot_table_name {
152 let fp_idx = fp_idx.expect("a snapshot node always has a fingerprint bind index");
153 let (snapshot_payload_expr, snapshot_payload_join) =
154 match (node.forgettable_table_name, node.snapshot_table_name) {
155 (Some(fp_tbl), Some(_)) => (
156 "sp.payload".to_string(),
157 format!(" LEFT JOIN {fp_tbl} sp ON sp.entity_id = i.id AND sp.sequence = 0"),
158 ),
159 _ => ("NULL::jsonb".to_string(), String::new()),
160 };
161 format!(
162 "SELECT {tag} AS tag, i.id AS entity_id, COALESCE(e.sequence, s.sequence) AS sequence, \
163 e.event, {context_expr} AS context, e.recorded_at, {payload_expr} AS forgettable_payload, \
164 CASE WHEN e.sequence IS NULL OR e.sequence = s.sequence + 1 THEN s.snapshot END AS snapshot, \
165 s.sequence AS snapshot_sequence, s.recorded_at AS snapshot_recorded_at, \
166 s.first_recorded_at AS snapshot_first_recorded_at, \
167 {snapshot_payload_expr} AS snapshot_forgettable_payload, {ord_expr} AS __ord \
168 FROM {cte_name} i \
169 LEFT JOIN {snap_tbl} s ON s.id = i.id AND s.fingerprint = ${fp_idx} \
170 LEFT JOIN {events_table} e ON e.id = i.id AND e.sequence > COALESCE(s.sequence, 0)\
171 {forgettable_join}{snapshot_payload_join}"
172 )
173 } else if uniform_snapshot_columns {
174 format!(
175 "SELECT {tag} AS tag, i.id AS entity_id, e.sequence, e.event, {context_expr} AS context, \
176 e.recorded_at, {payload_expr} AS forgettable_payload, \
177 NULL::jsonb AS snapshot, NULL::INT AS snapshot_sequence, \
178 NULL::TIMESTAMPTZ AS snapshot_recorded_at, NULL::TIMESTAMPTZ AS snapshot_first_recorded_at, \
179 NULL::jsonb AS snapshot_forgettable_payload, {ord_expr} AS __ord \
180 FROM {cte_name} i JOIN {events_table} e ON i.id = e.id{forgettable_join}"
181 )
182 } else {
183 format!(
184 "SELECT {tag} AS tag, i.id AS entity_id, e.sequence, e.event, {context_expr} AS context, \
185 e.recorded_at, {payload_expr} AS forgettable_payload, {ord_expr} AS __ord \
186 FROM {cte_name} i JOIN {events_table} e ON i.id = e.id{forgettable_join}"
187 )
188 }
189}
190
191pub fn build_tree_query(
192 user_sql: &str,
193 order_by_cols: &[&str],
194 spec: &TreeSpec,
195 include_deleted: bool,
196 ctx_param_idx: usize,
197) -> String {
198 let order_clause = if order_by_cols.is_empty() {
199 "id".to_string()
200 } else {
201 order_by_cols.join(", ")
202 };
203
204 let mut ctes = vec![
205 format!("__user AS ({user_sql})"),
206 format!(
207 "entities AS (SELECT *, ROW_NUMBER() OVER (ORDER BY {order_clause}) AS __ord FROM __user)"
208 ),
209 ];
210
211 let uniform = tree_has_snapshot(spec);
212 let mut fp_cursor = ctx_param_idx + 1;
213 let root_fp_idx = take_fp_idx(spec, &mut fp_cursor);
214 let mut branches = vec![branch_sql(
215 0,
216 "entities",
217 spec,
218 true,
219 ctx_param_idx,
220 root_fp_idx,
221 uniform,
222 )];
223 let mut cursor: i32 = 1;
224
225 walk_children(
226 spec,
227 "entities",
228 include_deleted,
229 ctx_param_idx,
230 &mut fp_cursor,
231 &mut cursor,
232 &mut ctes,
233 &mut branches,
234 uniform,
235 );
236
237 format!(
238 "WITH {} {} ORDER BY tag, __ord, entity_id, sequence",
239 ctes.join(", "),
240 branches.join(" UNION ALL "),
241 )
242}
243
244fn take_fp_idx(node: &TreeSpec, fp_cursor: &mut usize) -> Option<usize> {
245 if node.snapshot_table_name.is_some() {
246 let idx = *fp_cursor;
247 *fp_cursor += 1;
248 Some(idx)
249 } else {
250 None
251 }
252}
253
254#[allow(clippy::too_many_arguments)]
255fn walk_children(
256 node: &TreeSpec,
257 parent_cte: &str,
258 include_deleted: bool,
259 ctx_param_idx: usize,
260 fp_cursor: &mut usize,
261 cursor: &mut i32,
262 ctes: &mut Vec<String>,
263 branches: &mut Vec<String>,
264 uniform: bool,
265) {
266 for child in &node.children {
267 let tag = *cursor;
268 *cursor += 1;
269 let cte_name = format!("n{tag}");
270 let parent_col = child
271 .parent_column
272 .expect("non-root tree node must declare a parent column");
273 let deleted_cond = if child.soft_delete && !include_deleted {
274 " AND deleted = FALSE"
275 } else {
276 ""
277 };
278 ctes.push(format!(
279 "{cte_name} AS (SELECT id FROM {} WHERE {parent_col} IN (SELECT id FROM {parent_cte}){deleted_cond})",
280 child.table_name
281 ));
282 let fp_idx = take_fp_idx(child, fp_cursor);
283 branches.push(branch_sql(
284 tag,
285 &cte_name,
286 child,
287 false,
288 ctx_param_idx,
289 fp_idx,
290 uniform,
291 ));
292 walk_children(
293 child,
294 &cte_name,
295 include_deleted,
296 ctx_param_idx,
297 fp_cursor,
298 cursor,
299 ctes,
300 branches,
301 uniform,
302 );
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309 use crate::snapshot::NO_SNAPSHOT_FINGERPRINT;
310
311 fn leaf(table: &'static str, events: &'static str, parent_col: &'static str) -> TreeSpec {
312 TreeSpec {
313 table_name: table,
314 events_table_name: events,
315 parent_column: Some(parent_col),
316 soft_delete: false,
317 forgettable_table_name: None,
318 event_context: false,
319 snapshot_table_name: None,
320 snapshot_fingerprint: NO_SNAPSHOT_FINGERPRINT,
321 children: Vec::new(),
322 }
323 }
324
325 fn snapshotted(spec: TreeSpec, tbl: &'static str, fingerprint: i64) -> TreeSpec {
326 TreeSpec {
327 snapshot_table_name: Some(tbl),
328 snapshot_fingerprint: fingerprint,
329 ..spec
330 }
331 }
332
333 fn root(table: &'static str, events: &'static str, children: Vec<TreeSpec>) -> TreeSpec {
334 TreeSpec {
335 table_name: table,
336 events_table_name: events,
337 parent_column: None,
338 soft_delete: false,
339 forgettable_table_name: None,
340 event_context: false,
341 snapshot_table_name: None,
342 snapshot_fingerprint: NO_SNAPSHOT_FINGERPRINT,
343 children,
344 }
345 }
346
347 #[test]
348 fn leaf_only_root() {
349 let spec = root("subscriptions", "subscription_events", Vec::new());
350 let sql = build_tree_query(
351 "SELECT id FROM subscriptions WHERE id = $1",
352 &[],
353 &spec,
354 false,
355 2,
356 );
357 assert_eq!(
358 sql,
359 "WITH __user AS (SELECT id FROM subscriptions WHERE id = $1), \
360 entities AS (SELECT *, ROW_NUMBER() OVER (ORDER BY id) AS __ord FROM __user) \
361 SELECT 0 AS tag, i.id AS entity_id, e.sequence, e.event, \
362 CASE WHEN $2 THEN e.context ELSE NULL::jsonb END AS context, e.recorded_at, \
363 NULL::jsonb AS forgettable_payload, i.__ord AS __ord \
364 FROM entities i JOIN subscription_events e ON i.id = e.id \
365 ORDER BY tag, __ord, entity_id, sequence"
366 );
367 }
368
369 #[test]
370 fn one_child() {
371 let child = TreeSpec {
372 soft_delete: true,
373 ..leaf(
374 "billing_periods",
375 "billing_period_events",
376 "subscription_id",
377 )
378 };
379 let spec = root("subscriptions", "subscription_events", vec![child]);
380 let sql = build_tree_query(
381 "SELECT id FROM subscriptions WHERE id = $1",
382 &[],
383 &spec,
384 false,
385 2,
386 );
387 assert!(sql.contains(
388 "n1 AS (SELECT id FROM billing_periods WHERE subscription_id IN (SELECT id FROM entities) AND deleted = FALSE)"
389 ));
390 assert!(sql.contains(
391 "SELECT 1 AS tag, i.id AS entity_id, e.sequence, e.event, NULL::jsonb AS context, \
392 e.recorded_at, NULL::jsonb AS forgettable_payload, NULL::BIGINT AS __ord \
393 FROM n1 i JOIN billing_period_events e ON i.id = e.id"
394 ));
395 }
396
397 #[test]
398 fn two_children_first_has_grandchild() {
399 let grandchild = leaf("line_items", "line_item_events", "billing_period_id");
400 let child_a = TreeSpec {
401 children: vec![grandchild],
402 ..leaf(
403 "billing_periods",
404 "billing_period_events",
405 "subscription_id",
406 )
407 };
408 let child_b = leaf("invoices", "invoice_events", "subscription_id");
409 let spec = root(
410 "subscriptions",
411 "subscription_events",
412 vec![child_a, child_b],
413 );
414 let sql = build_tree_query(
415 "SELECT id FROM subscriptions WHERE id = $1",
416 &[],
417 &spec,
418 false,
419 2,
420 );
421 assert!(sql.contains("n1 AS (SELECT id FROM billing_periods WHERE subscription_id IN (SELECT id FROM entities)"));
422 assert!(sql.contains(
423 "n2 AS (SELECT id FROM line_items WHERE billing_period_id IN (SELECT id FROM n1)"
424 ));
425 assert!(sql.contains(
426 "n3 AS (SELECT id FROM invoices WHERE subscription_id IN (SELECT id FROM entities)"
427 ));
428 assert!(sql.contains("SELECT 1 AS tag"));
429 assert!(sql.contains("SELECT 2 AS tag"));
430 assert!(sql.contains("SELECT 3 AS tag"));
431 }
432
433 #[test]
434 fn soft_delete_and_forgettable_and_inlined_context() {
435 let child = TreeSpec {
436 soft_delete: true,
437 forgettable_table_name: Some("order_items_forgettable_payloads"),
438 event_context: true,
439 ..leaf("order_items", "order_item_events", "order_id")
440 };
441 let spec = root("orders", "order_events", vec![child]);
442 let sql = build_tree_query("SELECT id FROM orders WHERE id = $1", &[], &spec, false, 2);
443 assert!(sql.contains("AND deleted = FALSE)"));
444 assert!(sql.contains(
445 "LEFT JOIN order_items_forgettable_payloads p ON e.id = p.entity_id AND e.sequence = p.sequence"
446 ));
447 assert!(sql.contains("p.payload AS forgettable_payload"));
448 assert!(sql.contains(
449 "SELECT 1 AS tag, i.id AS entity_id, e.sequence, e.event, e.context AS context"
450 ));
451 }
452
453 #[test]
454 fn include_deleted_drops_deleted_condition_transitively() {
455 let grandchild = TreeSpec {
456 soft_delete: true,
457 ..leaf("line_items", "line_item_events", "billing_period_id")
458 };
459 let child = TreeSpec {
460 soft_delete: true,
461 children: vec![grandchild],
462 ..leaf(
463 "billing_periods",
464 "billing_period_events",
465 "subscription_id",
466 )
467 };
468 let spec = root("subscriptions", "subscription_events", vec![child]);
469 let sql = build_tree_query(
470 "SELECT id FROM subscriptions WHERE id = $1",
471 &[],
472 &spec,
473 true,
474 2,
475 );
476 assert!(!sql.contains("deleted = FALSE"));
477 }
478
479 #[test]
480 fn empty_order_by_defaults_to_id() {
481 let spec = root("subscriptions", "subscription_events", Vec::new());
482 let sql = build_tree_query(
483 "SELECT id FROM subscriptions WHERE id = $1",
484 &[],
485 &spec,
486 false,
487 2,
488 );
489 assert!(sql.contains("ROW_NUMBER() OVER (ORDER BY id) AS __ord"));
490 }
491
492 #[test]
493 fn explicit_order_by_is_unprefixed_in_the_entities_cte() {
494 let spec = root("entities", "entity_events", Vec::new());
495 let sql = build_tree_query(
496 "SELECT name, id FROM entities ORDER BY name, id LIMIT $1",
497 &["name", "id"],
498 &spec,
499 false,
500 2,
501 );
502 assert!(sql.contains("ROW_NUMBER() OVER (ORDER BY name, id) AS __ord"));
503 }
504
505 #[test]
506 fn plain_tree_has_no_snapshot_columns_at_all() {
507 let child = leaf("meters", "meter_events", "site_id");
510 let spec = root("sites", "site_events", vec![child]);
511 assert!(!tree_has_snapshot(&spec));
512 let sql = build_tree_query("SELECT id FROM sites WHERE id = $1", &[], &spec, false, 2);
513 assert!(!sql.contains("snapshot"));
514 assert!(sql.contains(
515 "SELECT 0 AS tag, i.id AS entity_id, e.sequence, e.event, \
516 CASE WHEN $2 THEN e.context ELSE NULL::jsonb END AS context, e.recorded_at, \
517 NULL::jsonb AS forgettable_payload, i.__ord AS __ord \
518 FROM entities i JOIN site_events e ON i.id = e.id"
519 ));
520 }
521
522 #[test]
523 fn snapshot_root_plain_child() {
524 let child = leaf("meters", "meter_events", "site_id");
525 let spec = snapshotted(
526 root("sites", "site_events", vec![child]),
527 "site_snapshots",
528 111,
529 );
530 assert!(tree_has_snapshot(&spec));
531 let sql = build_tree_query("SELECT id FROM sites WHERE id = $1", &[], &spec, false, 2);
532 assert!(sql.contains("LEFT JOIN site_snapshots s ON s.id = i.id AND s.fingerprint = $3"));
534 assert!(sql.contains("COALESCE(e.sequence, s.sequence) AS sequence"));
535 assert!(sql.contains(
537 "SELECT 1 AS tag, i.id AS entity_id, e.sequence, e.event, NULL::jsonb AS context, \
538 e.recorded_at, NULL::jsonb AS forgettable_payload, \
539 NULL::jsonb AS snapshot, NULL::INT AS snapshot_sequence, \
540 NULL::TIMESTAMPTZ AS snapshot_recorded_at, NULL::TIMESTAMPTZ AS snapshot_first_recorded_at, \
541 NULL::jsonb AS snapshot_forgettable_payload, NULL::BIGINT AS __ord \
542 FROM n1 i JOIN meter_events e ON i.id = e.id"
543 ));
544 }
545
546 #[test]
547 fn plain_root_snapshot_child() {
548 let child = snapshotted(
549 leaf("meters", "meter_events", "site_id"),
550 "meter_snapshots",
551 222,
552 );
553 let spec = root("sites", "site_events", vec![child]);
554 assert!(tree_has_snapshot(&spec));
555 let sql = build_tree_query("SELECT id FROM sites WHERE id = $1", &[], &spec, false, 2);
556 assert!(sql.contains(
558 "SELECT 0 AS tag, i.id AS entity_id, e.sequence, e.event, \
559 CASE WHEN $2 THEN e.context ELSE NULL::jsonb END AS context, e.recorded_at, \
560 NULL::jsonb AS forgettable_payload, \
561 NULL::jsonb AS snapshot, NULL::INT AS snapshot_sequence, \
562 NULL::TIMESTAMPTZ AS snapshot_recorded_at, NULL::TIMESTAMPTZ AS snapshot_first_recorded_at, \
563 NULL::jsonb AS snapshot_forgettable_payload, i.__ord AS __ord \
564 FROM entities i JOIN site_events e ON i.id = e.id"
565 ));
566 assert!(sql.contains("LEFT JOIN meter_snapshots s ON s.id = i.id AND s.fingerprint = $3"));
568 }
569
570 #[test]
571 fn both_snapshot_param_numbering() {
572 let child = snapshotted(
573 leaf("meters", "meter_events", "site_id"),
574 "meter_snapshots",
575 222,
576 );
577 let spec = snapshotted(
578 root("sites", "site_events", vec![child]),
579 "site_snapshots",
580 111,
581 );
582 let sql = build_tree_query("SELECT id FROM sites WHERE id = $1", &[], &spec, false, 2);
583 assert!(sql.contains("LEFT JOIN site_snapshots s ON s.id = i.id AND s.fingerprint = $3"));
585 assert!(sql.contains("LEFT JOIN meter_snapshots s ON s.id = i.id AND s.fingerprint = $4"));
586 }
587
588 #[test]
589 fn snapshot_fingerprints_walks_dfs_order_only_snapshot_nodes() {
590 let grandchild = snapshotted(
591 leaf("line_items", "line_item_events", "billing_period_id"),
592 "line_item_snapshots",
593 3,
594 );
595 let child_a = TreeSpec {
596 children: vec![grandchild],
597 ..snapshotted(
598 leaf(
599 "billing_periods",
600 "billing_period_events",
601 "subscription_id",
602 ),
603 "billing_period_snapshots",
604 2,
605 )
606 };
607 let child_b = leaf("invoices", "invoice_events", "subscription_id");
608 let spec = snapshotted(
609 root(
610 "subscriptions",
611 "subscription_events",
612 vec![child_a, child_b],
613 ),
614 "subscription_snapshots",
615 1,
616 );
617 assert_eq!(snapshot_fingerprints(&spec), vec![1, 2, 3]);
618 }
619}