1use std::collections::BTreeMap;
23
24use crate::cardinality::{AccessParadigm, ColumnStats};
25use crate::cost_model::{CostEstimator, OperatorKind};
26use crate::join_enumerator::{enumerate_dpccp_with_cost_estimator, JoinPlan};
27use crate::join_graph::{JoinEdge, JoinGraph, JoinGraphResult};
28
29#[derive(Debug, Clone)]
31pub struct JoinRelation {
32 pub alias: String,
33 pub cardinality: f64,
34 pub column_stats: BTreeMap<String, ColumnStats>,
35 pub access_cost: f64,
37 pub paradigm: AccessParadigm,
39 pub source_id: u64,
42}
43
44#[derive(Debug, Clone)]
46pub struct JoinPredicate {
47 pub left_alias: String,
48 pub right_alias: String,
49 pub left_field: String,
50 pub right_field: String,
51}
52
53#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum JoinAlgorithm {
56 Hash,
57}
58
59#[derive(Debug, Clone)]
61pub struct JoinCondition {
62 pub left_field: String,
63 pub right_field: String,
64}
65
66#[derive(Debug, Clone)]
69pub enum JoinOrderTree {
70 Scan(JoinRelation),
72 Inner {
74 algorithm: JoinAlgorithm,
75 condition: JoinCondition,
76 left: Box<JoinOrderTree>,
77 right: Box<JoinOrderTree>,
78 },
79 Cross {
81 left: Box<JoinOrderTree>,
82 right: Box<JoinOrderTree>,
83 },
84}
85
86#[derive(Debug, Clone)]
90pub struct JoinOrderResult {
91 pub tree: JoinOrderTree,
92 pub primary_alias: Option<String>,
93}
94
95#[derive(Debug, Clone, Default)]
97pub struct JoinOrderOptimizer {
98 pub cost_estimator: CostEstimator,
99}
100
101impl JoinOrderOptimizer {
102 pub fn new() -> Self {
103 Self::default()
104 }
105
106 pub fn with_cost_estimator(mut self, est: CostEstimator) -> Self {
107 self.cost_estimator = est;
108 self
109 }
110
111 pub fn optimize(
113 &self,
114 relations: Vec<JoinRelation>,
115 predicates: Vec<JoinPredicate>,
116 ) -> JoinGraphResult<JoinOrderResult> {
117 if relations.is_empty() {
118 return Err(crate::join_graph::JoinGraphError::EmptyGraph);
119 }
120
121 if relations.len() == 1 {
122 let relation = relations
123 .first()
124 .ok_or(crate::join_graph::JoinGraphError::EmptyGraph)?;
125 let mut validation = JoinGraph::new();
126 validation.add_relation_with_cost(
127 relation.alias.clone(),
128 relation.cardinality,
129 relation.access_cost,
130 )?;
131 let rel = relations.into_iter().next().ok_or(
132 crate::join_graph::JoinGraphError::UnknownRelation {
133 index: 0,
134 relation_count: 0,
135 },
136 )?;
137 let alias = rel.alias.clone();
138 return Ok(JoinOrderResult {
139 tree: JoinOrderTree::Scan(rel),
140 primary_alias: Some(alias),
141 });
142 }
143
144 let primary_alias = relations.first().map(|r| r.alias.clone());
145
146 let mut graph = JoinGraph::new();
148 let mut alias_to_idx: BTreeMap<String, usize> = BTreeMap::new();
149 for rel in &relations {
150 let idx = graph.add_relation_with_cost(
151 rel.alias.clone(),
152 rel.cardinality,
153 rel.access_cost,
154 )?;
155 if alias_to_idx.insert(rel.alias.clone(), idx).is_some() {
156 return Err(crate::join_graph::JoinGraphError::DuplicateAlias {
157 alias: rel.alias.clone(),
158 });
159 }
160 }
161
162 let mut predicate_lookup: BTreeMap<(usize, usize), JoinPredicate> = BTreeMap::new();
165 for pred in predicates {
166 let l = alias_to_idx.get(&pred.left_alias).copied().ok_or_else(|| {
167 crate::join_graph::JoinGraphError::UnknownAlias {
168 alias: pred.left_alias.clone(),
169 }
170 })?;
171 let r = alias_to_idx
172 .get(&pred.right_alias)
173 .copied()
174 .ok_or_else(|| crate::join_graph::JoinGraphError::UnknownAlias {
175 alias: pred.right_alias.clone(),
176 })?;
177 let selectivity = Self::estimate_predicate_selectivity(
178 &relations,
179 l,
180 r,
181 &pred.left_field,
182 &pred.right_field,
183 );
184 graph.add_edge(l, r, selectivity)?;
185 predicate_lookup.insert((l.min(r), l.max(r)), pred);
188 }
189
190 let plan = enumerate_dpccp_with_cost_estimator(&graph, self.cost_estimator.clone());
191 let tree = match plan {
192 Some(p) => Self::materialize_plan(&p, &graph, &relations, &predicate_lookup)?,
193 None => {
194 let mut iter = relations.into_iter();
199 let first =
200 iter.next()
201 .ok_or(crate::join_graph::JoinGraphError::UnknownRelation {
202 index: 0,
203 relation_count: 0,
204 })?;
205 let mut tree = JoinOrderTree::Scan(first);
206 for rel in iter {
207 tree = JoinOrderTree::Cross {
208 left: Box::new(tree),
209 right: Box::new(JoinOrderTree::Scan(rel)),
210 };
211 }
212 tree
213 }
214 };
215
216 Ok(JoinOrderResult {
217 tree,
218 primary_alias,
219 })
220 }
221
222 fn estimate_predicate_selectivity(
223 relations: &[JoinRelation],
224 left_idx: usize,
225 right_idx: usize,
226 left_field: &str,
227 right_field: &str,
228 ) -> f64 {
229 let l_distinct = relations
230 .get(left_idx)
231 .and_then(|r| r.column_stats.get(left_field))
232 .map(|s| s.distinct_count.max(1));
233 let r_distinct = relations
234 .get(right_idx)
235 .and_then(|r| r.column_stats.get(right_field))
236 .map(|s| s.distinct_count.max(1));
237 match (l_distinct, r_distinct) {
238 (Some(left), Some(right)) => 1.0 / left.max(right) as f64,
239 (Some(distinct), None) | (None, Some(distinct)) => 1.0 / distinct as f64,
240 (None, None) => crate::CardinalityEstimator::new().default_selectivity,
241 }
242 }
243
244 fn materialize_plan(
245 plan: &JoinPlan,
246 graph: &JoinGraph,
247 relations: &[JoinRelation],
248 predicates: &BTreeMap<(usize, usize), JoinPredicate>,
249 ) -> JoinGraphResult<JoinOrderTree> {
250 match (&plan.left, &plan.right) {
251 (None, None) => {
252 let idx = usize::try_from(plan.relations.trailing_zeros()).map_err(|_| {
255 crate::join_graph::JoinGraphError::InvalidPlan {
256 detail: "join leaf index exceeds usize".into(),
257 }
258 })?;
259 relations
260 .get(idx)
261 .cloned()
262 .map(JoinOrderTree::Scan)
263 .ok_or_else(|| crate::join_graph::JoinGraphError::InvalidPlan {
264 detail: format!("leaf references relation index {idx}"),
265 })
266 }
267 (Some(left), Some(right)) => {
268 let l_tree = Self::materialize_plan(left, graph, relations, predicates)?;
269 let r_tree = Self::materialize_plan(right, graph, relations, predicates)?;
270 let l_set = left.relations;
271 let r_set = right.relations;
272 let Some(edge) = graph.edges.iter().find(|e| edge_connects(e, l_set, r_set)) else {
273 return Ok(JoinOrderTree::Cross {
274 left: Box::new(l_tree),
275 right: Box::new(r_tree),
276 });
277 };
278
279 let edge_left_bit = usize::try_from(edge.left.trailing_zeros()).map_err(|_| {
280 crate::join_graph::JoinGraphError::InvalidPlan {
281 detail: "left join edge index exceeds usize".into(),
282 }
283 })?;
284 let edge_right_bit =
285 usize::try_from(edge.right.trailing_zeros()).map_err(|_| {
286 crate::join_graph::JoinGraphError::InvalidPlan {
287 detail: "right join edge index exceeds usize".into(),
288 }
289 })?;
290 let left_in_l = (l_set & (1u64 << edge_left_bit)) != 0;
291 let right_in_l = (l_set & (1u64 << edge_right_bit)) != 0;
292 let key = (
293 edge_left_bit.min(edge_right_bit),
294 edge_left_bit.max(edge_right_bit),
295 );
296 let Some(pred) = predicates.get(&key) else {
297 return Err(crate::join_graph::JoinGraphError::InvalidPlan {
298 detail: format!(
299 "join edge between relation indices {edge_left_bit} and {edge_right_bit} has no predicate"
300 ),
301 });
302 };
303
304 let condition = if !left_in_l && right_in_l {
308 JoinCondition {
309 left_field: pred.right_field.clone(),
310 right_field: pred.left_field.clone(),
311 }
312 } else {
313 JoinCondition {
314 left_field: pred.left_field.clone(),
315 right_field: pred.right_field.clone(),
316 }
317 };
318
319 let algorithm = match plan.kind {
320 Some(OperatorKind::HashJoinInner) => JoinAlgorithm::Hash,
321 Some(kind) => {
322 return Err(crate::join_graph::JoinGraphError::InvalidPlan {
323 detail: format!(
324 "DPccp selected non-executable equijoin strategy {kind:?}"
325 ),
326 });
327 }
328 None => {
329 return Err(crate::join_graph::JoinGraphError::InvalidPlan {
330 detail: "DPccp equijoin node has no physical strategy".into(),
331 });
332 }
333 };
334
335 Ok(JoinOrderTree::Inner {
336 algorithm,
337 condition,
338 left: Box::new(l_tree),
339 right: Box::new(r_tree),
340 })
341 }
342 (Some(_), None) | (None, Some(_)) => {
343 Err(crate::join_graph::JoinGraphError::InvalidPlan {
344 detail: "join node contains exactly one child".into(),
345 })
346 }
347 }
348 }
349}
350
351fn edge_connects(edge: &JoinEdge, l_set: u64, r_set: u64) -> bool {
352 let l_in_l = edge.left & l_set != 0;
353 let r_in_r = edge.right & r_set != 0;
354 let l_in_r = edge.left & r_set != 0;
355 let r_in_l = edge.right & l_set != 0;
356 (l_in_l && r_in_r) || (l_in_r && r_in_l)
357}
358
359#[cfg(test)]
360mod tests {
361 use super::*;
362 use uqa_core::Value;
363
364 fn rel(alias: &str, card: f64, src: u64) -> JoinRelation {
365 JoinRelation {
366 alias: alias.into(),
367 cardinality: card,
368 column_stats: BTreeMap::new(),
369 access_cost: card,
370 paradigm: AccessParadigm::Relational,
371 source_id: src,
372 }
373 }
374
375 fn rel_with_stats(alias: &str, card: f64, src: u64, field: &str, ndv: u64) -> JoinRelation {
376 let mut cs = BTreeMap::new();
377 cs.insert(
378 field.to_string(),
379 ColumnStats {
380 distinct_count: ndv,
381 row_count: card as u64,
382 ..Default::default()
383 },
384 );
385 JoinRelation {
386 alias: alias.into(),
387 cardinality: card,
388 column_stats: cs,
389 access_cost: card,
390 paradigm: AccessParadigm::Relational,
391 source_id: src,
392 }
393 }
394
395 fn assert_hash_join_tree(tree: &JoinOrderTree) {
396 match tree {
397 JoinOrderTree::Inner {
398 algorithm,
399 left,
400 right,
401 ..
402 } => {
403 assert_eq!(*algorithm, JoinAlgorithm::Hash);
404 assert_hash_join_tree(left);
405 assert_hash_join_tree(right);
406 }
407 JoinOrderTree::Cross { left, right } => {
408 assert_hash_join_tree(left);
409 assert_hash_join_tree(right);
410 }
411 JoinOrderTree::Scan(_) => {}
412 }
413 }
414
415 #[test]
416 fn single_relation_returns_scan() {
417 let opt = JoinOrderOptimizer::new();
418 let result = opt.optimize(vec![rel("a", 100.0, 1)], vec![]).unwrap();
419 assert!(matches!(result.tree, JoinOrderTree::Scan(_)));
420 assert_eq!(result.primary_alias.as_deref(), Some("a"));
421 }
422
423 #[test]
424 fn three_chain_uses_executable_hash_strategies() {
425 let opt = JoinOrderOptimizer::new();
426 let result = opt
427 .optimize(
428 vec![
429 rel_with_stats("small", 10.0, 1, "id", 10),
430 rel_with_stats("mid", 10_000.0, 2, "small_id", 10_000),
431 rel_with_stats("big", 1_000_000.0, 3, "mid_id", 1_000_000),
432 ],
433 vec![
434 JoinPredicate {
435 left_alias: "small".into(),
436 right_alias: "mid".into(),
437 left_field: "id".into(),
438 right_field: "small_id".into(),
439 },
440 JoinPredicate {
441 left_alias: "mid".into(),
442 right_alias: "big".into(),
443 left_field: "id".into(),
444 right_field: "mid_id".into(),
445 },
446 ],
447 )
448 .unwrap();
449 assert_hash_join_tree(&result.tree);
450 assert_eq!(result.primary_alias.as_deref(), Some("small"));
451 }
452
453 #[test]
454 fn cross_join_when_no_predicate() {
455 let opt = JoinOrderOptimizer::new();
456 let result = opt
457 .optimize(vec![rel("a", 50.0, 1), rel("b", 50.0, 2)], vec![])
458 .unwrap();
459 match result.tree {
460 JoinOrderTree::Inner { .. } => panic!("expected cross join, got inner"),
461 JoinOrderTree::Scan(_) => panic!("expected cross join, got scan"),
462 JoinOrderTree::Cross { .. } => {}
463 }
464 }
465
466 #[test]
467 fn predicate_selectivity_uses_max_distinct() {
468 let relations = vec![
469 rel_with_stats("a", 1000.0, 1, "x", 100),
470 rel_with_stats("b", 1000.0, 2, "y", 50),
471 ];
472 let s = JoinOrderOptimizer::estimate_predicate_selectivity(&relations, 0, 1, "x", "y");
473 assert!((s - 0.01).abs() < 1e-9);
474 let _ = Value::Int(0);
475 }
476
477 #[test]
478 fn predicate_selectivity_uses_the_configured_fallback_without_statistics() {
479 let relations = vec![rel("a", 1000.0, 1), rel("b", 1000.0, 2)];
480 let selectivity =
481 JoinOrderOptimizer::estimate_predicate_selectivity(&relations, 0, 1, "x", "y");
482 assert_eq!(
483 selectivity,
484 crate::CardinalityEstimator::new().default_selectivity
485 );
486 }
487
488 #[test]
489 fn invalid_join_inputs_are_reported_instead_of_silently_rewritten() {
490 let optimizer = JoinOrderOptimizer::new();
491 assert!(matches!(
492 optimizer.optimize(Vec::new(), Vec::new()),
493 Err(crate::join_graph::JoinGraphError::EmptyGraph)
494 ));
495 assert!(matches!(
496 optimizer.optimize(vec![rel("a", 1.0, 1), rel("a", 2.0, 2)], Vec::new()),
497 Err(crate::join_graph::JoinGraphError::DuplicateAlias { .. })
498 ));
499 assert!(matches!(
500 optimizer.optimize(
501 vec![rel("a", 1.0, 1), rel("b", 2.0, 2)],
502 vec![JoinPredicate {
503 left_alias: "a".into(),
504 right_alias: "missing".into(),
505 left_field: "id".into(),
506 right_field: "id".into(),
507 }],
508 ),
509 Err(crate::join_graph::JoinGraphError::UnknownAlias { .. })
510 ));
511 assert!(matches!(
512 optimizer.optimize(vec![rel("bad", f64::NAN, 1)], Vec::new()),
513 Err(crate::join_graph::JoinGraphError::InvalidCardinality { .. })
514 ));
515 }
516}