Skip to main content

laminar_sql/planner/
mod.rs

1//! Query planner for streaming SQL
2//!
3//! This module translates parsed streaming SQL statements into execution plans.
4//! It integrates with the parser and translator modules to produce complete
5//! operator configurations for Ring 0 execution.
6
7pub mod channel_derivation;
8/// Optimizer rules for lookup join rewriting.
9pub mod lookup_join;
10mod lookup_table;
11/// Predicate splitting and pushdown for lookup joins.
12pub mod predicate_split;
13/// Physical optimizer rule for streaming plan validation.
14pub mod streaming_optimizer;
15
16#[allow(clippy::disallowed_types)] // cold path: query planning
17use 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/// Information about a registered lookup table.
43#[derive(Debug, Clone)]
44pub struct LookupTableInfo {
45    /// Table name.
46    pub name: String,
47    /// Column names and types.
48    pub columns: Vec<(String, String)>,
49    /// Primary key columns.
50    pub primary_key: Vec<String>,
51    /// Validated properties.
52    pub properties: LookupTableProperties,
53    /// Pre-computed Arrow schema from column definitions.
54    pub arrow_schema: SchemaRef,
55    /// Raw WITH options for connector configuration pass-through.
56    pub raw_options: HashMap<String, String>,
57}
58
59/// Streaming query planner
60pub struct StreamingPlanner {
61    /// Registered sources
62    sources: HashMap<String, SourceInfo>,
63    /// Registered sinks
64    sinks: HashMap<String, SinkInfo>,
65    /// Registered lookup tables
66    lookup_tables: HashMap<String, LookupTableInfo>,
67    /// Names of views/streams for which planning retains window classification.
68    windowed_views: std::collections::HashSet<String>,
69}
70
71/// Information about a registered source
72#[derive(Debug, Clone)]
73pub struct SourceInfo {
74    /// Source name
75    pub name: String,
76    /// Watermark column (if configured)
77    pub watermark_column: Option<String>,
78    /// Declared non-null primary-key columns.
79    pub primary_key: Vec<String>,
80}
81
82/// Information about a registered sink
83#[derive(Debug, Clone)]
84pub struct SinkInfo {
85    /// Sink name
86    pub name: String,
87    /// Source table or query name
88    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/// Result of planning a streaming statement
116#[derive(Debug)]
117#[allow(clippy::large_enum_variant)]
118pub enum StreamingPlan {
119    /// Source registration (DDL)
120    RegisterSource(SourceInfo),
121
122    /// Sink registration (DDL)
123    RegisterSink(SinkInfo),
124
125    /// Query plan with streaming configurations
126    Query(QueryPlan),
127
128    /// Standard SQL statement (pass-through to DataFusion)
129    Standard(Box<Statement>),
130
131    /// Lookup table registration (DDL)
132    RegisterLookupTable(LookupTableInfo),
133
134    /// Drop a lookup table
135    DropLookupTable {
136        /// Name of the lookup table to drop.
137        name: String,
138    },
139}
140
141/// A query plan with streaming operator configurations
142#[derive(Debug)]
143pub struct QueryPlan {
144    /// Optional name for the continuous query
145    pub name: Option<String>,
146    /// Window configuration if the query has windowed aggregation
147    pub window_config: Option<WindowOperatorConfig>,
148    /// Join configuration(s) if the query has joins (one per join step)
149    pub join_config: Option<Vec<JoinOperatorConfig>>,
150    /// ORDER BY configuration if the query has ordering
151    pub order_config: Option<OrderOperatorConfig>,
152    /// Analytic window function configuration (LAG/LEAD/etc.)
153    pub analytic_config: Option<AnalyticWindowConfig>,
154    /// Window frame configuration (ROWS BETWEEN / RANGE BETWEEN)
155    pub frame_config: Option<WindowFrameConfig>,
156    /// Emit strategy
157    pub emit_clause: Option<EmitClause>,
158    /// The underlying SQL statement
159    pub statement: Box<Statement>,
160}
161
162/// Exact one-call admission for database-certified changelog-to-static enrichment.
163///
164/// The certificate is matched against the planner's independent parse, so it
165/// cannot authorize another relation pair, key mapping, or join type.
166#[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    /// Construct an exact INNER or LEFT changelog enrichment admission.
177    ///
178    /// # Errors
179    /// Returns an error for empty relations/keys or a mismatched key arity.
180    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    /// Creates a new streaming planner
232    #[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    /// Plans a streaming statement.
243    ///
244    /// # Errors
245    ///
246    /// Returns `PlanningError` if the statement cannot be planned.
247    pub fn plan(&mut self, statement: &StreamingStatement) -> Result<StreamingPlan, PlanningError> {
248        self.plan_internal(statement, None)
249    }
250
251    /// Plan one query whose unbounded join shape has been certified as
252    /// changelog-to-static enrichment by the database.
253    ///
254    /// This does not relax multi-way, join-type, temporal, or implicit-join
255    /// validation. Raw stream callers must use [`Self::plan`].
256    ///
257    /// # Errors
258    /// Returns `PlanningError` if the statement fails the remaining streaming checks.
259    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                // These statements are handled directly by the database facade
320                // and don't need query planning. Return as Standard pass-through.
321                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    /// Remove query classification installed by a successful plan when the surrounding catalog
330    /// transaction is rolled back or the query is dropped.
331    pub fn unregister_query(&mut self, name: &str) {
332        self.windowed_views.remove(name);
333    }
334
335    /// Whether query classification state remains for a catalog object.
336    #[must_use]
337    pub fn has_query(&self, name: &str) -> bool {
338        self.windowed_views.contains(name)
339    }
340
341    /// Remove a source installed by a catalog transaction that was dropped or rolled back.
342    pub fn unregister_source(&mut self, name: &str) {
343        self.sources.remove(name);
344    }
345
346    /// Remove a sink installed by a catalog transaction that was dropped or rolled back.
347    pub fn unregister_sink(&mut self, name: &str) {
348        self.sinks.remove(name);
349    }
350
351    /// Remove a lookup table installed by a catalog transaction that was dropped or rolled back.
352    pub fn unregister_lookup_table(&mut self, name: &str) {
353        self.lookup_tables.remove(name);
354    }
355
356    /// Plans a CREATE SOURCE statement.
357    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        // Check for existing source
364        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        // Extract watermark column
372        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        // Register the source
385        self.sources.insert(name, info.clone());
386
387        Ok(StreamingPlan::RegisterSource(info))
388    }
389
390    /// Plans a CREATE SINK statement.
391    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        // Check for existing sink
398        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        // Determine the source
406        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        // Register the sink
417        self.sinks.insert(name, info.clone());
418
419        Ok(StreamingPlan::RegisterSink(info))
420    }
421
422    /// Plans a CREATE CONTINUOUS QUERY statement.
423    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        // The query inside should be a standard SELECT
431        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        // Analyze the query for streaming features
445        let query_plan =
446            self.analyze_query(&stmt, emit_clause, changelog_enrich, temporal_probe)?;
447
448        // Keep planner classification in sync with catalog rollback/drop. A windowed query is the
449        // only query shape for which the planner retains classification after planning.
450        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    /// Plans a standard SQL statement.
470    #[allow(clippy::unused_self)] // Will use planner state for plan optimization
471    fn plan_standard_statement(
472        &self,
473        stmt: &Statement,
474        temporal_probe: Option<&JoinAnalysis>,
475    ) -> Result<StreamingPlan, PlanningError> {
476        // Check if it's a query that might have streaming features
477        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                // Check for window functions in GROUP BY
486                let window_function = Self::extract_window_from_select(select);
487
488                // Check for joins (multi-way)
489                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                // Check for ORDER BY
511                let order_analysis = analyze_order_by(stmt);
512                let order_config = OrderOperatorConfig::from_analysis(&order_analysis)
513                    .map_err(PlanningError::InvalidQuery)?;
514
515                // Check for analytic functions (LAG/LEAD/etc.)
516                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                // Check for window frame functions (ROWS BETWEEN / RANGE BETWEEN)
523                let frame_analysis = analyze_window_frames(stmt);
524                let frame_config = frame_analysis
525                    .as_ref()
526                    .map(WindowFrameConfig::from_analysis);
527
528                // Validate: reject UNBOUNDED FOLLOWING (streaming can't buffer infinite future)
529                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        // Pass through standard SQL
578        Ok(StreamingPlan::Standard(Box::new(stmt.clone())))
579    }
580
581    /// Analyzes a query for streaming features.
582    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                // Extract window function
601                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                    // Apply emit clause if present
606                    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                // Extract join info (multi-way)
616                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        // Extract ORDER BY info
641        let order_analysis = analyze_order_by(stmt);
642        analysis.order_config = OrderOperatorConfig::from_analysis(&order_analysis)
643            .map_err(PlanningError::InvalidQuery)?;
644
645        // Extract analytic function info (LAG/LEAD/etc.)
646        if let Some(analytic) = analyze_analytic_functions(stmt) {
647            analysis.analytic_config = Some(AnalyticWindowConfig::from_analysis(&analytic));
648        }
649
650        // Extract window frame functions (ROWS BETWEEN / RANGE BETWEEN)
651        if let Some(frame_analysis) = analyze_window_frames(stmt) {
652            // Validate: reject UNBOUNDED FOLLOWING
653            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    /// Extracts window function from a SELECT.
668    fn extract_window_from_select(select: &sqlparser::ast::Select) -> Option<WindowFunction> {
669        // Check GROUP BY for window functions
670        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    /// Gets a registered source by name.
686    #[must_use]
687    pub fn get_source(&self, name: &str) -> Option<&SourceInfo> {
688        self.sources.get(name)
689    }
690
691    /// Gets a registered sink by name.
692    #[must_use]
693    pub fn get_sink(&self, name: &str) -> Option<&SinkInfo> {
694        self.sinks.get(name)
695    }
696
697    /// Lists all registered sources.
698    #[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    /// Lists all registered sinks.
777    #[must_use]
778    pub fn list_sinks(&self) -> Vec<&SinkInfo> {
779        self.sinks.values().collect()
780    }
781
782    /// Gets a registered lookup table by name.
783    #[must_use]
784    pub fn get_lookup_table(&self, name: &str) -> Option<&LookupTableInfo> {
785        self.lookup_tables.get(name)
786    }
787
788    /// Lists all registered lookup tables.
789    #[must_use]
790    pub fn list_lookup_tables(&self) -> Vec<&LookupTableInfo> {
791        self.lookup_tables.values().collect()
792    }
793
794    /// Returns a clone of the lookup tables map for optimizer rule construction.
795    #[must_use]
796    pub fn lookup_tables_cloned(&self) -> HashMap<String, LookupTableInfo> {
797        self.lookup_tables.clone()
798    }
799
800    /// Converts a query plan's SQL statement into a `DataFusion`
801    /// `LogicalPlan`. Window UDFs (TUMBLE, HOP, SESSION) must be registered
802    /// on `ctx` via
803    /// [`register_streaming_functions`](crate::datafusion::register_streaming_functions)
804    /// for windowed queries to resolve correctly.
805    ///
806    /// # Errors
807    ///
808    /// Returns `PlanningError` if `DataFusion` cannot create the logical plan.
809    #[allow(clippy::unused_self)] // Method will use planner state for plan optimization
810    pub async fn to_logical_plan(
811        &self,
812        plan: &QueryPlan,
813        ctx: &SessionContext,
814    ) -> Result<LogicalPlan, PlanningError> {
815        // Convert the AST statement back to SQL and let DataFusion re-parse
816        // it with its own sqlparser version. This avoids version mismatches
817        // between our sqlparser (0.60) and DataFusion's (0.59).
818        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/// Intermediate query analysis result
833#[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
843/// Helper to convert `ObjectName` to String
844fn 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
915/// Fail closed on stream-join shapes whose state/output semantics are not implemented.
916fn 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/// Planning errors
952#[derive(Debug, thiserror::Error)]
953pub enum PlanningError {
954    /// Unsupported SQL feature
955    UnsupportedSql(String),
956
957    /// Invalid query
958    InvalidQuery(String),
959
960    /// Source not found
961    SourceNotFound(String),
962
963    /// Sink not found
964    SinkNotFound(String),
965
966    /// `DataFusion` error during logical plan creation (translated on display)
967    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;