Skip to main content

risingwave_frontend/planner/
relation.rs

1// Copyright 2022 RisingWave Labs
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::collections::HashMap;
16use std::ops::Deref;
17use std::rc::Rc;
18
19use iceberg::spec::{Operation, TableMetadata};
20use itertools::Itertools;
21use risingwave_common::bail_not_implemented;
22use risingwave_common::catalog::{
23    CdcTableDesc, ColumnCatalog, Engine, Field, RISINGWAVE_ICEBERG_COMMIT_EPOCH,
24    RISINGWAVE_ICEBERG_ROW_ID, ROW_ID_COLUMN_NAME, Schema,
25};
26use risingwave_common::constants::log_store::{
27    EPOCH_COLUMN_NAME, INSERT_OP_CODE, ROW_OP_COLUMN_NAME, encode_epoch,
28};
29use risingwave_common::session_config::IcebergQueryStorageMode;
30use risingwave_common::types::{DataType, Interval, ScalarImpl};
31use risingwave_common::util::iter_util::ZipEqFast;
32use risingwave_connector::WithOptionsSecResolved;
33use risingwave_connector::source::ConnectorProperties;
34use risingwave_connector::source::cdc::CdcScanOptions;
35use risingwave_connector::source::iceberg::IcebergTimeTravelInfo;
36use risingwave_sqlparser::ast::AsOf;
37use thiserror_ext::AsReport;
38
39use crate::TableCatalog;
40use crate::binder::{
41    BoundBaseTable, BoundGapFill, BoundIcebergMetadataTable, BoundJoin, BoundMatchRecognize,
42    BoundShare, BoundShareInput, BoundSource, BoundSystemTable, BoundWatermark,
43    BoundWindowTableFunction, Relation, WindowTableFunctionKind,
44};
45use crate::catalog::source_catalog::SourceCatalog;
46use crate::error::{ErrorCode, Result};
47use crate::expr::{CastContext, Expr, ExprImpl, ExprType, FunctionCall, InputRef, Literal};
48use crate::handler::cdc::derive_with_options_for_cdc_table;
49use crate::optimizer::IcebergSnapshotInfo;
50use crate::optimizer::plan_node::generic::{self, GenericPlanRef, SourceNodeKind};
51use crate::optimizer::plan_node::utils::to_iceberg_time_travel_as_of;
52use crate::optimizer::plan_node::{
53    LogicalApply, LogicalCdcScan, LogicalGapFill, LogicalHopWindow, LogicalIcebergIntermediateScan,
54    LogicalIcebergMetadataScan, LogicalJoin, LogicalMatchRecognize, LogicalPlanRef as PlanRef,
55    LogicalProject, LogicalScan, LogicalShare, LogicalSource, LogicalSysScan, LogicalTableFunction,
56    LogicalUnion, LogicalValues,
57};
58use crate::optimizer::property::Cardinality;
59use crate::planner::{PlanFor, Planner};
60use crate::utils::{ColIndexMapping, Condition};
61
62const ERROR_WINDOW_SIZE_ARG: &str =
63    "The size arg of window table function should be an interval literal.";
64
65impl Planner {
66    pub fn plan_relation(&mut self, relation: Relation) -> Result<PlanRef> {
67        match relation {
68            Relation::BaseTable(t) => self.plan_base_table(&t),
69            Relation::SystemTable(st) => self.plan_sys_table(*st),
70            Relation::IcebergMetadataTable(table) => self.plan_iceberg_metadata_table(*table),
71            // TODO: order is ignored in the subquery
72            Relation::Subquery(q) => Ok(self.plan_query(q.query)?.into_unordered_subplan()),
73            Relation::Join(join) => self.plan_join(*join),
74            Relation::Apply(join) => self.plan_apply(*join),
75            Relation::WindowTableFunction(tf) => self.plan_window_table_function(*tf),
76            Relation::Source(s) => self.plan_source(*s),
77            Relation::TableFunction {
78                expr: tf,
79                with_ordinality,
80            } => self.plan_table_function(tf, with_ordinality),
81            Relation::Watermark(tf) => self.plan_watermark(*tf),
82            Relation::Share(share) => self.plan_share(*share),
83            Relation::GapFill(bound_gap_fill) => self.plan_gap_fill(*bound_gap_fill),
84            Relation::MatchRecognize(mr) => self.plan_match_recognize(*mr),
85        }
86    }
87
88    pub(crate) fn plan_sys_table(&mut self, sys_table: BoundSystemTable) -> Result<PlanRef> {
89        Ok(LogicalSysScan::create(
90            sys_table.sys_table_catalog,
91            self.ctx(),
92            Cardinality::unknown(), // TODO(card): cardinality of system table
93        )
94        .into())
95    }
96
97    fn plan_iceberg_metadata_table(&mut self, table: BoundIcebergMetadataTable) -> Result<PlanRef> {
98        let timezone = self.ctx().get_session_timezone();
99        let time_travel_info = to_iceberg_time_travel_as_of(&table.as_of, &timezone)?;
100        let core = crate::optimizer::plan_node::generic::IcebergMetadataScan {
101            metadata_type: table.metadata_type,
102            properties: table.properties,
103            secret_refs: table.secret_refs,
104            time_travel_info,
105            ctx: self.ctx(),
106        };
107        Ok(LogicalIcebergMetadataScan::new(core).into())
108    }
109
110    /// Rejects time travel in a streaming job. A streaming job keeps following its upstream, so it
111    /// can't read a fixed snapshot, and its plan would silently read the latest data instead.
112    /// `FOR SYSTEM_TIME AS OF PROCTIME()` stays allowed: it marks the lookup side of a temporal join.
113    fn check_no_time_travel_in_stream(&self, as_of: Option<&AsOf>) -> Result<()> {
114        let is_stream = matches!(
115            self.plan_for(),
116            PlanFor::Stream | PlanFor::StreamIcebergEngineInternal
117        );
118        let is_time_travel = matches!(
119            as_of,
120            Some(
121                AsOf::ProcessTimeWithInterval(_)
122                    | AsOf::TimestampNum(_)
123                    | AsOf::TimestampString(_)
124                    | AsOf::VersionNum(_)
125                    | AsOf::VersionString(_)
126            )
127        );
128        if is_stream && is_time_travel {
129            return Err(ErrorCode::NotSupported(
130                "time travel in a streaming job".to_owned(),
131                "`FOR SYSTEM_TIME AS OF` and `FOR SYSTEM_VERSION AS OF` are only supported in batch queries. Remove the clause, or use `FOR SYSTEM_TIME AS OF PROCTIME()` for a temporal join.".to_owned(),
132            )
133            .into());
134        }
135        Ok(())
136    }
137
138    pub(super) fn plan_base_table(&mut self, base_table: &BoundBaseTable) -> Result<PlanRef> {
139        let as_of = base_table.as_of.clone();
140        self.check_no_time_travel_in_stream(as_of.as_ref())?;
141        let scan = LogicalScan::from_base_table(base_table, self.ctx(), as_of.clone());
142
143        match base_table.table_catalog.engine {
144            Engine::Hummock => {
145                match as_of {
146                    None
147                    | Some(AsOf::ProcessTime)
148                    | Some(AsOf::ProcessTimeBroadcast)
149                    | Some(AsOf::TimestampNum(_))
150                    | Some(AsOf::TimestampString(_))
151                    | Some(AsOf::ProcessTimeWithInterval(_)) => {}
152                    Some(AsOf::VersionNum(_)) | Some(AsOf::VersionString(_)) => {
153                        bail_not_implemented!("As Of Version is not supported yet.")
154                    }
155                };
156                Ok(scan.into())
157            }
158            Engine::Iceberg => self.plan_iceberg_table(base_table, scan, as_of),
159        }
160    }
161
162    fn plan_iceberg_table(
163        &mut self,
164        base_table: &BoundBaseTable,
165        scan: LogicalScan,
166        as_of: Option<AsOf>,
167    ) -> Result<PlanRef> {
168        let is_append_only = base_table.table_catalog.append_only;
169        let iceberg_query_storage_mode = self
170            .ctx()
171            .session_ctx()
172            .config()
173            .iceberg_query_storage_mode();
174
175        enum PlanTarget {
176            TableScan,
177            Source,
178            IntermediateScan,
179        }
180        let plan_target = match self.plan_for() {
181            PlanFor::StreamIcebergEngineInternal => PlanTarget::TableScan,
182            PlanFor::BatchDql => match iceberg_query_storage_mode {
183                // Append-only Iceberg engine tables use a dummy Hummock materialization and keep
184                // their rows in Iceberg plus the sink log store.
185                IcebergQueryStorageMode::Hummock if !is_append_only => PlanTarget::TableScan,
186                _ => PlanTarget::IntermediateScan,
187            },
188            PlanFor::Stream => {
189                if is_append_only {
190                    PlanTarget::Source
191                } else {
192                    PlanTarget::TableScan
193                }
194            }
195            PlanFor::Batch => {
196                if is_append_only {
197                    PlanTarget::IntermediateScan
198                } else {
199                    PlanTarget::TableScan
200                }
201            }
202        };
203        match as_of {
204            None
205            | Some(AsOf::VersionNum(_))
206            | Some(AsOf::TimestampString(_))
207            | Some(AsOf::TimestampNum(_)) => {}
208            Some(AsOf::ProcessTime)
209            | Some(AsOf::ProcessTimeBroadcast)
210            | Some(AsOf::ProcessTimeWithInterval(_)) => {
211                bail_not_implemented!("As Of ProcessTime() is not supported yet.")
212            }
213            Some(AsOf::VersionString(_)) => {
214                bail_not_implemented!("As Of Version is not supported yet.")
215            }
216        }
217
218        if matches!(plan_target, PlanTarget::TableScan) {
219            return Ok(scan.into());
220        }
221
222        let source_catalog = self.get_iceberg_source_by_table_catalog(&base_table.table_catalog)
223            .ok_or_else(|| {
224                ErrorCode::BindError(format!(
225                    "failed to plan an iceberg engine table: {}. Can't find the corresponding iceberg source. Maybe you need to recreate the table",
226                    base_table.table_catalog.name()
227                    ))
228            })?;
229
230        // Build type mapping: source column name → Hummock table type.
231        // This lets the intermediate scan output Hummock types directly,
232        // while the Iceberg scan path still adds explicit casts after
233        // materialization to match the expected output types.
234        let mut table_column_type_mapping = HashMap::new();
235        let table_column_map: HashMap<&str, &DataType> = base_table
236            .table_catalog
237            .columns
238            .iter()
239            .map(|c| (c.name.as_str(), &c.column_desc.data_type))
240            .collect();
241        for source_col in &source_catalog.columns {
242            let source_name = source_col.name();
243            let table_name = if source_name == RISINGWAVE_ICEBERG_ROW_ID {
244                ROW_ID_COLUMN_NAME
245            } else {
246                source_name
247            };
248            if let Some(&table_type) = table_column_map.get(table_name)
249                && source_col.column_desc.data_type != *table_type
250            {
251                table_column_type_mapping.insert(source_name.to_owned(), table_type.clone());
252            }
253        }
254
255        let column_map: HashMap<String, (usize, ColumnCatalog)> = source_catalog
256            .columns
257            .clone()
258            .into_iter()
259            .enumerate()
260            .map(|(i, column)| (column.name().to_owned(), (i, column)))
261            .collect();
262        // For intermediate scan, it will output Hummock types directly. But for source, it still
263        // outputs original types. So only when the plan target is source and the column type is
264        // different, we need to add cast.
265        let exprs = scan
266            .table()
267            .column_schema()
268            .fields()
269            .iter()
270            .map(|field| {
271                let source_filed_name = if field.name == ROW_ID_COLUMN_NAME {
272                    RISINGWAVE_ICEBERG_ROW_ID
273                } else {
274                    &field.name
275                };
276                if let Some((i, source_column)) = column_map.get(source_filed_name) {
277                    let input_type = &source_column.column_desc.data_type;
278                    if matches!(plan_target, PlanTarget::Source) && input_type != &field.data_type {
279                        let mut input_ref =
280                            ExprImpl::InputRef(InputRef::new(*i, input_type.clone()).into());
281                        FunctionCall::cast_mut(
282                            &mut input_ref,
283                            &field.data_type,
284                            CastContext::Explicit,
285                        )
286                        .unwrap();
287                        input_ref
288                    } else {
289                        ExprImpl::InputRef(InputRef::new(*i, field.data_type.clone()).into())
290                    }
291                } else {
292                    // fields like `_rw_timestamp`, would not be found in source.
293                    ExprImpl::Literal(Literal::new(None, field.data_type.clone()).into())
294                }
295            })
296            .collect_vec();
297
298        // Build source→table column index mapping for Hummock rewrite.
299        // Must be built before source_catalog is moved into Rc.
300        let table_col_index: HashMap<&str, usize> = base_table
301            .table_catalog
302            .columns
303            .iter()
304            .enumerate()
305            .map(|(i, c)| (c.name.as_str(), i))
306            .collect();
307        let source_to_table_mapping = ColIndexMapping::new(
308            source_catalog
309                .columns
310                .iter()
311                .map(|c| {
312                    let table_name = if c.name() == RISINGWAVE_ICEBERG_ROW_ID {
313                        ROW_ID_COLUMN_NAME
314                    } else {
315                        c.name()
316                    };
317                    table_col_index.get(table_name).copied()
318                })
319                .collect(),
320            base_table.table_catalog.columns.len(),
321        );
322
323        let logical_source = LogicalSource::with_catalog(
324            Rc::new(source_catalog),
325            SourceNodeKind::CreateMViewOrBatch,
326            self.ctx(),
327            as_of.clone(),
328        )?;
329        if matches!(plan_target, PlanTarget::Source) {
330            return Ok(LogicalProject::new(logical_source.into(), exprs).into());
331        }
332
333        // Pin Hummock before loading the latest Iceberg snapshot. If a sink commit races with
334        // planning, this ordering guarantees that the Iceberg snapshot plus the filtered log
335        // store still represents a coherent prefix of the table.
336        let read_pending_log_store =
337            matches!(self.plan_for(), PlanFor::BatchDql) && is_append_only && as_of.is_none();
338        if read_pending_log_store {
339            drop(self.ctx().session_ctx().pinned_snapshot());
340        }
341
342        let mut logical_iceberg_intermediate_scan = self.plan_iceberg_intermediate_scan(
343            &logical_source,
344            table_column_type_mapping,
345            source_to_table_mapping,
346        )?;
347
348        if read_pending_log_store {
349            let snapshot_info = self.fetch_current_snapshot_info(&logical_source)?;
350            // A table without a snapshot has no committed rows, so all log-store rows are
351            // pending. For an existing snapshot without a RisingWave epoch marker, fall back to
352            // Iceberg-only reads to avoid returning duplicates from legacy/external snapshots.
353            let committed_epoch = match snapshot_info {
354                None => Some(None),
355                Some(IcebergSnapshotInfo {
356                    commit_epoch: Some(epoch),
357                    ..
358                }) => Some(Some(epoch)),
359                Some(_) => None,
360            };
361            if let Some(committed_epoch) = committed_epoch
362                && let Some(log_store_scan) = self.plan_iceberg_log_store_scan(
363                    &base_table.table_catalog,
364                    logical_iceberg_intermediate_scan.schema(),
365                    &logical_source.core.column_catalog,
366                    committed_epoch,
367                )?
368            {
369                logical_iceberg_intermediate_scan = LogicalUnion::create(
370                    true,
371                    vec![logical_iceberg_intermediate_scan, log_store_scan],
372                );
373            }
374        }
375
376        Ok(LogicalProject::new(logical_iceberg_intermediate_scan, exprs).into())
377    }
378
379    pub(super) fn plan_source(&mut self, source: BoundSource) -> Result<PlanRef> {
380        if source.catalog.is_cdc_table_source() {
381            if !matches!(self.plan_for(), PlanFor::Stream) {
382                return Err(ErrorCode::NotSupported(
383                    "batch queries on CDC table sources are not supported".to_owned(),
384                    "Create a materialized view to consume the CDC table source".to_owned(),
385                )
386                .into());
387            }
388            if source.as_of.is_some() {
389                return Err(ErrorCode::NotSupported(
390                    "AS OF on CDC table sources is not supported".to_owned(),
391                    "Remove the AS OF clause".to_owned(),
392                )
393                .into());
394            }
395
396            let catalog_desc = source
397                .catalog
398                .info
399                .external_table
400                .as_ref()
401                .expect("checked by is_cdc_table_source");
402            let desc = CdcTableDesc::from_protobuf(catalog_desc)?;
403            let upstream_source = {
404                let session = self.ctx.session_ctx();
405                let catalog_reader = session.env().catalog_reader().read_guard();
406                catalog_reader
407                    .get_source_by_id_with_db(&session.database(), desc.source_id)?
408                    .clone()
409            };
410            let desc = resolve_current_cdc_table_desc(desc, &upstream_source.with_properties)?;
411            let scan = LogicalCdcScan::create(
412                source.catalog.name.clone(),
413                Rc::new(desc),
414                self.ctx(),
415                CdcScanOptions {
416                    disable_backfill: true,
417                    ..Default::default()
418                },
419            );
420            Ok(scan.into())
421        } else if source.is_shareable_cdc_connector() {
422            Err(ErrorCode::InternalError(
423                "Should not create MATERIALIZED VIEW or SELECT directly on shared CDC source. HINT: create TABLE from the source instead.".to_owned(),
424            )
425            .into())
426        } else {
427            let as_of = source.as_of.clone();
428            self.check_no_time_travel_in_stream(as_of.as_ref())?;
429            match as_of {
430                None
431                | Some(AsOf::VersionNum(_))
432                | Some(AsOf::TimestampString(_))
433                | Some(AsOf::TimestampNum(_)) => {}
434                Some(AsOf::ProcessTime)
435                | Some(AsOf::ProcessTimeBroadcast)
436                | Some(AsOf::ProcessTimeWithInterval(_)) => {
437                    bail_not_implemented!("As Of ProcessTime() is not supported yet.")
438                }
439                Some(AsOf::VersionString(_)) => {
440                    bail_not_implemented!("As Of Version is not supported yet.")
441                }
442            }
443            let is_iceberg = source.catalog.is_iceberg_connector();
444
445            // validate the source has pk. We raise an error here to avoid panic in expect_stream_key later
446            // for a nicer error message.
447            if matches!(self.plan_for(), PlanFor::Stream) {
448                let has_pk =
449                    source.catalog.row_id_index.is_some() || !source.catalog.pk_col_ids.is_empty();
450                if !has_pk {
451                    // in older version, iceberg source doesn't have row_id, thus may hit this
452                    // only iceberg should hit this.
453                    debug_assert!(is_iceberg);
454                    if is_iceberg {
455                        return Err(ErrorCode::BindError(format!(
456                        "Cannot create a stream job from an iceberg source without a primary key.\nThe iceberg source might be created in an older version of RisingWave. Please try recreating the source.\nSource: {:?}",
457                        source.catalog
458                    ))
459                    .into());
460                    } else {
461                        return Err(ErrorCode::BindError(format!(
462                            "Cannot create a stream job from a source without a primary key.
463This is a bug. We would appreciate a bug report at:
464https://github.com/risingwavelabs/risingwave/issues/new?labels=type%2Fbug&template=bug_report.yml
465
466source: {:?}",
467                            source.catalog
468                        ))
469                        .into());
470                    }
471                }
472            }
473
474            let source = LogicalSource::with_catalog(
475                Rc::new(source.catalog),
476                SourceNodeKind::CreateMViewOrBatch,
477                self.ctx(),
478                as_of,
479            )?;
480            if is_iceberg && !matches!(self.plan_for(), PlanFor::Stream) {
481                let num_cols = source.core.column_catalog.len();
482                let intermediate_scan = self.plan_iceberg_intermediate_scan(
483                    &source,
484                    HashMap::new(),
485                    ColIndexMapping::identity(num_cols),
486                )?;
487                Ok(intermediate_scan)
488            } else {
489                Ok(source.into())
490            }
491        }
492    }
493
494    pub(super) fn plan_join(&mut self, join: BoundJoin) -> Result<PlanRef> {
495        let left = self.plan_relation(join.left)?;
496        let right = self.plan_relation(join.right)?;
497        let join_type = join.join_type;
498        let on_clause = join.cond;
499        if on_clause.has_subquery() {
500            bail_not_implemented!("Subquery in join on condition");
501        } else {
502            Ok(LogicalJoin::create(left, right, join_type, on_clause))
503        }
504    }
505
506    pub(super) fn plan_apply(&mut self, mut join: BoundJoin) -> Result<PlanRef> {
507        let join_type = join.join_type;
508        let on_clause = join.cond;
509        if on_clause.has_subquery() {
510            bail_not_implemented!("Subquery in join on condition");
511        }
512
513        let correlated_id = self.ctx.next_correlated_id();
514        let correlated_indices = join
515            .right
516            .collect_correlated_indices_by_depth_and_assign_id(0, correlated_id);
517        let left = self.plan_relation(join.left)?;
518        let right = self.plan_relation(join.right)?;
519
520        Ok(LogicalApply::create(
521            left,
522            right,
523            join_type,
524            Condition::with_expr(on_clause),
525            correlated_id,
526            correlated_indices,
527            false,
528        ))
529    }
530
531    pub(super) fn plan_window_table_function(
532        &mut self,
533        table_function: BoundWindowTableFunction,
534    ) -> Result<PlanRef> {
535        use WindowTableFunctionKind::*;
536        match table_function.kind {
537            Tumble => self.plan_tumble_window(
538                table_function.input,
539                table_function.time_col,
540                table_function.args,
541            ),
542            Hop => self.plan_hop_window(
543                table_function.input,
544                table_function.time_col,
545                table_function.args,
546            ),
547        }
548    }
549
550    pub(super) fn plan_table_function(
551        &mut self,
552        table_function: ExprImpl,
553        with_ordinality: bool,
554    ) -> Result<PlanRef> {
555        // TODO: maybe we can unify LogicalTableFunction with LogicalValues
556        match table_function {
557            ExprImpl::TableFunction(tf) => {
558                Ok(LogicalTableFunction::new(*tf, with_ordinality, self.ctx()).into())
559            }
560            expr => {
561                let schema = Schema {
562                    // TODO: should be named
563                    fields: vec![Field::unnamed(expr.return_type())],
564                };
565                let expr_return_type = expr.return_type();
566                let root = LogicalValues::create(vec![vec![expr]], schema, self.ctx());
567                let input_ref = ExprImpl::from(InputRef::new(0, expr_return_type.clone()));
568                let mut exprs = if let DataType::Struct(st) = expr_return_type {
569                    st.iter()
570                        .enumerate()
571                        .map(|(i, (_, ty))| {
572                            let idx = ExprImpl::literal_int(i.try_into().unwrap());
573                            let args = vec![input_ref.clone(), idx];
574                            FunctionCall::new_unchecked(ExprType::Field, args, ty.clone()).into()
575                        })
576                        .collect()
577                } else {
578                    vec![input_ref]
579                };
580                if with_ordinality {
581                    exprs.push(ExprImpl::literal_bigint(1));
582                }
583                Ok(LogicalProject::create(root, exprs))
584            }
585        }
586    }
587
588    pub(super) fn plan_share(&mut self, share: BoundShare) -> Result<PlanRef> {
589        match share.input {
590            BoundShareInput::Query(query) => {
591                let id = share.share_id;
592                match self.share_cache.get(&id) {
593                    None => {
594                        let result = self.plan_query(query)?.into_unordered_subplan();
595                        let logical_share = LogicalShare::create(result);
596                        self.share_cache.insert(id, logical_share.clone());
597                        Ok(logical_share)
598                    }
599                    Some(result) => Ok(result.clone()),
600                }
601            }
602            BoundShareInput::ChangeLog {
603                relation,
604                key_indices,
605            } => {
606                let id = share.share_id;
607                let result = self.plan_changelog(relation, key_indices)?;
608                let logical_share = LogicalShare::create(result);
609                self.share_cache.insert(id, logical_share.clone());
610                Ok(logical_share)
611            }
612        }
613    }
614
615    pub(super) fn plan_watermark(&mut self, _watermark: BoundWatermark) -> Result<PlanRef> {
616        todo!("plan watermark");
617    }
618
619    pub(super) fn plan_gap_fill(&mut self, gap_fill: BoundGapFill) -> Result<PlanRef> {
620        let input = self.plan_relation(gap_fill.input)?;
621        Ok(LogicalGapFill::new(
622            input,
623            gap_fill.time_col,
624            gap_fill.interval,
625            gap_fill.fill_strategies,
626            gap_fill.partition_by_cols,
627        )
628        .into())
629    }
630
631    pub(super) fn plan_match_recognize(&mut self, mr: BoundMatchRecognize) -> Result<PlanRef> {
632        let input = self.plan_relation(mr.input)?;
633        Ok(LogicalMatchRecognize::new(
634            input,
635            mr.partition_by,
636            mr.order_by,
637            mr.measures,
638            mr.rows_per_match,
639            mr.after_match_skip,
640            mr.pattern,
641            mr.defines,
642            mr.within,
643            mr.within_deadline,
644        )
645        .into())
646    }
647
648    fn collect_col_data_types_for_tumble_window(relation: &Relation) -> Result<Vec<DataType>> {
649        let col_data_types = match relation {
650            Relation::Source(s) => s
651                .catalog
652                .columns
653                .iter()
654                .map(|col| col.data_type().clone())
655                .collect(),
656            Relation::BaseTable(t) => t
657                .table_catalog
658                .columns
659                .iter()
660                .map(|col| col.data_type().clone())
661                .collect(),
662            Relation::Subquery(q) => q.query.schema().data_types(),
663            Relation::Share(share) => share
664                .input
665                .fields()?
666                .into_iter()
667                .map(|(_, f)| f.data_type)
668                .collect(),
669            r => {
670                return Err(ErrorCode::BindError(format!(
671                    "Invalid input relation to tumble: {r:?}"
672                ))
673                .into());
674            }
675        };
676        Ok(col_data_types)
677    }
678
679    fn plan_tumble_window(
680        &mut self,
681        input: Relation,
682        time_col: InputRef,
683        args: Vec<ExprImpl>,
684    ) -> Result<PlanRef> {
685        let mut args = args.into_iter();
686        let col_data_types: Vec<_> = Self::collect_col_data_types_for_tumble_window(&input)?;
687
688        match (args.next(), args.next(), args.next()) {
689            (Some(window_size @ ExprImpl::Literal(_)), None, None) => {
690                let mut exprs = Vec::with_capacity(col_data_types.len() + 2);
691                for (idx, col_dt) in col_data_types.iter().enumerate() {
692                    exprs.push(InputRef::new(idx, col_dt.clone()).into());
693                }
694                let window_start: ExprImpl = FunctionCall::new(
695                    ExprType::TumbleStart,
696                    vec![ExprImpl::InputRef(Box::new(time_col)), window_size.clone()],
697                )?
698                .into();
699                // TODO: `window_end` may be optimized to avoid double calculation of
700                // `tumble_start`, or we can depends on common expression
701                // optimization.
702                let window_end =
703                    FunctionCall::new(ExprType::Add, vec![window_start.clone(), window_size])?
704                        .into();
705                exprs.push(window_start);
706                exprs.push(window_end);
707                let base = self.plan_relation(input)?;
708                let project = LogicalProject::create(base, exprs);
709                Ok(project)
710            }
711            (
712                Some(window_size @ ExprImpl::Literal(_)),
713                Some(window_offset @ ExprImpl::Literal(_)),
714                None,
715            ) => {
716                let mut exprs = Vec::with_capacity(col_data_types.len() + 2);
717                for (idx, col_dt) in col_data_types.iter().enumerate() {
718                    exprs.push(InputRef::new(idx, col_dt.clone()).into());
719                }
720                let window_start: ExprImpl = FunctionCall::new(
721                    ExprType::TumbleStart,
722                    vec![
723                        ExprImpl::InputRef(Box::new(time_col)),
724                        window_size.clone(),
725                        window_offset,
726                    ],
727                )?
728                .into();
729                // TODO: `window_end` may be optimized to avoid double calculation of
730                // `tumble_start`, or we can depends on common expression
731                // optimization.
732                let window_end =
733                    FunctionCall::new(ExprType::Add, vec![window_start.clone(), window_size])?
734                        .into();
735                exprs.push(window_start);
736                exprs.push(window_end);
737                let base = self.plan_relation(input)?;
738                let project = LogicalProject::create(base, exprs);
739                Ok(project)
740            }
741            _ => Err(ErrorCode::BindError(ERROR_WINDOW_SIZE_ARG.to_owned()).into()),
742        }
743    }
744
745    fn plan_hop_window(
746        &mut self,
747        input: Relation,
748        time_col: InputRef,
749        args: Vec<ExprImpl>,
750    ) -> Result<PlanRef> {
751        let input = self.plan_relation(input)?;
752        let mut args = args.into_iter();
753        let Some((ExprImpl::Literal(window_slide), ExprImpl::Literal(window_size))) =
754            args.next_tuple()
755        else {
756            return Err(ErrorCode::BindError(ERROR_WINDOW_SIZE_ARG.to_owned()).into());
757        };
758
759        let Some(ScalarImpl::Interval(window_slide)) = *window_slide.get_data() else {
760            return Err(ErrorCode::BindError(ERROR_WINDOW_SIZE_ARG.to_owned()).into());
761        };
762        let Some(ScalarImpl::Interval(window_size)) = *window_size.get_data() else {
763            return Err(ErrorCode::BindError(ERROR_WINDOW_SIZE_ARG.to_owned()).into());
764        };
765
766        let window_offset = match (args.next(), args.next()) {
767            (Some(ExprImpl::Literal(window_offset)), None) => match *window_offset.get_data() {
768                Some(ScalarImpl::Interval(window_offset)) => window_offset,
769                _ => return Err(ErrorCode::BindError(ERROR_WINDOW_SIZE_ARG.to_owned()).into()),
770            },
771            (None, None) => Interval::from_month_day_usec(0, 0, 0),
772            _ => return Err(ErrorCode::BindError(ERROR_WINDOW_SIZE_ARG.to_owned()).into()),
773        };
774
775        if !window_size.is_positive() || !window_slide.is_positive() {
776            return Err(ErrorCode::BindError(format!(
777                "window_size {} and window_slide {} must be positive",
778                window_size, window_slide
779            ))
780            .into());
781        }
782
783        if window_size.exact_div(&window_slide).is_none() {
784            return Err(ErrorCode::BindError(format!("Invalid arguments for HOP window function: window_size {} cannot be divided by window_slide {}",window_size, window_slide)).into());
785        }
786
787        Ok(LogicalHopWindow::create(
788            input,
789            time_col,
790            window_slide,
791            window_size,
792            window_offset,
793        ))
794    }
795
796    fn plan_iceberg_intermediate_scan(
797        &self,
798        source: &LogicalSource,
799        table_column_type_mapping: HashMap<String, DataType>,
800        source_to_table_mapping: ColIndexMapping,
801    ) -> Result<PlanRef> {
802        // If time travel is not specified, we use current timestamp to get the latest snapshot
803        let timezone = self.ctx().get_session_timezone();
804        let mut time_travel_info = to_iceberg_time_travel_as_of(&source.core.as_of, &timezone)?;
805        if time_travel_info.is_none() {
806            time_travel_info = self
807                .fetch_current_snapshot_info(source)?
808                .map(|info| IcebergTimeTravelInfo::Version(info.snapshot_id));
809        }
810        let Some(time_travel_info) = time_travel_info else {
811            let mut schema = source.schema().clone();
812            for field in &mut schema.fields {
813                if let Some(target_type) = table_column_type_mapping.get(&field.name) {
814                    field.data_type = target_type.clone();
815                }
816            }
817            return Ok(LogicalValues::new(vec![], schema, self.ctx()).into());
818        };
819        let intermediate_scan = LogicalIcebergIntermediateScan::new(
820            source,
821            time_travel_info,
822            table_column_type_mapping,
823            source_to_table_mapping,
824        );
825        Ok(intermediate_scan.into())
826    }
827
828    fn fetch_current_snapshot_info(
829        &self,
830        source: &LogicalSource,
831    ) -> Result<Option<IcebergSnapshotInfo>> {
832        let mut map = self.ctx.iceberg_snapshot_info_map();
833        let catalog = source.source_catalog().ok_or_else(|| {
834            crate::error::ErrorCode::InternalError(
835                "Iceberg source must have a valid source catalog".to_owned(),
836            )
837        })?;
838        if let Some(&snapshot_info) = map.get(&catalog.id) {
839            return Ok(snapshot_info);
840        }
841
842        #[cfg(madsim)]
843        return Err(crate::error::ErrorCode::BindError(
844            "iceberg source time travel can't be used in the madsim mode".to_string(),
845        )
846        .into());
847
848        #[cfg(not(madsim))]
849        {
850            let ConnectorProperties::Iceberg(prop) =
851                ConnectorProperties::extract(catalog.with_properties.clone(), false)?
852            else {
853                return Err(crate::error::ErrorCode::InternalError(
854                    "Iceberg source must have Iceberg connector properties".to_owned(),
855                )
856                .into());
857            };
858
859            let snapshot_info = tokio::task::block_in_place(|| {
860                crate::utils::FRONTEND_RUNTIME.block_on(async {
861                    prop.load_table().await.map(|table| {
862                        let metadata = table.metadata();
863                        metadata
864                            .current_snapshot()
865                            .map(|snapshot| IcebergSnapshotInfo {
866                                snapshot_id: snapshot.snapshot_id(),
867                                commit_epoch: risingwave_iceberg_commit_epoch(
868                                    metadata,
869                                    snapshot.snapshot_id(),
870                                ),
871                            })
872                    })
873                })
874            })?;
875            map.insert(catalog.id, snapshot_info);
876            Ok(snapshot_info)
877        }
878    }
879
880    /// Build a scan of insert rows that have not yet reached the current Iceberg snapshot.
881    /// Returns `None` for legacy/in-memory sinks whose persisted KV log store cannot be found.
882    fn plan_iceberg_log_store_scan(
883        &self,
884        table_catalog: &TableCatalog,
885        target_schema: &Schema,
886        source_columns: &[ColumnCatalog],
887        committed_epoch: Option<u64>,
888    ) -> Result<Option<PlanRef>> {
889        let Some(sink_name) = table_catalog.iceberg_sink_name() else {
890            return Ok(None);
891        };
892        let log_store_table = {
893            let catalog_reader = self.ctx.session_ctx().env().catalog_reader().read_guard();
894            let Ok(schema) =
895                catalog_reader.get_schema_by_id(table_catalog.database_id, table_catalog.schema_id)
896            else {
897                return Ok(None);
898            };
899            let Some(sink) = schema.get_created_sink_by_name(&sink_name) else {
900                return Ok(None);
901            };
902            schema
903                .iter_internal_table()
904                .find(|table| {
905                    table.job_id == Some(sink.id.as_job_id())
906                        && table
907                            .columns()
908                            .iter()
909                            .any(|column| column.name() == EPOCH_COLUMN_NAME)
910                        && table
911                            .columns()
912                            .iter()
913                            .any(|column| column.name() == ROW_OP_COLUMN_NAME)
914                })
915                .cloned()
916        };
917        let Some(log_store_table) = log_store_table else {
918            return Ok(None);
919        };
920
921        let Some(epoch_idx) = log_store_table
922            .columns()
923            .iter()
924            .position(|column| column.name() == EPOCH_COLUMN_NAME)
925        else {
926            return Ok(None);
927        };
928        let Some(row_op_idx) = log_store_table
929            .columns()
930            .iter()
931            .position(|column| column.name() == ROW_OP_COLUMN_NAME)
932        else {
933            return Ok(None);
934        };
935        // The log-store payload can contain hidden upstream columns such as `_rw_timestamp`
936        // that are not part of the Iceberg table schema. Do not let those implementation-only
937        // columns prevent the pending rows from being planned.
938        let payload_col_idx = log_store_table
939            .columns()
940            .iter()
941            .enumerate()
942            .skip(row_op_idx + 1)
943            .filter_map(|(idx, column)| (!column.is_hidden).then_some(idx))
944            .collect_vec();
945        if source_columns.len() != target_schema.len()
946            || payload_col_idx.len()
947                != source_columns
948                    .iter()
949                    .filter(|column| !column.is_hidden)
950                    .count()
951        {
952            return Ok(None);
953        }
954
955        let row_op_predicate: ExprImpl = FunctionCall::new(
956            ExprType::Equal,
957            vec![
958                InputRef::new(row_op_idx, DataType::Int16).into(),
959                Literal::new(Some(ScalarImpl::Int16(INSERT_OP_CODE)), DataType::Int16).into(),
960            ],
961        )?
962        .into();
963        let mut predicate = Condition::with_expr(row_op_predicate);
964        if let Some(epoch) = committed_epoch {
965            let epoch_predicate: ExprImpl = FunctionCall::new(
966                ExprType::GreaterThan,
967                vec![
968                    InputRef::new(epoch_idx, DataType::Int64).into(),
969                    Literal::new(
970                        Some(ScalarImpl::Int64(encode_epoch(epoch))),
971                        DataType::Int64,
972                    )
973                    .into(),
974                ],
975            )?
976            .into();
977            predicate = predicate.and(Condition::with_expr(epoch_predicate));
978        }
979
980        let log_store_scan: PlanRef = LogicalScan::from(generic::TableScan::new(
981            payload_col_idx,
982            log_store_table,
983            vec![],
984            vec![],
985            self.ctx(),
986            predicate,
987            None,
988        ))
989        .into();
990
991        let source_types = log_store_scan.schema().data_types();
992        let target_types = target_schema.data_types();
993        let mut payload_idx = 0;
994        let project_exprs = source_columns
995            .iter()
996            .zip_eq_fast(target_types)
997            .map(|(source_column, target_type)| {
998                if source_column.is_hidden {
999                    return Ok(Literal::new(None, target_type).into());
1000                }
1001
1002                let source_type = source_types[payload_idx].clone();
1003                let mut expr: ExprImpl = InputRef::new(payload_idx, source_type).into();
1004                payload_idx += 1;
1005                FunctionCall::cast_mut(&mut expr, &target_type, CastContext::Explicit).map_err(
1006                    |error| {
1007                        ErrorCode::InternalError(format!(
1008                            "failed to align Iceberg log-store payload type: {}",
1009                            error.as_report()
1010                        ))
1011                    },
1012                )?;
1013                Ok(expr)
1014            })
1015            .collect::<Result<Vec<_>>>()?;
1016        Ok(Some(LogicalProject::create(log_store_scan, project_exprs)))
1017    }
1018
1019    fn get_iceberg_source_by_table_catalog(
1020        &self,
1021        table_catalog: &TableCatalog,
1022    ) -> Option<SourceCatalog> {
1023        let catalog_reader = self.ctx.session_ctx().env().catalog_reader().read_guard();
1024
1025        let iceberg_source_name = table_catalog.iceberg_source_name()?;
1026        let schema = catalog_reader
1027            .get_schema_by_id(table_catalog.database_id, table_catalog.schema_id)
1028            .ok()?;
1029        let source_catalog = schema.get_source_by_name(&iceberg_source_name)?;
1030        Some(source_catalog.deref().clone())
1031    }
1032}
1033
1034/// Find the latest known RisingWave commit boundary. Iceberg compaction produces `replace`
1035/// snapshots without changing table contents, so the marker is inherited across a chain of those
1036/// snapshots. We deliberately stop at any other unmarked operation: an external or legacy append
1037/// cannot be assigned a safe log-store boundary.
1038fn risingwave_iceberg_commit_epoch(metadata: &TableMetadata, snapshot_id: i64) -> Option<u64> {
1039    let mut snapshot = metadata.snapshot_by_id(snapshot_id)?;
1040    loop {
1041        if let Some(epoch) = snapshot
1042            .summary()
1043            .additional_properties
1044            .get(RISINGWAVE_ICEBERG_COMMIT_EPOCH)
1045        {
1046            return epoch.parse().ok();
1047        }
1048        if snapshot.summary().operation != Operation::Replace {
1049            return None;
1050        }
1051        snapshot = metadata.snapshot_by_id(snapshot.parent_snapshot_id()?)?;
1052    }
1053}
1054
1055fn resolve_current_cdc_table_desc(
1056    mut desc: CdcTableDesc,
1057    upstream_properties: &WithOptionsSecResolved,
1058) -> Result<CdcTableDesc> {
1059    let (current_properties, normalized_external_table_name) =
1060        derive_with_options_for_cdc_table(upstream_properties, desc.external_table_name)?;
1061    let (connect_properties, secret_refs) = current_properties.into_parts();
1062    desc.external_table_name = normalized_external_table_name;
1063    desc.connect_properties = connect_properties;
1064    desc.secret_refs = secret_refs;
1065    Ok(desc)
1066}
1067
1068#[cfg(test)]
1069mod tests {
1070    use std::collections::BTreeMap;
1071
1072    use iceberg::spec::{
1073        FormatVersion, MAIN_BRANCH, NestedField, PrimitiveType, Schema as IcebergSchema, Snapshot,
1074        SortOrder, Summary, TableMetadataBuilder, Type, UnboundPartitionSpec,
1075    };
1076
1077    use super::*;
1078
1079    #[test]
1080    fn test_risingwave_commit_epoch_inherits_only_across_replace_snapshots() {
1081        let append = snapshot(
1082            1,
1083            None,
1084            Operation::Append,
1085            HashMap::from([(RISINGWAVE_ICEBERG_COMMIT_EPOCH.to_owned(), "42".to_owned())]),
1086        );
1087        let replace = snapshot(2, Some(1), Operation::Replace, HashMap::new());
1088        let metadata = metadata_with_current(vec![append, replace]);
1089        assert_eq!(risingwave_iceberg_commit_epoch(&metadata, 2), Some(42));
1090
1091        let external_append = snapshot(3, Some(2), Operation::Append, HashMap::new());
1092        let metadata = metadata_with_current(vec![
1093            snapshot(
1094                1,
1095                None,
1096                Operation::Append,
1097                HashMap::from([(RISINGWAVE_ICEBERG_COMMIT_EPOCH.to_owned(), "42".to_owned())]),
1098            ),
1099            snapshot(2, Some(1), Operation::Replace, HashMap::new()),
1100            external_append,
1101        ]);
1102        assert_eq!(risingwave_iceberg_commit_epoch(&metadata, 3), None);
1103    }
1104
1105    fn metadata_with_current(mut snapshots: Vec<Snapshot>) -> TableMetadata {
1106        let current = snapshots.pop().unwrap();
1107        let mut builder = TableMetadataBuilder::new(
1108            IcebergSchema::builder()
1109                .with_fields(vec![
1110                    NestedField::new(1, "id", Type::Primitive(PrimitiveType::Long), false).into(),
1111                ])
1112                .build()
1113                .unwrap(),
1114            UnboundPartitionSpec::builder().build(),
1115            SortOrder::unsorted_order(),
1116            "s3://warehouse/db/table".to_owned(),
1117            FormatVersion::V2,
1118            HashMap::new(),
1119        )
1120        .unwrap();
1121        for snapshot in snapshots {
1122            builder = builder.add_snapshot(snapshot).unwrap();
1123        }
1124        builder
1125            .set_branch_snapshot(current, MAIN_BRANCH)
1126            .unwrap()
1127            .build()
1128            .unwrap()
1129            .metadata
1130    }
1131
1132    fn snapshot(
1133        snapshot_id: i64,
1134        parent_snapshot_id: Option<i64>,
1135        operation: Operation,
1136        additional_properties: HashMap<String, String>,
1137    ) -> Snapshot {
1138        Snapshot::builder()
1139            .with_snapshot_id(snapshot_id)
1140            .with_parent_snapshot_id(parent_snapshot_id)
1141            .with_sequence_number(snapshot_id)
1142            .with_timestamp_ms(snapshot_id)
1143            .with_manifest_list(format!("/snap-{snapshot_id}.avro"))
1144            .with_summary(Summary {
1145                operation,
1146                additional_properties,
1147            })
1148            .with_schema_id(0)
1149            .build()
1150    }
1151
1152    #[test]
1153    fn test_resolve_current_cdc_table_desc_replaces_stale_properties() {
1154        let desc = CdcTableDesc {
1155            external_table_name: "risedev.orders".to_owned(),
1156            connect_properties: BTreeMap::from([("hostname".to_owned(), "old-host".to_owned())]),
1157            ..Default::default()
1158        };
1159        let upstream_properties = WithOptionsSecResolved::new(
1160            BTreeMap::from([
1161                ("connector".to_owned(), "mysql-cdc".to_owned()),
1162                ("database.name".to_owned(), "risedev".to_owned()),
1163                ("hostname".to_owned(), "new-host".to_owned()),
1164            ]),
1165            BTreeMap::new(),
1166        );
1167
1168        let desc = resolve_current_cdc_table_desc(desc, &upstream_properties).unwrap();
1169
1170        assert_eq!(desc.connect_properties.get("hostname").unwrap(), "new-host");
1171        assert_eq!(desc.connect_properties.get("table.name").unwrap(), "orders");
1172        assert_eq!(
1173            desc.connect_properties.get("database.name").unwrap(),
1174            "risedev"
1175        );
1176    }
1177}