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