1pub mod channel_derivation;
8pub mod lookup_join;
10mod lookup_table;
11pub mod predicate_split;
13pub mod streaming_optimizer;
15
16#[allow(clippy::disallowed_types)] use std::collections::HashMap;
18use std::sync::Arc;
19
20use arrow::datatypes::{Field, Schema, SchemaRef};
21use datafusion::logical_expr::LogicalPlan;
22use datafusion::prelude::SessionContext;
23use sqlparser::ast::{ObjectName, Select, SetExpr, Statement, TableFactor};
24
25use crate::parser::aggregation_parser::analyze_aggregates;
26use crate::parser::analytic_parser::{
27 analyze_analytic_functions, analyze_window_frames, FrameBound,
28};
29use crate::parser::join_parser::{analyze_joins, JoinAnalysis, JoinType, MultiJoinAnalysis};
30use crate::parser::lookup_table::LookupTableProperties;
31use crate::parser::order_analyzer::analyze_order_by;
32use crate::parser::{
33 CreateSinkStatement, CreateSourceStatement, EmitClause, SinkFrom, StreamingStatement,
34 WindowFunction, WindowRewriter,
35};
36use crate::temporal::temporal_table_version_count;
37use crate::translator::{
38 AnalyticWindowConfig, JoinOperatorConfig, OrderOperatorConfig, WindowFrameConfig,
39 WindowOperatorConfig,
40};
41
42#[derive(Debug, Clone)]
44pub struct LookupTableInfo {
45 pub name: String,
47 pub columns: Vec<(String, String)>,
49 pub primary_key: Vec<String>,
51 pub properties: LookupTableProperties,
53 pub arrow_schema: SchemaRef,
55 pub raw_options: HashMap<String, String>,
57}
58
59pub struct StreamingPlanner {
61 sources: HashMap<String, SourceInfo>,
63 sinks: HashMap<String, SinkInfo>,
65 lookup_tables: HashMap<String, LookupTableInfo>,
67 windowed_views: std::collections::HashSet<String>,
69}
70
71#[derive(Debug, Clone)]
73pub struct SourceInfo {
74 pub name: String,
76 pub watermark_column: Option<String>,
78 pub primary_key: Vec<String>,
80}
81
82#[derive(Debug, Clone)]
84pub struct SinkInfo {
85 pub name: String,
87 pub from: String,
89}
90
91fn is_inline_unnest(factor: &TableFactor) -> bool {
92 match factor {
93 TableFactor::UNNEST { .. } => true,
94 TableFactor::Table {
95 name,
96 args: Some(_),
97 ..
98 }
99 | TableFactor::Function { name, .. } => {
100 name.0.len() == 1 && name.to_string().eq_ignore_ascii_case("unnest")
101 }
102 _ => false,
103 }
104}
105
106fn has_implicit_multi_source(select: &Select) -> bool {
107 select
108 .from
109 .iter()
110 .filter(|from| !is_inline_unnest(&from.relation))
111 .count()
112 > 1
113}
114
115#[derive(Debug)]
117#[allow(clippy::large_enum_variant)]
118pub enum StreamingPlan {
119 RegisterSource(SourceInfo),
121
122 RegisterSink(SinkInfo),
124
125 Query(QueryPlan),
127
128 Standard(Box<Statement>),
130
131 RegisterLookupTable(LookupTableInfo),
133
134 DropLookupTable {
136 name: String,
138 },
139}
140
141#[derive(Debug)]
143pub struct QueryPlan {
144 pub name: Option<String>,
146 pub window_config: Option<WindowOperatorConfig>,
148 pub join_config: Option<Vec<JoinOperatorConfig>>,
150 pub order_config: Option<OrderOperatorConfig>,
152 pub analytic_config: Option<AnalyticWindowConfig>,
154 pub frame_config: Option<WindowFrameConfig>,
156 pub emit_clause: Option<EmitClause>,
158 pub statement: Box<Statement>,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct ChangelogEnrichAdmission {
168 left_table: String,
169 right_table: String,
170 left_keys: Vec<String>,
171 right_keys: Vec<String>,
172 join_type: JoinType,
173}
174
175impl ChangelogEnrichAdmission {
176 pub fn try_new(
181 left_table: impl Into<String>,
182 right_table: impl Into<String>,
183 left_keys: Vec<String>,
184 right_keys: Vec<String>,
185 left_outer: bool,
186 ) -> Result<Self, String> {
187 let left_table = left_table.into();
188 let right_table = right_table.into();
189 if left_table.is_empty() || right_table.is_empty() {
190 return Err("changelog enrichment relations cannot be empty".into());
191 }
192 if left_keys.is_empty()
193 || left_keys.len() != right_keys.len()
194 || left_keys.iter().chain(&right_keys).any(String::is_empty)
195 {
196 return Err("changelog enrichment keys must be non-empty with matching arity".into());
197 }
198 Ok(Self {
199 left_table,
200 right_table,
201 left_keys,
202 right_keys,
203 join_type: if left_outer {
204 JoinType::Left
205 } else {
206 JoinType::Inner
207 },
208 })
209 }
210
211 fn matches(&self, step: &JoinAnalysis) -> bool {
212 let mut left_keys = Vec::with_capacity(1 + step.additional_key_columns.len());
213 let mut right_keys = Vec::with_capacity(1 + step.additional_key_columns.len());
214 left_keys.push(step.left_key_column.as_str());
215 right_keys.push(step.right_key_column.as_str());
216 for (left, right) in &step.additional_key_columns {
217 left_keys.push(left);
218 right_keys.push(right);
219 }
220 self.left_table == step.left_table
221 && self.right_table == step.right_table
222 && self.join_type == step.join_type
223 && self.left_keys.iter().map(String::as_str).eq(left_keys)
224 && self.right_keys.iter().map(String::as_str).eq(right_keys)
225 && !step.is_temporal_join()
226 && step.time_bound.is_none()
227 }
228}
229
230impl StreamingPlanner {
231 #[must_use]
233 pub fn new() -> Self {
234 Self {
235 sources: HashMap::new(),
236 sinks: HashMap::new(),
237 lookup_tables: HashMap::new(),
238 windowed_views: std::collections::HashSet::new(),
239 }
240 }
241
242 pub fn plan(&mut self, statement: &StreamingStatement) -> Result<StreamingPlan, PlanningError> {
248 self.plan_internal(statement, None)
249 }
250
251 pub fn plan_changelog_enrich(
260 &mut self,
261 statement: &StreamingStatement,
262 admission: &ChangelogEnrichAdmission,
263 ) -> Result<StreamingPlan, PlanningError> {
264 if !matches!(
265 statement,
266 StreamingStatement::CreateContinuousQuery { .. }
267 | StreamingStatement::CreateStream { .. }
268 ) {
269 return Err(PlanningError::InvalidQuery(
270 "changelog enrichment admission is valid only for a named streaming query".into(),
271 ));
272 }
273 self.plan_internal(statement, Some(admission))
274 }
275
276 fn plan_internal(
277 &mut self,
278 statement: &StreamingStatement,
279 changelog_enrich: Option<&ChangelogEnrichAdmission>,
280 ) -> Result<StreamingPlan, PlanningError> {
281 match statement {
282 StreamingStatement::CreateSource(source) => self.plan_create_source(source),
283 StreamingStatement::CreateSink(sink) => self.plan_create_sink(sink),
284 StreamingStatement::CreateContinuousQuery {
285 name,
286 query,
287 emit_clause,
288 ..
289 }
290 | StreamingStatement::CreateStream {
291 name,
292 query,
293 emit_clause,
294 ..
295 } => self.plan_continuous_query(name, query, emit_clause.as_ref(), changelog_enrich),
296 StreamingStatement::Standard(stmt) => self.plan_standard_statement(stmt, None),
297 StreamingStatement::TemporalProbeQuery {
298 statement,
299 analysis,
300 } => self.plan_standard_statement(statement, Some(analysis)),
301 StreamingStatement::CreateLookupTable(lt) => self.plan_create_lookup_table(lt),
302 StreamingStatement::DropLookupTable { name, if_exists } => {
303 self.plan_drop_lookup_table(name, *if_exists)
304 }
305 StreamingStatement::DropSource { .. }
306 | StreamingStatement::DropSink { .. }
307 | StreamingStatement::DropStream { .. }
308 | StreamingStatement::DropMaterializedView { .. }
309 | StreamingStatement::Show(_)
310 | StreamingStatement::Describe { .. }
311 | StreamingStatement::Explain { .. }
312 | StreamingStatement::CreateMaterializedView { .. }
313 | StreamingStatement::InsertInto { .. }
314 | StreamingStatement::AlterSource { .. }
315 | StreamingStatement::Checkpoint
316 | StreamingStatement::RestoreCheckpoint { .. }
317 | StreamingStatement::Subscribe(_)
318 | StreamingStatement::DeclareCursorForSubscribe { .. } => {
319 Err(PlanningError::UnsupportedSql(format!(
322 "Statement type {:?} is handled by the database layer, not the planner",
323 std::mem::discriminant(statement)
324 )))
325 }
326 }
327 }
328
329 pub fn unregister_query(&mut self, name: &str) {
332 self.windowed_views.remove(name);
333 }
334
335 #[must_use]
337 pub fn has_query(&self, name: &str) -> bool {
338 self.windowed_views.contains(name)
339 }
340
341 pub fn unregister_source(&mut self, name: &str) {
343 self.sources.remove(name);
344 }
345
346 pub fn unregister_sink(&mut self, name: &str) {
348 self.sinks.remove(name);
349 }
350
351 pub fn unregister_lookup_table(&mut self, name: &str) {
353 self.lookup_tables.remove(name);
354 }
355
356 fn plan_create_source(
358 &mut self,
359 source: &CreateSourceStatement,
360 ) -> Result<StreamingPlan, PlanningError> {
361 let name = object_name_to_string(&source.name);
362
363 if !source.or_replace && !source.if_not_exists && self.sources.contains_key(&name) {
365 return Err(PlanningError::InvalidQuery(format!(
366 "Source '{}' already exists",
367 name
368 )));
369 }
370
371 let watermark_column = source.watermark.as_ref().map(|w| w.column.value.clone());
373
374 let info = SourceInfo {
375 name: name.clone(),
376 watermark_column,
377 primary_key: source
378 .primary_key
379 .iter()
380 .map(|column| column.value.clone())
381 .collect(),
382 };
383
384 self.sources.insert(name, info.clone());
386
387 Ok(StreamingPlan::RegisterSource(info))
388 }
389
390 fn plan_create_sink(
392 &mut self,
393 sink: &CreateSinkStatement,
394 ) -> Result<StreamingPlan, PlanningError> {
395 let name = object_name_to_string(&sink.name);
396
397 if !sink.or_replace && !sink.if_not_exists && self.sinks.contains_key(&name) {
399 return Err(PlanningError::InvalidQuery(format!(
400 "Sink '{}' already exists",
401 name
402 )));
403 }
404
405 let from = match &sink.from {
407 SinkFrom::Table(table) => object_name_to_string(table),
408 SinkFrom::Query(_) => format!("{}_query", name),
409 };
410
411 let info = SinkInfo {
412 name: name.clone(),
413 from,
414 };
415
416 self.sinks.insert(name, info.clone());
418
419 Ok(StreamingPlan::RegisterSink(info))
420 }
421
422 fn plan_continuous_query(
424 &mut self,
425 name: &ObjectName,
426 query: &StreamingStatement,
427 emit_clause: Option<&EmitClause>,
428 changelog_enrich: Option<&ChangelogEnrichAdmission>,
429 ) -> Result<StreamingPlan, PlanningError> {
430 let (stmt, temporal_probe) = match query {
432 StreamingStatement::Standard(stmt) => (stmt.as_ref().clone(), None),
433 StreamingStatement::TemporalProbeQuery {
434 statement,
435 analysis,
436 } => (statement.as_ref().clone(), Some(analysis.as_ref())),
437 _ => {
438 return Err(PlanningError::InvalidQuery(
439 "Continuous query must contain a SELECT statement".to_string(),
440 ))
441 }
442 };
443
444 let query_plan =
446 self.analyze_query(&stmt, emit_clause, changelog_enrich, temporal_probe)?;
447
448 let view_name = object_name_to_string(name);
451 if query_plan.window_config.is_some() {
452 self.windowed_views.insert(view_name);
453 } else {
454 self.windowed_views.remove(&view_name);
455 }
456
457 Ok(StreamingPlan::Query(QueryPlan {
458 name: Some(object_name_to_string(name)),
459 window_config: query_plan.window_config,
460 join_config: query_plan.join_config,
461 order_config: query_plan.order_config,
462 analytic_config: query_plan.analytic_config,
463 frame_config: query_plan.frame_config,
464 emit_clause: emit_clause.cloned(),
465 statement: Box::new(stmt),
466 }))
467 }
468
469 #[allow(clippy::unused_self)] fn plan_standard_statement(
472 &self,
473 stmt: &Statement,
474 temporal_probe: Option<&JoinAnalysis>,
475 ) -> Result<StreamingPlan, PlanningError> {
476 if let Statement::Query(query) = stmt {
478 if let SetExpr::Select(select) = query.body.as_ref() {
479 if has_implicit_multi_source(select) {
480 return Err(PlanningError::InvalidQuery(
481 "implicit multi-source joins are unsupported; use one bounded INNER JOIN"
482 .to_string(),
483 ));
484 }
485 let window_function = Self::extract_window_from_select(select);
487
488 let mut join_analysis = analyze_joins(select).map_err(|e| {
490 PlanningError::InvalidQuery(format!("Join analysis failed: {e}"))
491 })?;
492
493 validate_temporal_version_shape(
494 stmt,
495 join_analysis.as_ref().map_or(0, |multi| {
496 multi
497 .joins
498 .iter()
499 .filter(|join| join.is_temporal_join())
500 .count()
501 }),
502 )?;
503
504 if let Some(ref mut multi) = join_analysis {
505 apply_temporal_probe_analysis(multi, temporal_probe)?;
506 self.resolve_temporal_source_contracts(multi)?;
507 validate_streaming_joins(multi, &self.lookup_tables, None)?;
508 }
509
510 let order_analysis = analyze_order_by(stmt);
512 let order_config = OrderOperatorConfig::from_analysis(&order_analysis)
513 .map_err(PlanningError::InvalidQuery)?;
514
515 let analytic_analysis = analyze_analytic_functions(stmt);
517 let analytic_config =
518 analytic_analysis.map(|a| AnalyticWindowConfig::from_analysis(&a));
519
520 let has_having = analyze_aggregates(stmt).has_having;
521
522 let frame_analysis = analyze_window_frames(stmt);
524 let frame_config = frame_analysis
525 .as_ref()
526 .map(WindowFrameConfig::from_analysis);
527
528 if let Some(fa) = &frame_analysis {
530 for f in &fa.functions {
531 if matches!(f.end_bound, FrameBound::UnboundedFollowing) {
532 return Err(PlanningError::InvalidQuery(
533 "UNBOUNDED FOLLOWING is not supported in streaming window frames"
534 .to_string(),
535 ));
536 }
537 }
538 }
539
540 let has_streaming_features = window_function.is_some()
541 || join_analysis.is_some()
542 || order_config.is_some()
543 || analytic_config.is_some()
544 || has_having
545 || frame_config.is_some();
546
547 if has_streaming_features {
548 let window_config = match window_function {
549 Some(w) => Some(
550 WindowOperatorConfig::from_window_function(&w)
551 .map_err(|e| PlanningError::InvalidQuery(e.to_string()))?,
552 ),
553 None => None,
554 };
555
556 let join_config = join_analysis
557 .map(|m| JoinOperatorConfig::from_multi_analysis(&m))
558 .transpose()
559 .map_err(PlanningError::InvalidQuery)?;
560
561 return Ok(StreamingPlan::Query(QueryPlan {
562 name: None,
563 window_config,
564 join_config,
565 order_config,
566 analytic_config,
567 frame_config,
568 emit_clause: None,
569 statement: Box::new(stmt.clone()),
570 }));
571 }
572 }
573 }
574
575 validate_temporal_version_shape(stmt, 0)?;
576
577 Ok(StreamingPlan::Standard(Box::new(stmt.clone())))
579 }
580
581 fn analyze_query(
583 &self,
584 stmt: &Statement,
585 emit_clause: Option<&EmitClause>,
586 changelog_enrich: Option<&ChangelogEnrichAdmission>,
587 temporal_probe: Option<&JoinAnalysis>,
588 ) -> Result<QueryAnalysis, PlanningError> {
589 let mut analysis = QueryAnalysis::default();
590 let mut recognized_temporal_versions = 0;
591
592 if let Statement::Query(query) = stmt {
593 if let SetExpr::Select(select) = query.body.as_ref() {
594 if has_implicit_multi_source(select) {
595 return Err(PlanningError::InvalidQuery(
596 "implicit multi-source joins are unsupported; use one bounded INNER JOIN"
597 .to_string(),
598 ));
599 }
600 if let Some(window) = Self::extract_window_from_select(select) {
602 let mut config = WindowOperatorConfig::from_window_function(&window)
603 .map_err(|e| PlanningError::InvalidQuery(e.to_string()))?;
604
605 if let Some(emit) = emit_clause {
607 config = config
608 .with_emit_clause(emit)
609 .map_err(|e| PlanningError::InvalidQuery(e.to_string()))?;
610 }
611
612 analysis.window_config = Some(config);
613 }
614
615 let join_analysis = analyze_joins(select).map_err(|e| {
617 PlanningError::InvalidQuery(format!("Join analysis failed: {e}"))
618 })?;
619 recognized_temporal_versions = join_analysis.as_ref().map_or(0, |multi| {
620 multi
621 .joins
622 .iter()
623 .filter(|join| join.is_temporal_join())
624 .count()
625 });
626 if let Some(mut multi) = join_analysis {
627 apply_temporal_probe_analysis(&mut multi, temporal_probe)?;
628 self.resolve_temporal_source_contracts(&mut multi)?;
629 validate_streaming_joins(&multi, &self.lookup_tables, changelog_enrich)?;
630 analysis.join_config = Some(
631 JoinOperatorConfig::from_multi_analysis(&multi)
632 .map_err(PlanningError::InvalidQuery)?,
633 );
634 }
635 }
636 }
637
638 validate_temporal_version_shape(stmt, recognized_temporal_versions)?;
639
640 let order_analysis = analyze_order_by(stmt);
642 analysis.order_config = OrderOperatorConfig::from_analysis(&order_analysis)
643 .map_err(PlanningError::InvalidQuery)?;
644
645 if let Some(analytic) = analyze_analytic_functions(stmt) {
647 analysis.analytic_config = Some(AnalyticWindowConfig::from_analysis(&analytic));
648 }
649
650 if let Some(frame_analysis) = analyze_window_frames(stmt) {
652 for f in &frame_analysis.functions {
654 if matches!(f.end_bound, FrameBound::UnboundedFollowing) {
655 return Err(PlanningError::InvalidQuery(
656 "UNBOUNDED FOLLOWING is not supported in streaming window frames"
657 .to_string(),
658 ));
659 }
660 }
661 analysis.frame_config = Some(WindowFrameConfig::from_analysis(&frame_analysis));
662 }
663
664 Ok(analysis)
665 }
666
667 fn extract_window_from_select(select: &sqlparser::ast::Select) -> Option<WindowFunction> {
669 use sqlparser::ast::GroupByExpr;
671 match &select.group_by {
672 GroupByExpr::Expressions(exprs, _modifiers) => {
673 for group_by_expr in exprs {
674 if let Ok(Some(window)) = WindowRewriter::extract_window_function(group_by_expr)
675 {
676 return Some(window);
677 }
678 }
679 }
680 GroupByExpr::All(_) => {}
681 }
682 None
683 }
684
685 #[must_use]
687 pub fn get_source(&self, name: &str) -> Option<&SourceInfo> {
688 self.sources.get(name)
689 }
690
691 #[must_use]
693 pub fn get_sink(&self, name: &str) -> Option<&SinkInfo> {
694 self.sinks.get(name)
695 }
696
697 #[must_use]
699 pub fn list_sources(&self) -> Vec<&SourceInfo> {
700 self.sources.values().collect()
701 }
702
703 fn resolve_temporal_source_contracts(
704 &self,
705 multi: &mut MultiJoinAnalysis,
706 ) -> Result<(), PlanningError> {
707 for step in &mut multi.joins {
708 if !step.is_temporal_join() {
709 continue;
710 }
711 let (_, right_key_columns) = temporal_key_columns(step)?;
712 let left = self.sources.get(&step.left_table).ok_or_else(|| {
713 PlanningError::SourceNotFound(format!(
714 "{} (temporal left input must be a registered event-time source)",
715 step.left_table
716 ))
717 })?;
718 let left_time = step.left_time_column.as_ref().ok_or_else(|| {
719 PlanningError::InvalidQuery(
720 "temporal join is missing its explicit left event-time column".into(),
721 )
722 })?;
723 let left_watermark = left.watermark_column.as_ref().ok_or_else(|| {
724 PlanningError::InvalidQuery(format!(
725 "temporal left source '{}' must declare WATERMARK FOR {}",
726 step.left_table, left_time
727 ))
728 })?;
729 if left_watermark != left_time {
730 return Err(PlanningError::InvalidQuery(format!(
731 "temporal left timestamp '{}' does not match WATERMARK FOR {} on source '{}'",
732 left_time, left_watermark, step.left_table
733 )));
734 }
735 let right = self.sources.get(&step.right_table).ok_or_else(|| {
736 PlanningError::SourceNotFound(format!(
737 "{} (temporal right input must be a registered source)",
738 step.right_table
739 ))
740 })?;
741 if !right
742 .primary_key
743 .iter()
744 .map(String::as_str)
745 .eq(right_key_columns.iter().copied())
746 {
747 return Err(PlanningError::InvalidQuery(format!(
748 "temporal right source '{}' must declare PRIMARY KEY ({}) matching the join key",
749 step.right_table,
750 right_key_columns.join(", ")
751 )));
752 }
753 let right_time = right.watermark_column.as_ref().ok_or_else(|| {
754 PlanningError::InvalidQuery(format!(
755 "temporal right source '{}' must declare WATERMARK FOR its version column",
756 step.right_table
757 ))
758 })?;
759 if step
760 .right_time_column
761 .as_ref()
762 .is_some_and(|column| column != right_time)
763 {
764 return Err(PlanningError::InvalidQuery(format!(
765 "temporal right timestamp '{}' does not match WATERMARK FOR {} on source '{}'",
766 step.right_time_column.as_deref().unwrap_or_default(),
767 right_time,
768 step.right_table
769 )));
770 }
771 step.right_time_column = Some(right_time.clone());
772 }
773 Ok(())
774 }
775
776 #[must_use]
778 pub fn list_sinks(&self) -> Vec<&SinkInfo> {
779 self.sinks.values().collect()
780 }
781
782 #[must_use]
784 pub fn get_lookup_table(&self, name: &str) -> Option<&LookupTableInfo> {
785 self.lookup_tables.get(name)
786 }
787
788 #[must_use]
790 pub fn list_lookup_tables(&self) -> Vec<&LookupTableInfo> {
791 self.lookup_tables.values().collect()
792 }
793
794 #[must_use]
796 pub fn lookup_tables_cloned(&self) -> HashMap<String, LookupTableInfo> {
797 self.lookup_tables.clone()
798 }
799
800 #[allow(clippy::unused_self)] pub async fn to_logical_plan(
811 &self,
812 plan: &QueryPlan,
813 ctx: &SessionContext,
814 ) -> Result<LogicalPlan, PlanningError> {
815 let sql = plan.statement.to_string();
819 ctx.state()
820 .create_logical_plan(&sql)
821 .await
822 .map_err(PlanningError::DataFusion)
823 }
824}
825
826impl Default for StreamingPlanner {
827 fn default() -> Self {
828 Self::new()
829 }
830}
831
832#[derive(Debug, Default)]
834#[allow(clippy::struct_field_names)]
835struct QueryAnalysis {
836 window_config: Option<WindowOperatorConfig>,
837 join_config: Option<Vec<JoinOperatorConfig>>,
838 order_config: Option<OrderOperatorConfig>,
839 analytic_config: Option<AnalyticWindowConfig>,
840 frame_config: Option<WindowFrameConfig>,
841}
842
843fn object_name_to_string(name: &ObjectName) -> String {
845 match name.0.as_slice() {
846 [sqlparser::ast::ObjectNamePart::Identifier(ident)] => ident.value.clone(),
847 _ => name.to_string(),
848 }
849}
850
851fn temporal_key_columns(step: &JoinAnalysis) -> Result<(Vec<&str>, Vec<&str>), PlanningError> {
852 let mut left = Vec::with_capacity(1 + step.additional_key_columns.len());
853 let mut right = Vec::with_capacity(1 + step.additional_key_columns.len());
854 left.push(step.left_key_column.as_str());
855 right.push(step.right_key_column.as_str());
856 for (left_column, right_column) in &step.additional_key_columns {
857 left.push(left_column);
858 right.push(right_column);
859 }
860 if left.is_empty()
861 || left.len() != right.len()
862 || left.iter().chain(&right).any(|column| column.is_empty())
863 {
864 return Err(PlanningError::InvalidQuery(
865 "temporal join equality keys must be non-empty and have matching cardinality".into(),
866 ));
867 }
868 Ok((left, right))
869}
870
871fn apply_temporal_probe_analysis(
872 multi: &mut MultiJoinAnalysis,
873 temporal_probe: Option<&JoinAnalysis>,
874) -> Result<(), PlanningError> {
875 let Some(temporal_probe) = temporal_probe else {
876 return Ok(());
877 };
878 let [normalized] = multi.joins.as_slice() else {
879 return Err(PlanningError::InvalidQuery(
880 "TEMPORAL PROBE JOIN requires one explicitly named two-way stage".into(),
881 ));
882 };
883 let (normalized_left_keys, normalized_right_keys) = temporal_key_columns(normalized)?;
884 let (probe_left_keys, probe_right_keys) = temporal_key_columns(temporal_probe)?;
885 if !normalized.is_temporal_join()
886 || normalized.left_table != temporal_probe.left_table
887 || normalized.right_table != temporal_probe.right_table
888 || normalized_left_keys != probe_left_keys
889 || normalized_right_keys != probe_right_keys
890 || normalized.left_time_column != temporal_probe.left_time_column
891 || normalized.join_type != temporal_probe.join_type
892 {
893 return Err(PlanningError::InvalidQuery(
894 "TEMPORAL PROBE JOIN metadata does not match its normalized AS-OF plan".into(),
895 ));
896 }
897 multi.joins[0] = temporal_probe.clone();
898 Ok(())
899}
900
901fn validate_temporal_version_shape(
902 statement: &Statement,
903 recognized_versions: usize,
904) -> Result<(), PlanningError> {
905 let ast_versions = temporal_table_version_count(statement);
906 if ast_versions != recognized_versions {
907 return Err(PlanningError::InvalidQuery(
908 "FOR SYSTEM_TIME AS OF is supported only on the right input of one direct two-input temporal join; nested and set-operation temporal joins are unsupported"
909 .into(),
910 ));
911 }
912 Ok(())
913}
914
915fn validate_streaming_joins(
917 multi: &MultiJoinAnalysis,
918 lookup_tables: &HashMap<String, LookupTableInfo>,
919 changelog_enrich: Option<&ChangelogEnrichAdmission>,
920) -> Result<(), PlanningError> {
921 if multi.joins.len() != 1 {
922 return Err(PlanningError::InvalidQuery(
923 "multi-way streaming joins require explicitly named two-way stages".to_string(),
924 ));
925 }
926 for step in &multi.joins {
927 if step.time_bound.is_some_and(|bound| bound.is_zero()) {
928 return Err(PlanningError::InvalidQuery(
929 "streaming interval joins require a positive finite time bound".to_string(),
930 ));
931 }
932 if step.is_bounded() {
933 continue;
934 }
935 let left_lookup = lookup_tables.contains_key(&step.left_table);
936 let right_lookup = lookup_tables.contains_key(&step.right_table);
937 if !left_lookup && !right_lookup {
938 if changelog_enrich.is_some_and(|admission| admission.matches(step)) {
939 continue;
940 }
941 return Err(PlanningError::InvalidQuery(format!(
942 "unbounded join between streaming sources '{}' and '{}'; \
943 add a temporal predicate or use a lookup table",
944 step.left_table, step.right_table,
945 )));
946 }
947 }
948 Ok(())
949}
950
951#[derive(Debug, thiserror::Error)]
953pub enum PlanningError {
954 UnsupportedSql(String),
956
957 InvalidQuery(String),
959
960 SourceNotFound(String),
962
963 SinkNotFound(String),
965
966 DataFusion(#[from] datafusion_common::DataFusionError),
968}
969
970impl std::fmt::Display for PlanningError {
971 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
972 match self {
973 Self::UnsupportedSql(msg) => write!(f, "Unsupported SQL: {msg}"),
974 Self::InvalidQuery(msg) => write!(f, "Invalid query: {msg}"),
975 Self::SourceNotFound(name) => write!(f, "Source not found: {name}"),
976 Self::SinkNotFound(name) => write!(f, "Sink not found: {name}"),
977 Self::DataFusion(e) => {
978 let translated = crate::error::translate_datafusion_error(&e.to_string());
979 write!(f, "{translated}")
980 }
981 }
982 }
983}
984
985#[cfg(test)]
986mod tests;