Skip to main content

paimon_datafusion/
sql_context.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! SQL support for Paimon tables.
19//!
20//! DataFusion does not natively support all SQL statements needed by Paimon.
21//! This module provides [`SQLContext`] which intercepts CREATE TABLE,
22//! ALTER TABLE, MERGE INTO, UPDATE and other SQL, translates them to Paimon
23//! catalog operations, and delegates everything else (SELECT, CREATE/DROP
24//! SCHEMA, DROP TABLE, etc.) to the underlying [`SessionContext`].
25//!
26//! Supported DDL:
27//! - `CREATE TABLE db.t (col TYPE, ..., PRIMARY KEY (col, ...)) [PARTITIONED BY (col, ...)] [WITH ('key' = 'val')]`
28//! - `ALTER TABLE db.t ADD COLUMN col TYPE`
29//! - `ALTER TABLE db.t DROP COLUMN col`
30//! - `ALTER TABLE db.t RENAME COLUMN old TO new`
31//! - `ALTER TABLE db.t RENAME TO new_name`
32//! - `ALTER TABLE db.t DROP PARTITION (col = val, ...)`
33//! - `TRUNCATE TABLE db.t`
34//! - `TRUNCATE TABLE db.t PARTITION (col = val, ...)`
35
36use std::collections::HashMap;
37use std::sync::Arc;
38
39use datafusion::arrow::array::{
40    new_null_array, ArrayRef, BooleanArray, Date32Array, Float32Array, Float64Array, Int16Array,
41    Int32Array, Int64Array, Int8Array, StringArray,
42};
43use datafusion::arrow::compute::cast;
44use datafusion::arrow::datatypes::{DataType as ArrowDataType, Field, Schema};
45use datafusion::arrow::record_batch::RecordBatch;
46use datafusion::common::TableReference;
47use datafusion::datasource::{MemTable, TableProvider};
48use datafusion::error::{DataFusionError, Result as DFResult};
49use datafusion::prelude::{DataFrame, SessionConfig, SessionContext};
50use datafusion::sql::sqlparser::ast::{
51    AlterTableOperation, ColumnDef, CreateTable, CreateTableOptions, CreateView, Delete,
52    Expr as SqlExpr, FromTable, Insert, Merge, ObjectName, ObjectType, RenameTableNameKind, Reset,
53    ResetStatement, Set, SqlOption, Statement, TableFactor, TableObject, Truncate, Update,
54    Value as SqlValue,
55};
56use datafusion::sql::sqlparser::dialect::GenericDialect;
57use datafusion::sql::sqlparser::parser::Parser;
58use futures::StreamExt;
59use paimon::catalog::{Catalog, Identifier};
60use paimon::spec::{
61    ArrayType as PaimonArrayType, BigIntType, BlobType, BooleanType, DataField as PaimonDataField,
62    DataType as PaimonDataType, DateType, Datum, DecimalType, DoubleType, FloatType, IntType,
63    LocalZonedTimestampType, MapType as PaimonMapType, RowType as PaimonRowType, SchemaChange,
64    SmallIntType, TimestampType, TinyIntType, VarBinaryType, VarCharType,
65};
66
67use crate::error::to_datafusion_error;
68use crate::DynamicOptions;
69
70/// A SQL context that supports registering multiple Paimon catalogs and executing SQL.
71///
72/// # Example
73/// ```ignore
74/// let mut ctx = SQLContext::new();
75/// ctx.register_catalog("paimon", catalog).await?;
76/// ctx.set_current_catalog("paimon").await?;
77/// let df = ctx.sql("ALTER TABLE paimon.db.t ADD COLUMN age INT").await?;
78/// ```
79pub struct SQLContext {
80    ctx: SessionContext,
81    catalogs: HashMap<String, Arc<dyn Catalog>>,
82    /// Session-scoped dynamic options set via `SET 'paimon.key' = 'value'`.
83    dynamic_options: DynamicOptions,
84}
85
86impl Default for SQLContext {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92impl SQLContext {
93    /// Creates a new empty SQL context.
94    pub fn new() -> Self {
95        let ctx =
96            SessionContext::new_with_config(SessionConfig::new().with_information_schema(true));
97        ctx.register_relation_planner(Arc::new(
98            crate::relation_planner::PaimonRelationPlanner::new(),
99        ))
100        .expect("failed to register relation planner");
101        Self {
102            ctx,
103            catalogs: HashMap::new(),
104            dynamic_options: Default::default(),
105        }
106    }
107
108    /// Registers a Paimon catalog under the given name.
109    ///
110    /// The first registered catalog automatically becomes the current catalog
111    /// for both Paimon-handled SQL and DataFusion-delegated SQL (SELECT, etc.).
112    /// A "default" database is created if it does not already exist (matching
113    /// the behavior of Spark/Flink Paimon catalogs).
114    pub async fn register_catalog(
115        &mut self,
116        catalog_name: impl Into<String>,
117        catalog: Arc<dyn Catalog>,
118    ) -> DFResult<()> {
119        let catalog_name = catalog_name.into();
120        let is_first = self.catalogs.is_empty();
121        let default_db = "default";
122        match catalog.get_database(default_db).await {
123            Ok(_) => {}
124            Err(paimon::Error::DatabaseNotExist { .. }) => {
125                catalog
126                    .create_database(default_db, true, Default::default())
127                    .await
128                    .map_err(|e| DataFusionError::External(Box::new(e)))?;
129            }
130            Err(e) => return Err(DataFusionError::External(Box::new(e))),
131        }
132        self.ctx.register_catalog(
133            &catalog_name,
134            Arc::new(crate::catalog::PaimonCatalogProvider::with_dynamic_options(
135                catalog.clone(),
136                self.dynamic_options.clone(),
137            )),
138        );
139        register_table_functions(&self.ctx, &catalog, default_db);
140        self.catalogs.insert(catalog_name.clone(), catalog);
141        if is_first {
142            self.set_current_catalog(catalog_name).await?;
143            self.set_current_database(default_db).await?;
144        }
145        Ok(())
146    }
147
148    /// Sets the current catalog for unqualified table references.
149    pub async fn set_current_catalog(&mut self, catalog_name: impl Into<String>) -> DFResult<()> {
150        let catalog_name = catalog_name.into();
151        if !self.catalogs.contains_key(&catalog_name) {
152            return Err(DataFusionError::Plan(format!(
153                "Unknown catalog '{catalog_name}'"
154            )));
155        }
156        if catalog_name.contains('\'') {
157            return Err(DataFusionError::Plan(
158                "Catalog name must not contain single quotes".to_string(),
159            ));
160        }
161        self.ctx
162            .sql(&format!(
163                "SET datafusion.catalog.default_catalog = '{catalog_name}'"
164            ))
165            .await?;
166        Ok(())
167    }
168
169    /// Sets the current database for unqualified table references.
170    pub async fn set_current_database(&self, database_name: &str) -> DFResult<()> {
171        if database_name.contains('\'') {
172            return Err(DataFusionError::Plan(
173                "Database name must not contain single quotes".to_string(),
174            ));
175        }
176        self.ctx
177            .sql(&format!(
178                "SET datafusion.catalog.default_schema = '{database_name}'"
179            ))
180            .await?;
181        Ok(())
182    }
183
184    /// Returns a reference to the inner [`SessionContext`].
185    pub fn ctx(&self) -> &SessionContext {
186        &self.ctx
187    }
188
189    /// Registers a temporary in-memory table or view.
190    ///
191    /// The `name` parameter accepts flexible table references, similar to DataFusion:
192    /// - `"my_table"` — uses the current catalog and current database
193    /// - `"database.my_table"` — uses the current catalog with the specified database
194    /// - `"catalog.database.my_table"` — fully qualified
195    ///
196    /// The table exists only for the lifetime of this SQLContext instance.
197    pub fn register_temp_table(
198        &self,
199        name: impl Into<TableReference>,
200        table: Arc<dyn TableProvider>,
201    ) -> DFResult<()> {
202        let (catalog, database, table_name) = self.resolve_temp_table_name(name.into())?;
203        let catalog_provider = self
204            .ctx
205            .catalog(&catalog)
206            .ok_or_else(|| DataFusionError::Plan(format!("Unknown catalog '{catalog}'")))?;
207
208        let paimon_provider = catalog_provider
209            .as_any()
210            .downcast_ref::<crate::catalog::PaimonCatalogProvider>()
211            .ok_or_else(|| {
212                DataFusionError::Plan(format!("Catalog '{catalog}' is not a Paimon catalog"))
213            })?;
214
215        paimon_provider.register_temp_table(&database, &table_name, table)
216    }
217
218    /// Deregisters a temporary table or view.
219    ///
220    /// Accepts the same flexible name format as `register_temp_table`.
221    pub fn deregister_temp_table(
222        &self,
223        name: impl Into<TableReference>,
224    ) -> DFResult<Option<Arc<dyn TableProvider>>> {
225        let (catalog, database, table_name) = self.resolve_temp_table_name(name.into())?;
226        let catalog_provider = self
227            .ctx
228            .catalog(&catalog)
229            .ok_or_else(|| DataFusionError::Plan(format!("Unknown catalog '{catalog}'")))?;
230
231        let paimon_provider = catalog_provider
232            .as_any()
233            .downcast_ref::<crate::catalog::PaimonCatalogProvider>()
234            .ok_or_else(|| {
235                DataFusionError::Plan(format!("Catalog '{catalog}' is not a Paimon catalog"))
236            })?;
237
238        paimon_provider.deregister_temp_table(&database, &table_name)
239    }
240
241    /// Returns whether a temporary table or view with the given name already exists.
242    ///
243    /// Accepts the same flexible name format as `register_temp_table`.
244    pub fn temp_table_exist(&self, name: impl Into<TableReference>) -> DFResult<bool> {
245        let (catalog, database, table_name) = self.resolve_temp_table_name(name.into())?;
246        let catalog_provider = self
247            .ctx
248            .catalog(&catalog)
249            .ok_or_else(|| DataFusionError::Plan(format!("Unknown catalog '{catalog}'")))?;
250
251        let paimon_provider = catalog_provider
252            .as_any()
253            .downcast_ref::<crate::catalog::PaimonCatalogProvider>()
254            .ok_or_else(|| {
255                DataFusionError::Plan(format!("Catalog '{catalog}' is not a Paimon catalog"))
256            })?;
257
258        Ok(paimon_provider.temp_table_exist(&database, &table_name))
259    }
260
261    /// Resolve a TableReference into (catalog, database, table_name).
262    fn resolve_temp_table_name(&self, name: TableReference) -> DFResult<(String, String, String)> {
263        match name {
264            TableReference::Bare { table } => {
265                let catalog = self.current_catalog_name();
266                let database = self
267                    .ctx
268                    .state()
269                    .config_options()
270                    .catalog
271                    .default_schema
272                    .clone();
273                Ok((catalog, database, table.to_string()))
274            }
275            TableReference::Partial { schema, table } => {
276                let catalog = self.current_catalog_name();
277                Ok((catalog, schema.to_string(), table.to_string()))
278            }
279            TableReference::Full {
280                catalog,
281                schema,
282                table,
283            } => Ok((catalog.to_string(), schema.to_string(), table.to_string())),
284        }
285    }
286
287    #[cfg(test)]
288    pub(crate) fn dynamic_options(&self) -> &DynamicOptions {
289        &self.dynamic_options
290    }
291
292    /// Execute a SQL statement. ALTER TABLE is handled by Paimon directly;
293    /// everything else is delegated to DataFusion.
294    pub async fn sql(&self, sql: &str) -> DFResult<DataFrame> {
295        let is_create_table = looks_like_create_table(sql);
296        let (rewritten_sql, partition_keys) = if is_create_table {
297            extract_partition_by(sql)?
298        } else {
299            (sql.to_string(), vec![])
300        };
301        if contains_time_travel_keyword(&rewritten_sql) {
302            // Time-travel queries are not DDL; skip our own parsing and handle directly.
303            return self.handle_time_travel_query(&rewritten_sql).await;
304        }
305
306        let statements = Parser::parse_sql(&GenericDialect {}, &rewritten_sql)
307            .map_err(|e| DataFusionError::Plan(format!("SQL parse error: {e}")))?;
308
309        if statements.len() != 1 {
310            return Err(DataFusionError::Plan(
311                "Expected exactly one SQL statement".to_string(),
312            ));
313        }
314
315        match &statements[0] {
316            Statement::CreateTable(create_table) => {
317                if create_table.temporary {
318                    self.handle_create_temp_table(create_table).await
319                } else {
320                    let (catalog, _catalog_name, _) =
321                        self.resolve_catalog_and_table(&create_table.name)?;
322                    self.handle_create_table(&catalog, create_table, partition_keys)
323                        .await
324                }
325            }
326            Statement::AlterTable(alter_table) => {
327                let (catalog, _catalog_name, _) =
328                    self.resolve_catalog_and_table(&alter_table.name)?;
329                self.handle_alter_table(
330                    &catalog,
331                    &alter_table.name,
332                    &alter_table.operations,
333                    alter_table.if_exists,
334                )
335                .await
336            }
337            Statement::Merge(merge) => self.handle_merge_into(merge).await,
338            Statement::Update(update) => self.handle_update(update).await,
339            Statement::Delete(delete) => self.handle_delete(delete).await,
340            Statement::Insert(insert)
341                if insert.overwrite
342                    && insert.partitioned.as_ref().is_some_and(|p| !p.is_empty()) =>
343            {
344                self.handle_insert_overwrite_partition(insert).await
345            }
346            Statement::Set(Set::SingleAssignment {
347                variable, values, ..
348            }) => {
349                let key = variable.to_string();
350                let key = key.trim_matches('\'').trim_matches('"');
351                if let Some(paimon_key) = key.strip_prefix("paimon.") {
352                    let value = values
353                        .first()
354                        .ok_or_else(|| DataFusionError::Plan("SET requires a value".to_string()))?
355                        .to_string();
356                    let value = value
357                        .strip_prefix('\'')
358                        .and_then(|s| s.strip_suffix('\''))
359                        .unwrap_or(&value)
360                        .to_string();
361                    self.dynamic_options
362                        .write()
363                        .unwrap()
364                        .insert(paimon_key.to_string(), value);
365                    return ok_result(&self.ctx);
366                }
367                self.ctx.sql(sql).await
368            }
369            Statement::Reset(ResetStatement {
370                reset: Reset::ConfigurationParameter(name),
371            }) => {
372                let key = name.to_string();
373                let key = key.trim_matches('\'').trim_matches('"');
374                if let Some(paimon_key) = key.strip_prefix("paimon.") {
375                    self.dynamic_options.write().unwrap().remove(paimon_key);
376                    return ok_result(&self.ctx);
377                }
378                self.ctx.sql(sql).await
379            }
380            Statement::Truncate(truncate) => self.handle_truncate_table(truncate).await,
381            Statement::CreateView(create_view) => {
382                if create_view.temporary {
383                    // Temporary views are always handled by us (Paimon catalog temp storage)
384                    self.handle_create_view(create_view).await
385                } else {
386                    // Non-temporary views: only intercept if the target catalog is Paimon
387                    let view_name = create_view.name.to_string();
388                    let table_ref: TableReference = view_name.as_str().into();
389                    if self.is_paimon_catalog_ref(&table_ref) {
390                        self.handle_create_view(create_view).await
391                    } else {
392                        self.ctx.sql(sql).await
393                    }
394                }
395            }
396            Statement::Drop {
397                object_type,
398                if_exists,
399                names,
400                temporary,
401                ..
402            } if matches!(*object_type, ObjectType::Table | ObjectType::View) => {
403                if *temporary {
404                    self.handle_drop_temp_table(names, *if_exists)
405                } else if *object_type == ObjectType::Table {
406                    // Only intercept DROP TABLE for Paimon catalogs; fall through for others
407                    let table_ref: TableReference = names[0].to_string().as_str().into();
408                    if self.is_paimon_catalog_ref(&table_ref) {
409                        let (catalog, _catalog_name, _) =
410                            self.resolve_catalog_and_table(&names[0])?;
411                        self.handle_drop_table(&catalog, names, *if_exists).await
412                    } else {
413                        self.ctx.sql(sql).await
414                    }
415                } else {
416                    self.ctx.sql(sql).await
417                }
418            }
419            Statement::Call(func) => {
420                crate::procedures::execute_call(
421                    &self.ctx,
422                    &self.catalogs,
423                    &self.current_catalog_name(),
424                    func,
425                )
426                .await
427            }
428            _ => self.ctx.sql(sql).await,
429        }
430    }
431
432    /// Handle SQL queries containing time-travel syntax (`VERSION AS OF` / `TIMESTAMP AS OF`).
433    ///
434    /// DataFusion's default SQL parser does not support these clauses, so we:
435    /// 1. Extract all table name + version/timestamp pairs (skipping string literals and comments)
436    /// 2. Strip the time-travel clauses from the SQL
437    /// 3. For each table, create a `PaimonTableProvider` with the appropriate scan options
438    ///    (merged with session-scoped dynamic options)
439    /// 4. Register them as UUID-named temp tables, execute the rewritten SQL, then deregister
440    async fn handle_time_travel_query(&self, sql: &str) -> DFResult<DataFrame> {
441        use crate::table::PaimonTableProvider;
442        use paimon::spec::{SCAN_TIMESTAMP_MILLIS_OPTION, SCAN_VERSION_OPTION};
443
444        let mut tracker = crate::merge_into::TempTableTracker::new(self);
445
446        let version_clauses = extract_all_version_as_of(sql);
447        let timestamp_clauses = extract_all_timestamp_as_of(sql);
448
449        if version_clauses.is_empty() && timestamp_clauses.is_empty() {
450            return Err(DataFusionError::Plan(
451                "Failed to parse time-travel clause in SQL".to_string(),
452            ));
453        }
454
455        // Collect all replacements: (clause_range, uuid_name)
456        let mut replacements: Vec<((usize, usize), String)> = Vec::new();
457
458        // Process all VERSION AS OF clauses
459        for info in &version_clauses {
460            let table_ref: datafusion::common::TableReference = info.table_name.as_str().into();
461            let (catalog, _catalog_name, identifier) =
462                self.resolve_table_name_from_ref(&table_ref)?;
463
464            let paimon_table = catalog
465                .get_table(&identifier)
466                .await
467                .map_err(|e| DataFusionError::External(Box::new(e)))?;
468
469            // Merge dynamic options with time-travel options
470            let mut options = self.dynamic_options.read().unwrap().clone();
471            options.insert(SCAN_VERSION_OPTION.to_string(), info.version.clone());
472
473            let table_with_options = paimon_table.copy_with_options(options);
474            let provider = Arc::new(PaimonTableProvider::try_new(table_with_options)?);
475
476            let uuid_name = format!("__paimon_tt_{}", uuid::Uuid::new_v4().as_simple());
477            self.register_temp_table(uuid_name.as_str(), provider)?;
478            tracker.register(&uuid_name);
479            replacements.push((info.clause_range, uuid_name));
480        }
481
482        // Process all TIMESTAMP AS OF clauses
483        for info in &timestamp_clauses {
484            let table_ref: datafusion::common::TableReference = info.table_name.as_str().into();
485            let (catalog, _catalog_name, identifier) =
486                self.resolve_table_name_from_ref(&table_ref)?;
487
488            let paimon_table = catalog
489                .get_table(&identifier)
490                .await
491                .map_err(|e| DataFusionError::External(Box::new(e)))?;
492
493            let millis = Self::parse_timestamp_to_millis(&info.timestamp)?;
494
495            // Merge dynamic options with time-travel options
496            let mut options = self.dynamic_options.read().unwrap().clone();
497            options.insert(SCAN_TIMESTAMP_MILLIS_OPTION.to_string(), millis.to_string());
498
499            let table_with_options = paimon_table.copy_with_options(options);
500            let provider = Arc::new(PaimonTableProvider::try_new(table_with_options)?);
501
502            let uuid_name = format!("__paimon_tt_{}", uuid::Uuid::new_v4().as_simple());
503            self.register_temp_table(uuid_name.as_str(), provider)?;
504            tracker.register(&uuid_name);
505            replacements.push((info.clause_range, uuid_name));
506        }
507
508        // Sort replacements by position (descending) so that replacements
509        // from right to left don't shift indices of earlier ones
510        replacements.sort_by_key(|r| std::cmp::Reverse(r.0 .0));
511
512        // Build the rewritten SQL by replacing each clause from right to left
513        let mut rewritten_sql = sql.to_string();
514        for ((start, end), uuid_name) in &replacements {
515            rewritten_sql = format!(
516                "{}{}{}",
517                &rewritten_sql[..*start],
518                uuid_name,
519                &rewritten_sql[*end..]
520            );
521        }
522
523        // Execute the rewritten SQL; tracker auto-deregisters on drop
524        self.ctx.sql(&rewritten_sql).await
525    }
526
527    /// Parse a timestamp string to milliseconds since epoch (using local timezone).
528    fn parse_timestamp_to_millis(ts: &str) -> DFResult<i64> {
529        use chrono::{Local, NaiveDateTime, TimeZone};
530
531        let naive = NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S").map_err(|e| {
532            DataFusionError::Plan(format!(
533                "Cannot parse time travel timestamp '{ts}': {e}. Expected format: YYYY-MM-DD HH:MM:SS"
534            ))
535        })?;
536        let local = Local.from_local_datetime(&naive).single().ok_or_else(|| {
537            DataFusionError::Plan(format!("Ambiguous or invalid local time: '{ts}'"))
538        })?;
539        Ok(local.timestamp_millis())
540    }
541
542    /// Resolve a TableReference to (catalog, catalog_name, Identifier).
543    fn resolve_table_name_from_ref(
544        &self,
545        table_ref: &datafusion::common::TableReference,
546    ) -> DFResult<(Arc<dyn Catalog>, String, Identifier)> {
547        match table_ref {
548            datafusion::common::TableReference::Full {
549                catalog,
550                schema,
551                table,
552            } => {
553                let catalog_arc = self
554                    .catalogs
555                    .get(catalog.as_ref())
556                    .ok_or_else(|| DataFusionError::Plan(format!("Unknown catalog '{catalog}'")))?;
557                Ok((
558                    catalog_arc.clone(),
559                    catalog.to_string(),
560                    Identifier::new(schema.as_ref(), table.as_ref()),
561                ))
562            }
563            datafusion::common::TableReference::Partial { schema, table } => {
564                let catalog = self.current_catalog()?;
565                let catalog_name = self.current_catalog_name();
566                Ok((
567                    catalog,
568                    catalog_name,
569                    Identifier::new(schema.as_ref(), table.as_ref()),
570                ))
571            }
572            datafusion::common::TableReference::Bare { table } => {
573                let catalog = self.current_catalog()?;
574                let catalog_name = self.current_catalog_name();
575                let default_schema = self
576                    .ctx
577                    .state()
578                    .config_options()
579                    .catalog
580                    .default_schema
581                    .clone();
582                Ok((
583                    catalog,
584                    catalog_name,
585                    Identifier::new(default_schema, table.as_ref()),
586                ))
587            }
588        }
589    }
590
591    async fn handle_create_table(
592        &self,
593        catalog: &Arc<dyn Catalog>,
594        ct: &CreateTable,
595        partition_keys: Vec<String>,
596    ) -> DFResult<DataFrame> {
597        if ct.external {
598            return Err(DataFusionError::Plan(
599                "CREATE EXTERNAL TABLE is not supported. Use CREATE TABLE instead.".to_string(),
600            ));
601        }
602        if ct.location.is_some() {
603            return Err(DataFusionError::Plan(
604                "LOCATION is not supported for Paimon tables. Table path is determined by the catalog warehouse.".to_string(),
605            ));
606        }
607        if ct.query.is_some() {
608            return Err(DataFusionError::Plan(
609                "CREATE TABLE AS SELECT is not yet supported for Paimon tables.".to_string(),
610            ));
611        }
612
613        let identifier = self.resolve_table_name(&ct.name)?;
614
615        let mut builder = paimon::spec::Schema::builder();
616
617        // Columns
618        for col in &ct.columns {
619            let paimon_type = column_def_to_paimon_type(col)?;
620            builder = builder.column(col.name.value.clone(), paimon_type);
621        }
622
623        // Primary key from constraints: PRIMARY KEY (col, ...)
624        for constraint in &ct.constraints {
625            if let datafusion::sql::sqlparser::ast::TableConstraint::PrimaryKey(pk) = constraint {
626                let pk_cols: Vec<String> = pk
627                    .columns
628                    .iter()
629                    .map(|c| c.column.expr.to_string())
630                    .collect();
631                builder = builder.primary_key(pk_cols);
632            }
633        }
634
635        // Partition keys (extracted and validated before parsing)
636        if !partition_keys.is_empty() {
637            let col_names: Vec<&str> = ct.columns.iter().map(|c| c.name.value.as_str()).collect();
638            for pk in &partition_keys {
639                if !col_names.contains(&pk.as_str()) {
640                    return Err(DataFusionError::Plan(format!(
641                        "PARTITIONED BY column '{pk}' is not defined in the table"
642                    )));
643                }
644            }
645            builder = builder.partition_keys(partition_keys);
646        }
647
648        // Table options from WITH ('key' = 'value', ...)
649        for (k, v) in extract_options(&ct.table_options)? {
650            builder = builder.option(k, v);
651        }
652
653        let schema = builder.build().map_err(to_datafusion_error)?;
654
655        catalog
656            .create_table(&identifier, schema, ct.if_not_exists)
657            .await
658            .map_err(to_datafusion_error)?;
659
660        ok_result(&self.ctx)
661    }
662
663    async fn handle_create_temp_table(&self, ct: &CreateTable) -> DFResult<DataFrame> {
664        let table_ref: TableReference = ct.name.to_string().as_str().into();
665
666        if ct.if_not_exists && self.temp_table_exist(table_ref.clone())? {
667            return ok_result(&self.ctx);
668        }
669
670        // Build the schema from column definitions if provided
671        let declared_schema = if !ct.columns.is_empty() {
672            let fields: Vec<Field> = ct
673                .columns
674                .iter()
675                .map(|col| {
676                    let paimon_type =
677                        sql_data_type_to_paimon_type(&col.data_type, column_def_nullable(col))?;
678                    let arrow_type = paimon::arrow::paimon_type_to_arrow(&paimon_type)
679                        .map_err(to_datafusion_error)?;
680                    Ok(Field::new(
681                        &col.name.value,
682                        arrow_type,
683                        column_def_nullable(col),
684                    ))
685                })
686                .collect::<DFResult<Vec<_>>>()?;
687            Some(Arc::new(Schema::new(fields)))
688        } else {
689            None
690        };
691
692        if let Some(query) = &ct.query {
693            // CREATE TEMPORARY TABLE ... AS SELECT ...
694            let query_sql = query.to_string();
695            let df = self.ctx.sql(&query_sql).await?;
696            let schema = df.schema().inner().clone();
697            let batches = df.collect().await?;
698
699            // If column types are specified, cast each column to the declared type
700            let batches = if ct.columns.is_empty() {
701                batches
702            } else {
703                let target_fields: Vec<(String, ArrowDataType)> = ct
704                    .columns
705                    .iter()
706                    .map(|col| {
707                        let paimon_type =
708                            sql_data_type_to_paimon_type(&col.data_type, column_def_nullable(col))?;
709                        let arrow_type = paimon::arrow::paimon_type_to_arrow(&paimon_type)
710                            .map_err(to_datafusion_error)?;
711                        Ok((col.name.value.clone(), arrow_type))
712                    })
713                    .collect::<DFResult<Vec<_>>>()?;
714
715                let select_col_count = schema.fields().len();
716                let declared_col_count = target_fields.len();
717                if select_col_count < declared_col_count {
718                    return Err(DataFusionError::Plan(format!(
719                        "CREATE TEMPORARY TABLE AS SELECT: declared {declared_col_count} column(s) \
720                         but SELECT query returns only {select_col_count} column(s)"
721                    )));
722                }
723
724                batches
725                    .into_iter()
726                    .map(|batch| {
727                        let columns = batch
728                            .columns()
729                            .iter()
730                            .enumerate()
731                            .map(|(i, col)| {
732                                if i < target_fields.len() {
733                                    let target_dt = &target_fields[i].1;
734                                    if *col.data_type() != *target_dt {
735                                        cast(col, target_dt)
736                                            .map_err(|e| DataFusionError::External(e.into()))
737                                    } else {
738                                        Ok(col.clone())
739                                    }
740                                } else {
741                                    Ok(col.clone())
742                                }
743                            })
744                            .collect::<DFResult<Vec<_>>>()?;
745                        let new_fields = target_fields
746                            .iter()
747                            .zip(schema.fields().iter())
748                            .map(|((name, dt), _)| Field::new(name, dt.clone(), true))
749                            .chain(
750                                schema
751                                    .fields()
752                                    .iter()
753                                    .skip(target_fields.len())
754                                    .map(|f| f.as_ref().clone()),
755                            )
756                            .collect::<Vec<_>>();
757                        let new_schema = Schema::new(new_fields);
758                        RecordBatch::try_new(Arc::new(new_schema), columns)
759                            .map_err(|e| DataFusionError::External(e.into()))
760                    })
761                    .collect::<DFResult<Vec<_>>>()?
762            };
763
764            let schema = batches.first().map(|b| b.schema()).unwrap_or(schema);
765            let mem_table = MemTable::try_new(schema, vec![batches])?;
766            self.register_temp_table(table_ref, Arc::new(mem_table))?;
767        } else if let Some(schema) = declared_schema {
768            // CREATE TEMPORARY TABLE (col1 TYPE, col2 TYPE, ...) — no data, just the schema
769            let mem_table = MemTable::try_new(schema, vec![vec![]])?;
770            self.register_temp_table(table_ref, Arc::new(mem_table))?;
771        } else {
772            return Err(DataFusionError::Plan(
773                "CREATE TEMPORARY TABLE requires column definitions or AS SELECT".to_string(),
774            ));
775        }
776
777        ok_result(&self.ctx)
778    }
779
780    fn handle_drop_temp_table(&self, names: &[ObjectName], if_exists: bool) -> DFResult<DataFrame> {
781        for name in names {
782            let table_ref: TableReference = name.to_string().as_str().into();
783            if if_exists && !self.temp_table_exist(table_ref.clone())? {
784                continue;
785            }
786            self.deregister_temp_table(table_ref)?;
787        }
788        ok_result(&self.ctx)
789    }
790
791    async fn handle_drop_table(
792        &self,
793        catalog: &Arc<dyn Catalog>,
794        names: &[ObjectName],
795        if_exists: bool,
796    ) -> DFResult<DataFrame> {
797        for name in names {
798            let identifier = self.resolve_table_name(name)?;
799            catalog
800                .drop_table(&identifier, if_exists)
801                .await
802                .map_err(|e| DataFusionError::External(Box::new(e)))?;
803        }
804        ok_result(&self.ctx)
805    }
806
807    async fn handle_alter_table(
808        &self,
809        catalog: &Arc<dyn Catalog>,
810        name: &ObjectName,
811        operations: &[AlterTableOperation],
812        if_exists: bool,
813    ) -> DFResult<DataFrame> {
814        let identifier = self.resolve_table_name(name)?;
815
816        let mut changes = Vec::new();
817        let mut rename_to: Option<Identifier> = None;
818
819        for op in operations {
820            match op {
821                AlterTableOperation::AddColumn { column_def, .. } => {
822                    let change = column_def_to_add_column(column_def)?;
823                    changes.push(change);
824                }
825                AlterTableOperation::DropColumn {
826                    column_names,
827                    if_exists: _,
828                    ..
829                } => {
830                    for col in column_names {
831                        changes.push(SchemaChange::drop_column(col.value.clone()));
832                    }
833                }
834                AlterTableOperation::RenameColumn {
835                    old_column_name,
836                    new_column_name,
837                } => {
838                    changes.push(SchemaChange::rename_column(
839                        old_column_name.value.clone(),
840                        new_column_name.value.clone(),
841                    ));
842                }
843                AlterTableOperation::RenameTable { table_name } => {
844                    let new_name = match table_name {
845                        RenameTableNameKind::To(name) | RenameTableNameKind::As(name) => {
846                            object_name_to_string(name)
847                        }
848                    };
849                    rename_to = Some(Identifier::new(identifier.database().to_string(), new_name));
850                }
851                AlterTableOperation::SetTblProperties { table_properties } => {
852                    for opt in table_properties {
853                        if let SqlOption::KeyValue { key, value } = opt {
854                            let v = value.to_string();
855                            let v = v
856                                .strip_prefix('\'')
857                                .and_then(|s| s.strip_suffix('\''))
858                                .unwrap_or(&v)
859                                .to_string();
860                            changes.push(SchemaChange::set_option(key.value.clone(), v));
861                        }
862                    }
863                }
864                AlterTableOperation::DropPartitions {
865                    partitions,
866                    if_exists: partition_if_exists,
867                } => {
868                    return self
869                        .handle_drop_partitions(
870                            catalog,
871                            &identifier,
872                            partitions,
873                            if_exists || *partition_if_exists,
874                        )
875                        .await;
876                }
877                other => {
878                    return Err(DataFusionError::Plan(format!(
879                        "Unsupported ALTER TABLE operation: {other}"
880                    )));
881                }
882            }
883        }
884
885        if let Some(new_identifier) = rename_to {
886            catalog
887                .rename_table(&identifier, &new_identifier, if_exists)
888                .await
889                .map_err(to_datafusion_error)?;
890        }
891
892        if !changes.is_empty() {
893            catalog
894                .alter_table(&identifier, changes, if_exists)
895                .await
896                .map_err(to_datafusion_error)?;
897        }
898
899        ok_result(&self.ctx)
900    }
901
902    async fn handle_merge_into(&self, merge: &Merge) -> DFResult<DataFrame> {
903        let table_name = match &merge.table {
904            TableFactor::Table { name, .. } => name.clone(),
905            other => {
906                return Err(DataFusionError::Plan(format!(
907                    "Unsupported target table in MERGE INTO: {other}"
908                )))
909            }
910        };
911        let (catalog, _catalog_name, identifier) = self.resolve_catalog_and_table(&table_name)?;
912
913        let table = catalog
914            .get_table(&identifier)
915            .await
916            .map_err(to_datafusion_error)?;
917
918        crate::merge_into::execute_merge_into(self, merge, table).await
919    }
920
921    async fn handle_update(&self, update: &Update) -> DFResult<DataFrame> {
922        let table_name = match &update.table.relation {
923            TableFactor::Table { name, .. } => name.clone(),
924            other => {
925                return Err(DataFusionError::Plan(format!(
926                    "Unsupported target table in UPDATE: {other}"
927                )))
928            }
929        };
930        let (catalog, _catalog_name, identifier) = self.resolve_catalog_and_table(&table_name)?;
931
932        let table = catalog
933            .get_table(&identifier)
934            .await
935            .map_err(to_datafusion_error)?;
936
937        crate::update::execute_update(self, update, table).await
938    }
939
940    async fn handle_delete(&self, delete: &Delete) -> DFResult<DataFrame> {
941        let tables = match &delete.from {
942            FromTable::WithFromKeyword(t) | FromTable::WithoutKeyword(t) => t,
943        };
944        let table_factor = tables
945            .first()
946            .map(|t| &t.relation)
947            .ok_or_else(|| DataFusionError::Plan("DELETE requires a target table".to_string()))?;
948        let table_name = match table_factor {
949            TableFactor::Table { name, .. } => name.clone(),
950            other => {
951                return Err(DataFusionError::Plan(format!(
952                    "Unsupported target table in DELETE: {other}"
953                )))
954            }
955        };
956        let (catalog, _catalog_name, identifier) = self.resolve_catalog_and_table(&table_name)?;
957
958        let table = catalog
959            .get_table(&identifier)
960            .await
961            .map_err(to_datafusion_error)?;
962
963        let table_ref = table_name.to_string();
964        crate::delete::execute_delete(self, delete, table, &table_ref).await
965    }
966
967    async fn handle_insert_overwrite_partition(&self, insert: &Insert) -> DFResult<DataFrame> {
968        let table_name = match &insert.table {
969            TableObject::TableName(name) => name.clone(),
970            other => {
971                return Err(DataFusionError::Plan(format!(
972                    "Unsupported target table in INSERT OVERWRITE: {other}"
973                )))
974            }
975        };
976        let (catalog, _catalog_name, identifier) = self.resolve_catalog_and_table(&table_name)?;
977        let table = catalog
978            .get_table(&identifier)
979            .await
980            .map_err(to_datafusion_error)?;
981
982        let partition_exprs = insert.partitioned.as_ref().ok_or_else(|| {
983            DataFusionError::Plan("INSERT OVERWRITE PARTITION requires a PARTITION clause".into())
984        })?;
985        let partition_fields = table.schema().partition_fields();
986        let static_partitions =
987            parse_static_partitions(partition_exprs, &partition_fields, table.schema().fields())?;
988
989        let source = insert.source.as_ref().ok_or_else(|| {
990            DataFusionError::Plan("INSERT OVERWRITE requires a source query".into())
991        })?;
992        let df = self.ctx.sql(&source.to_string()).await?;
993
994        let all_fields = table.schema().fields();
995        let non_static_fields: Vec<&PaimonDataField> = all_fields
996            .iter()
997            .filter(|f| !static_partitions.contains_key(f.name()))
998            .collect();
999        let expected_source_cols = non_static_fields.len();
1000
1001        // Resolve target column mapping from the explicit column list.
1002        // `columns` = before PARTITION, `after_columns` = after PARTITION (Hive-style).
1003        let target_columns = if !insert.columns.is_empty() {
1004            Some(&insert.columns)
1005        } else if !insert.after_columns.is_empty() {
1006            Some(&insert.after_columns)
1007        } else {
1008            None
1009        };
1010        let column_reorder: Option<Vec<usize>> = if let Some(cols) = target_columns {
1011            if cols.len() != expected_source_cols {
1012                return Err(DataFusionError::Plan(format!(
1013                    "Column list has {} columns, but expected {} non-partition columns",
1014                    cols.len(),
1015                    expected_source_cols
1016                )));
1017            }
1018            let col_names: Vec<&str> = cols.iter().map(|id| id.value.as_str()).collect();
1019            let mut reorder = Vec::with_capacity(expected_source_cols);
1020            for field in &non_static_fields {
1021                let pos = col_names
1022                    .iter()
1023                    .position(|c| c == &field.name())
1024                    .ok_or_else(|| {
1025                        DataFusionError::Plan(format!(
1026                            "Column '{}' not found in target column list",
1027                            field.name()
1028                        ))
1029                    })?;
1030                reorder.push(pos);
1031            }
1032            Some(reorder)
1033        } else {
1034            None
1035        };
1036
1037        // Validate column count from the DataFrame schema before consuming any batches.
1038        let source_col_count = df.schema().fields().len();
1039        if source_col_count != expected_source_cols {
1040            return Err(DataFusionError::Plan(format!(
1041                "Source query has {} columns, but expected {} non-partition columns",
1042                source_col_count, expected_source_cols
1043            )));
1044        }
1045
1046        let mut stream = df.execute_stream().await?;
1047
1048        let wb = table.new_write_builder();
1049        let mut tw = wb
1050            .new_write()
1051            .map_err(to_datafusion_error)?
1052            .with_overwrite();
1053        let mut row_count = 0u64;
1054
1055        while let Some(batch_result) = stream.next().await {
1056            let batch = batch_result?;
1057            if batch.num_rows() == 0 {
1058                continue;
1059            }
1060            let batch = if let Some(ref reorder) = column_reorder {
1061                let reordered_cols: Vec<ArrayRef> =
1062                    reorder.iter().map(|&i| batch.column(i).clone()).collect();
1063                let reordered_fields: Vec<Field> = reorder
1064                    .iter()
1065                    .map(|&i| batch.schema().field(i).clone())
1066                    .collect();
1067                let reordered_schema = Arc::new(Schema::new(reordered_fields));
1068                RecordBatch::try_new(reordered_schema, reordered_cols)
1069                    .map_err(|e| DataFusionError::ArrowError(Box::new(e), None))?
1070            } else {
1071                batch
1072            };
1073            let augmented = append_partition_columns(
1074                &batch,
1075                &static_partitions,
1076                expected_source_cols,
1077                all_fields,
1078            )?;
1079            row_count += augmented.num_rows() as u64;
1080            tw.write_arrow_batch(&augmented)
1081                .await
1082                .map_err(to_datafusion_error)?;
1083        }
1084
1085        let messages = tw.prepare_commit().await.map_err(to_datafusion_error)?;
1086        let commit = wb.new_commit();
1087
1088        let overwrite_partitions = if static_partitions.is_empty() {
1089            None
1090        } else {
1091            Some(static_partitions)
1092        };
1093        commit
1094            .overwrite(messages, overwrite_partitions)
1095            .await
1096            .map_err(to_datafusion_error)?;
1097
1098        crate::merge_into::ok_result(&self.ctx, row_count)
1099    }
1100
1101    async fn handle_truncate_table(&self, truncate: &Truncate) -> DFResult<DataFrame> {
1102        if truncate.table_names.len() > 1 {
1103            return Err(DataFusionError::Plan(
1104                "TRUNCATE TABLE does not support multiple tables".to_string(),
1105            ));
1106        }
1107        let target = truncate.table_names.first().ok_or_else(|| {
1108            DataFusionError::Plan("TRUNCATE TABLE requires a table name".to_string())
1109        })?;
1110        let (catalog, _catalog_name, identifier) = self.resolve_catalog_and_table(&target.name)?;
1111        let table = match catalog.get_table(&identifier).await {
1112            Ok(t) => t,
1113            Err(e) if truncate.if_exists && is_table_not_exist(&e) => {
1114                return ok_result(&self.ctx);
1115            }
1116            Err(e) => return Err(to_datafusion_error(e)),
1117        };
1118
1119        let wb = table.new_write_builder();
1120        let commit = wb.new_commit();
1121
1122        if let Some(partitions) = &truncate.partitions {
1123            if partitions.is_empty() {
1124                return Err(DataFusionError::Plan(
1125                    "PARTITION clause requires at least one column = value".to_string(),
1126                ));
1127            }
1128            let partition_values = parse_partition_values(
1129                partitions,
1130                table.schema().fields(),
1131                table.schema().partition_keys(),
1132            )?;
1133            commit
1134                .truncate_partitions(partition_values)
1135                .await
1136                .map_err(to_datafusion_error)?;
1137            return ok_result(&self.ctx);
1138        }
1139
1140        commit.truncate_table().await.map_err(to_datafusion_error)?;
1141        ok_result(&self.ctx)
1142    }
1143
1144    async fn handle_create_view(&self, create_view: &CreateView) -> DFResult<DataFrame> {
1145        if create_view.materialized {
1146            return Err(DataFusionError::Plan(
1147                "CREATE MATERIALIZED VIEW is not supported".to_string(),
1148            ));
1149        }
1150
1151        let view_name = create_view.name.to_string();
1152        let table_ref: TableReference = view_name.as_str().into();
1153        let (catalog, database, name) = self.resolve_temp_table_name(table_ref)?;
1154
1155        // Use DataFusion's SQL planner to convert the sqlparser Query into a LogicalPlan
1156        let query_sql = create_view.query.to_string();
1157        let df = self.ctx.sql(&query_sql).await?;
1158        let logical_plan = df.logical_plan().clone();
1159
1160        if create_view.temporary {
1161            if create_view.if_not_exists
1162                && self.temp_table_exist(format!("{catalog}.{database}.{name}"))?
1163            {
1164                return ok_result(&self.ctx);
1165            }
1166            // Create a ViewTable and register it as a temp table
1167            let view_table = datafusion::datasource::ViewTable::new(logical_plan, Some(query_sql));
1168            self.register_temp_table(format!("{catalog}.{database}.{name}"), Arc::new(view_table))?;
1169            ok_result(&self.ctx)
1170        } else {
1171            Err(DataFusionError::Plan(
1172                "CREATE VIEW (non-temporary) is not supported. Use CREATE TEMPORARY VIEW instead."
1173                    .to_string(),
1174            ))
1175        }
1176    }
1177
1178    async fn handle_drop_partitions(
1179        &self,
1180        catalog: &Arc<dyn Catalog>,
1181        identifier: &Identifier,
1182        partitions: &[SqlExpr],
1183        if_exists: bool,
1184    ) -> DFResult<DataFrame> {
1185        if partitions.is_empty() {
1186            return Err(DataFusionError::Plan(
1187                "DROP PARTITIONS requires at least one partition specification".to_string(),
1188            ));
1189        }
1190        let table = match catalog.get_table(identifier).await {
1191            Ok(t) => t,
1192            Err(e) if if_exists && is_table_not_exist(&e) => {
1193                return ok_result(&self.ctx);
1194            }
1195            Err(e) => return Err(to_datafusion_error(e)),
1196        };
1197
1198        let partition_values = parse_partition_values(
1199            partitions,
1200            table.schema().fields(),
1201            table.schema().partition_keys(),
1202        )?;
1203
1204        let wb = table.new_write_builder();
1205        let commit = wb.new_commit();
1206        commit
1207            .truncate_partitions(partition_values)
1208            .await
1209            .map_err(to_datafusion_error)?;
1210
1211        ok_result(&self.ctx)
1212    }
1213
1214    /// Returns the name of the current default catalog from DataFusion config.
1215    pub(crate) fn current_catalog_name(&self) -> String {
1216        self.ctx
1217            .state()
1218            .config_options()
1219            .catalog
1220            .default_catalog
1221            .clone()
1222    }
1223
1224    fn current_catalog(&self) -> DFResult<Arc<dyn Catalog>> {
1225        let name = self.current_catalog_name();
1226        self.catalogs.get(&name).cloned().ok_or_else(|| {
1227            DataFusionError::Plan(
1228                "No catalog registered. Call register_catalog() first.".to_string(),
1229            )
1230        })
1231    }
1232
1233    /// Check whether a TableReference targets a registered Paimon catalog.
1234    fn is_paimon_catalog_ref(&self, table_ref: &TableReference) -> bool {
1235        let catalog_name = match table_ref {
1236            TableReference::Full { catalog, .. } => catalog.to_string(),
1237            TableReference::Partial { .. } | TableReference::Bare { .. } => {
1238                self.current_catalog_name()
1239            }
1240        };
1241        self.catalogs.contains_key(&catalog_name)
1242    }
1243
1244    /// Resolve an ObjectName like `catalog.db.table` or `db.table` to a catalog and Identifier.
1245    fn resolve_catalog_and_table(
1246        &self,
1247        name: &ObjectName,
1248    ) -> DFResult<(Arc<dyn Catalog>, String, Identifier)> {
1249        let parts: Vec<String> = name
1250            .0
1251            .iter()
1252            .filter_map(|p| p.as_ident().map(|id| id.value.clone()))
1253            .collect();
1254        match parts.len() {
1255            3 => {
1256                let catalog = self.catalogs.get(&parts[0]).ok_or_else(|| {
1257                    DataFusionError::Plan(format!("Unknown catalog '{}'", parts[0]))
1258                })?;
1259                Ok((
1260                    catalog.clone(),
1261                    parts[0].clone(),
1262                    Identifier::new(parts[1].clone(), parts[2].clone()),
1263                ))
1264            }
1265            2 => {
1266                let catalog = self.current_catalog()?;
1267                Ok((
1268                    catalog,
1269                    self.current_catalog_name(),
1270                    Identifier::new(parts[0].clone(), parts[1].clone()),
1271                ))
1272            }
1273            1 => {
1274                let catalog = self.current_catalog()?;
1275                let default_schema = self
1276                    .ctx
1277                    .state()
1278                    .config_options()
1279                    .catalog
1280                    .default_schema
1281                    .clone();
1282                Ok((
1283                    catalog,
1284                    self.current_catalog_name(),
1285                    Identifier::new(default_schema, parts[0].clone()),
1286                ))
1287            }
1288            _ => Err(DataFusionError::Plan(format!(
1289                "Invalid table reference: {name}"
1290            ))),
1291        }
1292    }
1293
1294    /// Resolve an ObjectName to just the Identifier (for backward compat in handle_alter_table).
1295    fn resolve_table_name(&self, name: &ObjectName) -> DFResult<Identifier> {
1296        let (_catalog, _catalog_name, identifier) = self.resolve_catalog_and_table(name)?;
1297        Ok(identifier)
1298    }
1299}
1300
1301/// Quick check whether the SQL looks like a CREATE TABLE statement.
1302/// Skips leading whitespace, `--` line comments, and `/* */` block comments.
1303fn looks_like_create_table(sql: &str) -> bool {
1304    let bytes = sql.as_bytes();
1305    let len = bytes.len();
1306    let mut i = 0;
1307    // Skip leading whitespace and comments
1308    loop {
1309        while i < len && bytes[i].is_ascii_whitespace() {
1310            i += 1;
1311        }
1312        if i + 1 < len && bytes[i] == b'-' && bytes[i + 1] == b'-' {
1313            i += 2;
1314            while i < len && bytes[i] != b'\n' {
1315                i += 1;
1316            }
1317            continue;
1318        }
1319        if i + 1 < len && bytes[i] == b'/' && bytes[i + 1] == b'*' {
1320            i += 2;
1321            while i + 1 < len {
1322                if bytes[i] == b'*' && bytes[i + 1] == b'/' {
1323                    i += 2;
1324                    break;
1325                }
1326                i += 1;
1327            }
1328            continue;
1329        }
1330        break;
1331    }
1332    // Match "CREATE" then whitespace then optional "TEMPORARY"/"TEMP" then "TABLE" (all ASCII, byte-safe)
1333    if i + 6 > len || !bytes[i..i + 6].eq_ignore_ascii_case(b"CREATE") {
1334        return false;
1335    }
1336    i += 6;
1337    if i >= len || !bytes[i].is_ascii_whitespace() {
1338        return false;
1339    }
1340    while i < len && bytes[i].is_ascii_whitespace() {
1341        i += 1;
1342    }
1343    // Skip optional TEMPORARY or TEMP keyword
1344    if i + 9 <= len && bytes[i..i + 9].eq_ignore_ascii_case(b"TEMPORARY") {
1345        i += 9;
1346        while i < len && bytes[i].is_ascii_whitespace() {
1347            i += 1;
1348        }
1349    } else if i + 4 <= len && bytes[i..i + 4].eq_ignore_ascii_case(b"TEMP") {
1350        i += 4;
1351        while i < len && bytes[i].is_ascii_whitespace() {
1352            i += 1;
1353        }
1354    }
1355    // After optional TEMPORARY/TEMP, reject CREATE TEMPORARY VIEW / CREATE TEMP VIEW
1356    if i + 4 <= len && bytes[i..i + 4].eq_ignore_ascii_case(b"VIEW") {
1357        return false;
1358    }
1359    i + 5 <= len && bytes[i..i + 5].eq_ignore_ascii_case(b"TABLE")
1360}
1361
1362/// Find `PARTITIONED BY` keyword position, skipping string literals and comments.
1363fn find_partitioned_by(sql: &str) -> Option<(usize, usize)> {
1364    let bytes = sql.as_bytes();
1365    let len = bytes.len();
1366    let mut i = 0;
1367    while i < len {
1368        match bytes[i] {
1369            b'\'' => {
1370                i += 1;
1371                while i < len {
1372                    if bytes[i] == b'\'' {
1373                        i += 1;
1374                        if i < len && bytes[i] == b'\'' {
1375                            i += 1;
1376                        } else {
1377                            break;
1378                        }
1379                    } else {
1380                        i += 1;
1381                    }
1382                }
1383            }
1384            b'-' if i + 1 < len && bytes[i + 1] == b'-' => {
1385                i += 2;
1386                while i < len && bytes[i] != b'\n' {
1387                    i += 1;
1388                }
1389            }
1390            b'/' if i + 1 < len && bytes[i + 1] == b'*' => {
1391                i += 2;
1392                while i + 1 < len {
1393                    if bytes[i] == b'*' && bytes[i + 1] == b'/' {
1394                        i += 2;
1395                        break;
1396                    }
1397                    i += 1;
1398                }
1399            }
1400            b if b.is_ascii_alphabetic() && i + 11 <= len => {
1401                if bytes[i..i + 11].eq_ignore_ascii_case(b"PARTITIONED") {
1402                    let rest = &bytes[i + 11..];
1403                    let ws = rest.iter().take_while(|b| b.is_ascii_whitespace()).count();
1404                    if ws > 0
1405                        && i + 11 + ws + 2 <= len
1406                        && rest[ws..ws + 2].eq_ignore_ascii_case(b"BY")
1407                    {
1408                        let by_end = i + 11 + ws + 2;
1409                        return Some((i, by_end));
1410                    }
1411                }
1412                i += 1;
1413            }
1414            _ => {
1415                i += 1;
1416            }
1417        }
1418    }
1419    None
1420}
1421
1422/// Parse a single partition column token, handling quoted identifiers.
1423fn parse_partition_column(token: &str) -> DFResult<String> {
1424    let trimmed = token.trim();
1425    if trimmed.is_empty() {
1426        return Err(DataFusionError::Plan(
1427            "Empty column name in PARTITIONED BY".to_string(),
1428        ));
1429    }
1430
1431    let first = trimmed.as_bytes()[0];
1432    if first == b'"' || first == b'`' {
1433        let close = if first == b'"' { b'"' } else { b'`' };
1434        if let Some(end) = trimmed[1..].find(close as char) {
1435            let after_quote = trimmed[1 + end + 1..].trim();
1436            if after_quote.is_empty() {
1437                return Ok(trimmed[1..1 + end].to_string());
1438            }
1439        }
1440        return Err(DataFusionError::Plan(format!(
1441            "Invalid quoted identifier in PARTITIONED BY: {trimmed}"
1442        )));
1443    }
1444
1445    let parts: Vec<&str> = trimmed.split_whitespace().collect();
1446    match parts.len() {
1447        1 => Ok(parts[0].to_string()),
1448        _ => Err(DataFusionError::Plan(format!(
1449            "PARTITIONED BY column '{}' should not specify a type. \
1450             Use column references only, e.g. PARTITIONED BY ({})",
1451            parts[0], parts[0]
1452        ))),
1453    }
1454}
1455
1456/// Extract `PARTITIONED BY (col1, col2, ...)` from SQL before parsing.
1457///
1458/// Paimon only allows column references (no types) in PARTITIONED BY.
1459/// Since sqlparser's GenericDialect requires types in column definitions,
1460/// we extract and validate the clause ourselves, then strip it from the SQL
1461/// so sqlparser can parse the rest.
1462fn extract_partition_by(sql: &str) -> DFResult<(String, Vec<String>)> {
1463    let Some((kw_start, by_end)) = find_partitioned_by(sql) else {
1464        return Ok((sql.to_string(), vec![]));
1465    };
1466
1467    let after_by = sql[by_end..].trim_start();
1468    let paren_start = by_end + (sql[by_end..].len() - after_by.len());
1469
1470    if !after_by.starts_with('(') {
1471        return Err(DataFusionError::Plan(
1472            "Expected '(' after PARTITIONED BY".to_string(),
1473        ));
1474    }
1475
1476    let inner_start = paren_start + 1;
1477    let mut depth = 1;
1478    let mut paren_end = None;
1479    for (i, ch) in sql[inner_start..].char_indices() {
1480        match ch {
1481            '(' => depth += 1,
1482            ')' => {
1483                depth -= 1;
1484                if depth == 0 {
1485                    paren_end = Some(inner_start + i);
1486                    break;
1487                }
1488            }
1489            _ => {}
1490        }
1491    }
1492    let paren_end = paren_end.ok_or_else(|| {
1493        DataFusionError::Plan("Unmatched '(' in PARTITIONED BY clause".to_string())
1494    })?;
1495
1496    let inner = sql[inner_start..paren_end].trim();
1497    if inner.is_empty() {
1498        return Err(DataFusionError::Plan(
1499            "PARTITIONED BY must specify at least one column".to_string(),
1500        ));
1501    }
1502
1503    let mut partition_keys = Vec::new();
1504    for token in inner.split(',') {
1505        partition_keys.push(parse_partition_column(token)?);
1506    }
1507
1508    let clause_end = paren_end + 1;
1509    let mut rewritten = String::with_capacity(sql.len());
1510    rewritten.push_str(&sql[..kw_start]);
1511    rewritten.push_str(&sql[clause_end..]);
1512    Ok((rewritten, partition_keys))
1513}
1514
1515/// Convert a sqlparser [`ColumnDef`] to a Paimon [`SchemaChange::AddColumn`].
1516fn column_def_to_add_column(col: &ColumnDef) -> DFResult<SchemaChange> {
1517    let paimon_type = column_def_to_paimon_type(col)?;
1518    Ok(SchemaChange::add_column(
1519        col.name.value.clone(),
1520        paimon_type,
1521    ))
1522}
1523
1524fn column_def_to_paimon_type(col: &ColumnDef) -> DFResult<PaimonDataType> {
1525    sql_data_type_to_paimon_type(&col.data_type, column_def_nullable(col))
1526}
1527
1528fn column_def_nullable(col: &ColumnDef) -> bool {
1529    !col.options.iter().any(|opt| {
1530        matches!(
1531            opt.option,
1532            datafusion::sql::sqlparser::ast::ColumnOption::NotNull
1533        )
1534    })
1535}
1536
1537/// Convert a sqlparser SQL data type to a Paimon data type.
1538///
1539/// DDL schema translation must use this function instead of going through Arrow,
1540/// because Arrow cannot preserve logical distinctions such as `BLOB` vs `VARBINARY`.
1541fn sql_data_type_to_paimon_type(
1542    sql_type: &datafusion::sql::sqlparser::ast::DataType,
1543    nullable: bool,
1544) -> DFResult<PaimonDataType> {
1545    use datafusion::sql::sqlparser::ast::{
1546        ArrayElemTypeDef, DataType as SqlType, ExactNumberInfo, TimezoneInfo,
1547    };
1548
1549    match sql_type {
1550        SqlType::Boolean => Ok(PaimonDataType::Boolean(BooleanType::with_nullable(
1551            nullable,
1552        ))),
1553        SqlType::TinyInt(_) => Ok(PaimonDataType::TinyInt(TinyIntType::with_nullable(
1554            nullable,
1555        ))),
1556        SqlType::SmallInt(_) => Ok(PaimonDataType::SmallInt(SmallIntType::with_nullable(
1557            nullable,
1558        ))),
1559        SqlType::Int(_) | SqlType::Integer(_) => {
1560            Ok(PaimonDataType::Int(IntType::with_nullable(nullable)))
1561        }
1562        SqlType::BigInt(_) => Ok(PaimonDataType::BigInt(BigIntType::with_nullable(nullable))),
1563        SqlType::Float(_) | SqlType::Real => {
1564            Ok(PaimonDataType::Float(FloatType::with_nullable(nullable)))
1565        }
1566        SqlType::Double(_) | SqlType::DoublePrecision => {
1567            Ok(PaimonDataType::Double(DoubleType::with_nullable(nullable)))
1568        }
1569        SqlType::Varchar(_)
1570        | SqlType::CharVarying(_)
1571        | SqlType::Text
1572        | SqlType::String(_)
1573        | SqlType::Char(_)
1574        | SqlType::Character(_) => Ok(PaimonDataType::VarChar(
1575            VarCharType::with_nullable(nullable, VarCharType::MAX_LENGTH)
1576                .map_err(to_datafusion_error)?,
1577        )),
1578        SqlType::Binary(_) | SqlType::Varbinary(_) | SqlType::Bytea => {
1579            Ok(PaimonDataType::VarBinary(
1580                VarBinaryType::try_new(nullable, VarBinaryType::MAX_LENGTH)
1581                    .map_err(to_datafusion_error)?,
1582            ))
1583        }
1584        SqlType::Blob(_) => Ok(PaimonDataType::Blob(BlobType::with_nullable(nullable))),
1585        SqlType::Date => Ok(PaimonDataType::Date(DateType::with_nullable(nullable))),
1586        SqlType::Timestamp(precision, tz_info) => {
1587            let precision = match precision {
1588                Some(0) => 0,
1589                Some(1..=3) | None => 3,
1590                Some(4..=6) => 6,
1591                _ => 9,
1592            };
1593            match tz_info {
1594                TimezoneInfo::None | TimezoneInfo::WithoutTimeZone => {
1595                    Ok(PaimonDataType::Timestamp(
1596                        TimestampType::with_nullable(nullable, precision)
1597                            .map_err(to_datafusion_error)?,
1598                    ))
1599                }
1600                _ => Ok(PaimonDataType::LocalZonedTimestamp(
1601                    LocalZonedTimestampType::with_nullable(nullable, precision)
1602                        .map_err(to_datafusion_error)?,
1603                )),
1604            }
1605        }
1606        SqlType::Decimal(info) => {
1607            let (precision, scale) = match info {
1608                ExactNumberInfo::PrecisionAndScale(precision, scale) => {
1609                    (*precision as u32, *scale as u32)
1610                }
1611                ExactNumberInfo::Precision(precision) => (*precision as u32, 0),
1612                ExactNumberInfo::None => (10, 0),
1613            };
1614            Ok(PaimonDataType::Decimal(
1615                DecimalType::with_nullable(nullable, precision, scale)
1616                    .map_err(to_datafusion_error)?,
1617            ))
1618        }
1619        SqlType::Array(elem_def) => {
1620            let element_type = match elem_def {
1621                ArrayElemTypeDef::AngleBracket(t)
1622                | ArrayElemTypeDef::SquareBracket(t, _)
1623                | ArrayElemTypeDef::Parenthesis(t) => sql_data_type_to_paimon_type(t, true)?,
1624                ArrayElemTypeDef::None => {
1625                    return Err(DataFusionError::Plan(
1626                        "ARRAY type requires an element type".to_string(),
1627                    ));
1628                }
1629            };
1630            Ok(PaimonDataType::Array(PaimonArrayType::with_nullable(
1631                nullable,
1632                element_type,
1633            )))
1634        }
1635        SqlType::Map(key_type, value_type) => {
1636            let key = sql_data_type_to_paimon_type(key_type, false)?;
1637            let value = sql_data_type_to_paimon_type(value_type, true)?;
1638            Ok(PaimonDataType::Map(PaimonMapType::with_nullable(
1639                nullable, key, value,
1640            )))
1641        }
1642        SqlType::Struct(fields, _) => {
1643            let paimon_fields = fields
1644                .iter()
1645                .enumerate()
1646                .map(|(idx, field)| {
1647                    let name = field
1648                        .field_name
1649                        .as_ref()
1650                        .map(|n| n.value.clone())
1651                        .unwrap_or_default();
1652                    let data_type = sql_data_type_to_paimon_type(&field.field_type, true)?;
1653                    Ok(PaimonDataField::new(idx as i32, name, data_type))
1654                })
1655                .collect::<DFResult<Vec<_>>>()?;
1656            Ok(PaimonDataType::Row(PaimonRowType::with_nullable(
1657                nullable,
1658                paimon_fields,
1659            )))
1660        }
1661        _ => Err(DataFusionError::Plan(format!(
1662            "Unsupported SQL data type: {sql_type}"
1663        ))),
1664    }
1665}
1666
1667fn object_name_to_string(name: &ObjectName) -> String {
1668    name.0
1669        .iter()
1670        .filter_map(|p| p.as_ident().map(|id| id.value.clone()))
1671        .collect::<Vec<_>>()
1672        .join(".")
1673}
1674
1675/// Extract key-value pairs from [`CreateTableOptions`].
1676fn extract_options(opts: &CreateTableOptions) -> DFResult<Vec<(String, String)>> {
1677    let sql_options = match opts {
1678        CreateTableOptions::With(options)
1679        | CreateTableOptions::Options(options)
1680        | CreateTableOptions::TableProperties(options)
1681        | CreateTableOptions::Plain(options) => options,
1682        CreateTableOptions::None => return Ok(Vec::new()),
1683    };
1684    sql_options
1685        .iter()
1686        .map(|opt| match opt {
1687            SqlOption::KeyValue { key, value } => {
1688                let v = value.to_string();
1689                // Strip surrounding quotes from the value if present.
1690                let v = v
1691                    .strip_prefix('\'')
1692                    .and_then(|s| s.strip_suffix('\''))
1693                    .unwrap_or(&v)
1694                    .to_string();
1695                Ok((key.value.clone(), v))
1696            }
1697            other => Err(DataFusionError::Plan(format!(
1698                "Unsupported table option: {other}"
1699            ))),
1700        })
1701        .collect()
1702}
1703
1704fn is_table_not_exist(e: &paimon::Error) -> bool {
1705    matches!(e, paimon::Error::TableNotExist { .. })
1706}
1707
1708/// Parse partition expressions (`col = val, ...`) into partition value maps
1709/// suitable for `TableCommit::truncate_partitions`.
1710///
1711/// All expressions are treated as belonging to a single partition specification.
1712/// For multiple partitions, callers should invoke this once per partition clause.
1713fn parse_partition_values(
1714    exprs: &[SqlExpr],
1715    all_fields: &[PaimonDataField],
1716    partition_keys: &[String],
1717) -> DFResult<Vec<HashMap<String, Option<Datum>>>> {
1718    let field_map: HashMap<&str, &PaimonDataField> =
1719        all_fields.iter().map(|f| (f.name(), f)).collect();
1720
1721    let mut partition = HashMap::new();
1722    for expr in exprs {
1723        let (col_name, val_expr) = match expr {
1724            SqlExpr::BinaryOp {
1725                left,
1726                op: datafusion::sql::sqlparser::ast::BinaryOperator::Eq,
1727                right,
1728            } => {
1729                let col = match left.as_ref() {
1730                    SqlExpr::Identifier(ident) => ident.value.clone(),
1731                    other => {
1732                        return Err(DataFusionError::Plan(format!(
1733                            "Expected column name in partition spec, got: {other}"
1734                        )))
1735                    }
1736                };
1737                (col, right.as_ref())
1738            }
1739            other => {
1740                return Err(DataFusionError::Plan(format!(
1741                    "Expected 'column = value' in partition spec, got: {other}"
1742                )))
1743            }
1744        };
1745
1746        if !partition_keys.iter().any(|k| k == &col_name) {
1747            return Err(DataFusionError::Plan(format!(
1748                "Column '{col_name}' is not a partition column"
1749            )));
1750        }
1751
1752        let field = field_map.get(col_name.as_str()).ok_or_else(|| {
1753            DataFusionError::Plan(format!("Column '{col_name}' not found in table schema"))
1754        })?;
1755        let datum = sql_expr_to_datum(val_expr, field.data_type())?;
1756        partition.insert(col_name, Some(datum));
1757    }
1758
1759    let missing: Vec<&str> = partition_keys
1760        .iter()
1761        .filter(|k| !partition.contains_key(k.as_str()))
1762        .map(|k| k.as_str())
1763        .collect();
1764    if !missing.is_empty() {
1765        return Err(DataFusionError::Plan(format!(
1766            "Incomplete partition spec: missing keys [{}]. All partition columns must be specified.",
1767            missing.join(", ")
1768        )));
1769    }
1770
1771    Ok(vec![partition])
1772}
1773
1774/// Parse static partition assignments from `PARTITION (col = val, ...)` expressions.
1775/// Dynamic partition columns (bare identifiers without `= val`) are skipped —
1776/// they will be read from the source query.
1777fn parse_static_partitions(
1778    exprs: &[SqlExpr],
1779    partition_fields: &[PaimonDataField],
1780    all_fields: &[PaimonDataField],
1781) -> DFResult<HashMap<String, Option<Datum>>> {
1782    let mut result = HashMap::new();
1783    let field_map: HashMap<&str, &PaimonDataField> =
1784        all_fields.iter().map(|f| (f.name(), f)).collect();
1785    let partition_names: Vec<&str> = partition_fields.iter().map(|f| f.name()).collect();
1786
1787    for expr in exprs {
1788        let (col_name, val_expr) = match expr {
1789            SqlExpr::BinaryOp {
1790                left,
1791                op: datafusion::sql::sqlparser::ast::BinaryOperator::Eq,
1792                right,
1793            } => {
1794                let col = match left.as_ref() {
1795                    SqlExpr::Identifier(ident) => ident.value.clone(),
1796                    other => {
1797                        return Err(DataFusionError::Plan(format!(
1798                            "Expected column name in PARTITION clause, got: {other}"
1799                        )))
1800                    }
1801                };
1802                (col, right.as_ref())
1803            }
1804            // Dynamic partition: bare column name without value — skip it,
1805            // the column will be read from the source query.
1806            SqlExpr::Identifier(ident) => {
1807                let col_name = &ident.value;
1808                if !partition_names.contains(&col_name.as_str()) {
1809                    return Err(DataFusionError::Plan(format!(
1810                        "Column '{col_name}' is not a partition column"
1811                    )));
1812                }
1813                continue;
1814            }
1815            other => {
1816                return Err(DataFusionError::Plan(format!(
1817                    "Unsupported expression in PARTITION clause: {other}"
1818                )))
1819            }
1820        };
1821
1822        if !partition_names.contains(&col_name.as_str()) {
1823            return Err(DataFusionError::Plan(format!(
1824                "Column '{col_name}' is not a partition column"
1825            )));
1826        }
1827
1828        let field = field_map.get(col_name.as_str()).ok_or_else(|| {
1829            DataFusionError::Plan(format!("Column '{col_name}' not found in table schema"))
1830        })?;
1831        let datum = sql_expr_to_datum(val_expr, field.data_type())?;
1832        result.insert(col_name, Some(datum));
1833    }
1834
1835    Ok(result)
1836}
1837
1838/// Convert a SQL literal expression to a Paimon Datum.
1839fn sql_expr_to_datum(expr: &SqlExpr, data_type: &PaimonDataType) -> DFResult<Datum> {
1840    let (value, negate) = match expr {
1841        SqlExpr::Value(v) => (&v.value, false),
1842        SqlExpr::UnaryOp {
1843            op: datafusion::sql::sqlparser::ast::UnaryOperator::Minus,
1844            expr: inner,
1845        } => {
1846            if let SqlExpr::Value(v) = inner.as_ref() {
1847                (&v.value, true)
1848            } else {
1849                return Err(DataFusionError::Plan(format!(
1850                    "Unsupported partition value expression: {expr}"
1851                )));
1852            }
1853        }
1854        other => {
1855            return Err(DataFusionError::Plan(format!(
1856                "Unsupported partition value expression: {other}"
1857            )))
1858        }
1859    };
1860
1861    match (value, data_type) {
1862        (SqlValue::Number(n, _), _) => parse_number_datum(n, data_type, negate),
1863        (SqlValue::SingleQuotedString(s), PaimonDataType::VarChar(_)) if !negate => {
1864            Ok(Datum::String(s.clone()))
1865        }
1866        (SqlValue::SingleQuotedString(s), PaimonDataType::Date(_)) if !negate => {
1867            let date = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")
1868                .map_err(|e| DataFusionError::Plan(format!("Invalid DATE '{s}': {e}")))?;
1869            let epoch = chrono::NaiveDate::from_ymd_opt(1970, 1, 1).unwrap();
1870            Ok(Datum::Date((date - epoch).num_days() as i32))
1871        }
1872        (SqlValue::Boolean(b), PaimonDataType::Boolean(_)) if !negate => Ok(Datum::Bool(*b)),
1873        _ if negate => Err(DataFusionError::Plan(format!(
1874            "Cannot negate value for type {data_type:?}"
1875        ))),
1876        _ => Err(DataFusionError::Plan(format!(
1877            "Cannot convert {value} to {data_type:?}"
1878        ))),
1879    }
1880}
1881
1882fn parse_number_datum(n: &str, data_type: &PaimonDataType, negate: bool) -> DFResult<Datum> {
1883    let s: String = if negate {
1884        format!("-{n}")
1885    } else {
1886        n.to_string()
1887    };
1888    match data_type {
1889        PaimonDataType::TinyInt(_) => {
1890            Ok(Datum::TinyInt(s.parse::<i8>().map_err(|e| {
1891                DataFusionError::Plan(format!("Invalid TINYINT: {e}"))
1892            })?))
1893        }
1894        PaimonDataType::SmallInt(_) => {
1895            Ok(Datum::SmallInt(s.parse::<i16>().map_err(|e| {
1896                DataFusionError::Plan(format!("Invalid SMALLINT: {e}"))
1897            })?))
1898        }
1899        PaimonDataType::Int(_) => {
1900            Ok(Datum::Int(s.parse::<i32>().map_err(|e| {
1901                DataFusionError::Plan(format!("Invalid INT: {e}"))
1902            })?))
1903        }
1904        PaimonDataType::BigInt(_) => {
1905            Ok(Datum::Long(s.parse::<i64>().map_err(|e| {
1906                DataFusionError::Plan(format!("Invalid BIGINT: {e}"))
1907            })?))
1908        }
1909        PaimonDataType::Float(_) => {
1910            Ok(Datum::Float(s.parse::<f32>().map_err(|e| {
1911                DataFusionError::Plan(format!("Invalid FLOAT: {e}"))
1912            })?))
1913        }
1914        PaimonDataType::Double(_) => {
1915            Ok(Datum::Double(s.parse::<f64>().map_err(|e| {
1916                DataFusionError::Plan(format!("Invalid DOUBLE: {e}"))
1917            })?))
1918        }
1919        _ => Err(DataFusionError::Plan(format!(
1920            "Cannot convert {n} to {data_type:?}"
1921        ))),
1922    }
1923}
1924
1925/// Append static partition columns to a RecordBatch.
1926fn append_partition_columns(
1927    batch: &RecordBatch,
1928    partitions: &HashMap<String, Option<Datum>>,
1929    expected_source_cols: usize,
1930    all_fields: &[PaimonDataField],
1931) -> DFResult<RecordBatch> {
1932    let num_rows = batch.num_rows();
1933
1934    let mut columns: Vec<(String, ArrayRef)> = Vec::with_capacity(all_fields.len());
1935
1936    let mut source_col_idx = 0;
1937    for field in all_fields {
1938        let name = field.name().to_string();
1939        if let Some(datum_opt) = partitions.get(&name) {
1940            let array = datum_to_constant_array(datum_opt, field.data_type(), num_rows)?;
1941            columns.push((name, array));
1942        } else {
1943            if source_col_idx >= batch.num_columns() {
1944                return Err(DataFusionError::Plan(format!(
1945                    "Source query has fewer columns than expected non-partition columns. \
1946                     Expected column '{name}' at position {source_col_idx}"
1947                )));
1948            }
1949            let col = batch.column(source_col_idx).clone();
1950            let target_type = paimon::arrow::paimon_type_to_arrow(field.data_type())
1951                .map_err(to_datafusion_error)?;
1952            let col = if col.data_type() != &target_type {
1953                cast(&col, &target_type).map_err(|e| {
1954                    DataFusionError::Plan(format!(
1955                        "Cannot cast column '{name}' from {:?} to {:?}: {e}",
1956                        col.data_type(),
1957                        target_type
1958                    ))
1959                })?
1960            } else {
1961                col
1962            };
1963            columns.push((name, col));
1964            source_col_idx += 1;
1965        }
1966    }
1967
1968    if source_col_idx != batch.num_columns() || source_col_idx != expected_source_cols {
1969        return Err(DataFusionError::Plan(format!(
1970            "Source query has {} columns, but expected {} non-partition columns",
1971            batch.num_columns(),
1972            expected_source_cols
1973        )));
1974    }
1975
1976    let fields: Vec<Field> = columns
1977        .iter()
1978        .map(|(name, arr)| Field::new(name, arr.data_type().clone(), true))
1979        .collect();
1980    let schema = Arc::new(Schema::new(fields));
1981    let arrays: Vec<ArrayRef> = columns.into_iter().map(|(_, arr)| arr).collect();
1982    RecordBatch::try_new(schema, arrays).map_err(|e| DataFusionError::ArrowError(Box::new(e), None))
1983}
1984
1985/// Create a constant Arrow array from a Datum value.
1986/// Only variants produced by `sql_expr_to_datum` are supported here.
1987fn datum_to_constant_array(
1988    datum: &Option<Datum>,
1989    data_type: &PaimonDataType,
1990    num_rows: usize,
1991) -> DFResult<ArrayRef> {
1992    match datum {
1993        None => {
1994            let arrow_type =
1995                paimon::arrow::paimon_type_to_arrow(data_type).map_err(to_datafusion_error)?;
1996            Ok(new_null_array(&arrow_type, num_rows))
1997        }
1998        Some(d) => match d {
1999            Datum::Bool(v) => Ok(Arc::new(BooleanArray::from(vec![*v; num_rows]))),
2000            Datum::TinyInt(v) => Ok(Arc::new(Int8Array::from(vec![*v; num_rows]))),
2001            Datum::SmallInt(v) => Ok(Arc::new(Int16Array::from(vec![*v; num_rows]))),
2002            Datum::Int(v) => Ok(Arc::new(Int32Array::from(vec![*v; num_rows]))),
2003            Datum::Long(v) => Ok(Arc::new(Int64Array::from(vec![*v; num_rows]))),
2004            Datum::Float(v) => Ok(Arc::new(Float32Array::from(vec![*v; num_rows]))),
2005            Datum::Double(v) => Ok(Arc::new(Float64Array::from(vec![*v; num_rows]))),
2006            Datum::String(v) => Ok(Arc::new(StringArray::from(vec![v.as_str(); num_rows]))),
2007            Datum::Date(v) => Ok(Arc::new(Date32Array::from(vec![*v; num_rows]))),
2008            Datum::Time(_)
2009            | Datum::Timestamp { .. }
2010            | Datum::LocalZonedTimestamp { .. }
2011            | Datum::Decimal { .. }
2012            | Datum::Bytes(_) => Err(DataFusionError::Plan(format!(
2013                "Unsupported datum type for partition column: {d}"
2014            ))),
2015        },
2016    }
2017}
2018
2019struct VersionAsOfInfo {
2020    table_name: String,
2021    version: String,
2022    /// Byte range (start, end) covering "table_name VERSION AS OF n"
2023    clause_range: (usize, usize),
2024}
2025
2026struct TimestampAsOfInfo {
2027    table_name: String,
2028    timestamp: String,
2029    /// Byte range (start, end) covering "table_name TIMESTAMP AS OF 'ts'"
2030    clause_range: (usize, usize),
2031}
2032
2033/// Check whether a SQL string contains a time-travel keyword (`VERSION AS OF` or
2034/// `TIMESTAMP AS OF`) **outside** of single-quoted string literals, `--` line
2035/// comments, and `/* */` block comments.
2036fn contains_time_travel_keyword(sql: &str) -> bool {
2037    let lower = sql.to_lowercase();
2038    let bytes = lower.as_bytes();
2039    let len = bytes.len();
2040    let mut i = 0;
2041    while i < len {
2042        match bytes[i] {
2043            b'\'' => {
2044                // Skip string literal
2045                i += 1;
2046                while i < len {
2047                    if bytes[i] == b'\'' {
2048                        i += 1;
2049                        if i < len && bytes[i] == b'\'' {
2050                            i += 1; // escaped quote
2051                        } else {
2052                            break;
2053                        }
2054                    } else {
2055                        i += 1;
2056                    }
2057                }
2058            }
2059            b'-' if i + 1 < len && bytes[i + 1] == b'-' => {
2060                // Skip line comment
2061                i += 2;
2062                while i < len && bytes[i] != b'\n' {
2063                    i += 1;
2064                }
2065            }
2066            b'/' if i + 1 < len && bytes[i + 1] == b'*' => {
2067                // Skip block comment
2068                i += 2;
2069                while i + 1 < len {
2070                    if bytes[i] == b'*' && bytes[i + 1] == b'/' {
2071                        i += 2;
2072                        break;
2073                    }
2074                    i += 1;
2075                }
2076            }
2077            _ => {
2078                // Check for keywords
2079                if i + 14 <= len && bytes[i..i + 14].eq_ignore_ascii_case(b"version as of ") {
2080                    return true;
2081                }
2082                if i + 16 <= len && bytes[i..i + 16].eq_ignore_ascii_case(b"timestamp as of ") {
2083                    return true;
2084                }
2085                i += 1;
2086            }
2087        }
2088    }
2089    false
2090}
2091
2092/// Extract **all** `VERSION AS OF <n>` or `VERSION AS OF '<tag>'` clauses from a
2093/// SQL string, skipping string literals and comments.
2094fn extract_all_version_as_of(sql: &str) -> Vec<VersionAsOfInfo> {
2095    let lower = sql.to_lowercase();
2096    let bytes = lower.as_bytes();
2097    let len = bytes.len();
2098    let sql_bytes = sql.as_bytes();
2099    let mut i = 0;
2100    let mut results = Vec::new();
2101
2102    while i < len {
2103        match bytes[i] {
2104            b'\'' => {
2105                // Skip string literal
2106                i += 1;
2107                while i < len {
2108                    if sql_bytes[i] == b'\'' {
2109                        i += 1;
2110                        if i < len && sql_bytes[i] == b'\'' {
2111                            i += 1; // escaped quote
2112                        } else {
2113                            break;
2114                        }
2115                    } else {
2116                        i += 1;
2117                    }
2118                }
2119            }
2120            b'-' if i + 1 < len && bytes[i + 1] == b'-' => {
2121                // Skip line comment
2122                i += 2;
2123                while i < len && bytes[i] != b'\n' {
2124                    i += 1;
2125                }
2126            }
2127            b'/' if i + 1 < len && bytes[i + 1] == b'*' => {
2128                // Skip block comment
2129                i += 2;
2130                while i + 1 < len {
2131                    if bytes[i] == b'*' && bytes[i + 1] == b'/' {
2132                        i += 2;
2133                        break;
2134                    }
2135                    i += 1;
2136                }
2137            }
2138            _ => {
2139                if i + 14 <= len && bytes[i..i + 14].eq_ignore_ascii_case(b"version as of ") {
2140                    let kw_start = i;
2141                    let val_start = i + 14;
2142                    let remaining = &sql[val_start..];
2143
2144                    // Parse either a quoted tag name or a numeric snapshot ID
2145                    let version = if let Some(after_quote) = remaining.strip_prefix('\'') {
2146                        // Tag name: VERSION AS OF 'tagname'
2147                        if let Some(close_quote) = after_quote.find('\'') {
2148                            after_quote[..close_quote].to_string()
2149                        } else {
2150                            i += 1;
2151                            continue;
2152                        }
2153                    } else {
2154                        // Numeric snapshot ID: VERSION AS OF 1
2155                        let v: String = remaining
2156                            .chars()
2157                            .take_while(|c| c.is_ascii_digit())
2158                            .collect();
2159                        if v.is_empty() {
2160                            i += 1;
2161                            continue;
2162                        }
2163                        v
2164                    };
2165
2166                    let is_quoted = remaining.starts_with('\'');
2167                    let val_end = if is_quoted {
2168                        val_start + version.len() + 2 // 2 quotes
2169                    } else {
2170                        val_start + version.len()
2171                    };
2172
2173                    // Walk backwards from kw_start to find the table name boundary
2174                    let table_end = sql[..kw_start].trim_end_matches(' ').len();
2175                    let table_start = sql[..table_end]
2176                        .rfind(|c: char| c.is_whitespace() || c == ',' || c == '(')
2177                        .map(|idx| idx + 1)
2178                        .unwrap_or(0);
2179                    let table_name = sql[table_start..table_end].to_string();
2180
2181                    if !table_name.is_empty() {
2182                        results.push(VersionAsOfInfo {
2183                            table_name,
2184                            version,
2185                            clause_range: (table_start, val_end),
2186                        });
2187                    }
2188
2189                    i = val_end;
2190                } else {
2191                    i += 1;
2192                }
2193            }
2194        }
2195    }
2196
2197    results
2198}
2199
2200/// Extract **all** `TIMESTAMP AS OF '<ts>'` clauses from a SQL string, skipping
2201/// string literals and comments.
2202fn extract_all_timestamp_as_of(sql: &str) -> Vec<TimestampAsOfInfo> {
2203    let lower = sql.to_lowercase();
2204    let bytes = lower.as_bytes();
2205    let len = bytes.len();
2206    let sql_bytes = sql.as_bytes();
2207    let mut i = 0;
2208    let mut results = Vec::new();
2209
2210    while i < len {
2211        match bytes[i] {
2212            b'\'' => {
2213                // Skip string literal
2214                i += 1;
2215                while i < len {
2216                    if sql_bytes[i] == b'\'' {
2217                        i += 1;
2218                        if i < len && sql_bytes[i] == b'\'' {
2219                            i += 1; // escaped quote
2220                        } else {
2221                            break;
2222                        }
2223                    } else {
2224                        i += 1;
2225                    }
2226                }
2227            }
2228            b'-' if i + 1 < len && bytes[i + 1] == b'-' => {
2229                // Skip line comment
2230                i += 2;
2231                while i < len && bytes[i] != b'\n' {
2232                    i += 1;
2233                }
2234            }
2235            b'/' if i + 1 < len && bytes[i + 1] == b'*' => {
2236                // Skip block comment
2237                i += 2;
2238                while i + 1 < len {
2239                    if bytes[i] == b'*' && bytes[i + 1] == b'/' {
2240                        i += 2;
2241                        break;
2242                    }
2243                    i += 1;
2244                }
2245            }
2246            _ => {
2247                if i + 16 <= len && bytes[i..i + 16].eq_ignore_ascii_case(b"timestamp as of ") {
2248                    let kw_start = i;
2249                    let val_start = i + 16;
2250                    let remaining = &sql[val_start..];
2251
2252                    // Read the quoted timestamp string
2253                    if !remaining.starts_with('\'') {
2254                        i += 1;
2255                        continue;
2256                    }
2257                    if let Some(close_quote) = remaining[1..].find('\'') {
2258                        let timestamp = remaining[1..close_quote + 1].to_string();
2259                        let val_end = val_start + close_quote + 2; // skip both quotes
2260
2261                        // Walk backwards to find the table name boundary
2262                        let table_end = sql[..kw_start].trim_end_matches(' ').len();
2263                        let table_start = sql[..table_end]
2264                            .rfind(|c: char| c.is_whitespace() || c == ',' || c == '(')
2265                            .map(|idx| idx + 1)
2266                            .unwrap_or(0);
2267                        let table_name = sql[table_start..table_end].to_string();
2268
2269                        if !table_name.is_empty() {
2270                            results.push(TimestampAsOfInfo {
2271                                table_name,
2272                                timestamp,
2273                                clause_range: (table_start, val_end),
2274                            });
2275                        }
2276
2277                        i = val_end;
2278                    } else {
2279                        i += 1;
2280                    }
2281                } else {
2282                    i += 1;
2283                }
2284            }
2285        }
2286    }
2287
2288    results
2289}
2290
2291/// Return an empty DataFrame with a single "result" column containing "OK".
2292fn ok_result(ctx: &SessionContext) -> DFResult<DataFrame> {
2293    let schema = Arc::new(Schema::new(vec![Field::new(
2294        "result",
2295        ArrowDataType::Utf8,
2296        false,
2297    )]));
2298    let batch = RecordBatch::try_new(
2299        schema.clone(),
2300        vec![Arc::new(StringArray::from(vec!["OK"]))],
2301    )?;
2302    let df = ctx.read_batch(batch)?;
2303    Ok(df)
2304}
2305
2306/// Registers the built-in table-valued functions against `catalog` so they can
2307/// be used in SQL without any extra setup call. Called for every catalog
2308/// registered on the context; add new built-in table functions here.
2309fn register_table_functions(
2310    ctx: &SessionContext,
2311    catalog: &Arc<dyn Catalog>,
2312    default_database: &str,
2313) {
2314    crate::vector_search::register_vector_search(ctx, Arc::clone(catalog), default_database);
2315    #[cfg(feature = "fulltext")]
2316    crate::full_text_search::register_full_text_search(ctx, Arc::clone(catalog), default_database);
2317}
2318
2319#[cfg(test)]
2320mod tests {
2321    use super::*;
2322    use std::collections::HashMap;
2323    use std::sync::Mutex;
2324
2325    use async_trait::async_trait;
2326    use paimon::catalog::Database;
2327    use paimon::spec::{DataType as PaimonDataType, Schema as PaimonSchema};
2328    use paimon::table::Table;
2329
2330    // ==================== Mock Catalog ====================
2331
2332    #[allow(clippy::enum_variant_names)]
2333    #[derive(Debug)]
2334    enum CatalogCall {
2335        CreateTable {
2336            identifier: Identifier,
2337            schema: PaimonSchema,
2338            ignore_if_exists: bool,
2339        },
2340        AlterTable {
2341            identifier: Identifier,
2342            changes: Vec<SchemaChange>,
2343            ignore_if_not_exists: bool,
2344        },
2345        RenameTable {
2346            from: Identifier,
2347            to: Identifier,
2348            ignore_if_not_exists: bool,
2349        },
2350    }
2351
2352    struct MockCatalog {
2353        calls: Mutex<Vec<CatalogCall>>,
2354    }
2355
2356    impl MockCatalog {
2357        fn new() -> Self {
2358            Self {
2359                calls: Mutex::new(Vec::new()),
2360            }
2361        }
2362
2363        fn take_calls(&self) -> Vec<CatalogCall> {
2364            std::mem::take(&mut *self.calls.lock().unwrap())
2365        }
2366    }
2367
2368    #[async_trait]
2369    impl Catalog for MockCatalog {
2370        async fn list_databases(&self) -> paimon::Result<Vec<String>> {
2371            Ok(vec![])
2372        }
2373        async fn create_database(
2374            &self,
2375            _name: &str,
2376            _ignore_if_exists: bool,
2377            _properties: HashMap<String, String>,
2378        ) -> paimon::Result<()> {
2379            Ok(())
2380        }
2381        async fn get_database(&self, _name: &str) -> paimon::Result<Database> {
2382            Err(paimon::Error::DatabaseNotExist {
2383                database: _name.to_string(),
2384            })
2385        }
2386        async fn drop_database(
2387            &self,
2388            _name: &str,
2389            _ignore_if_not_exists: bool,
2390            _cascade: bool,
2391        ) -> paimon::Result<()> {
2392            Ok(())
2393        }
2394        async fn get_table(&self, _identifier: &Identifier) -> paimon::Result<Table> {
2395            Err(paimon::Error::TableNotExist {
2396                full_name: _identifier.to_string(),
2397            })
2398        }
2399        async fn list_tables(&self, _database_name: &str) -> paimon::Result<Vec<String>> {
2400            Ok(vec![])
2401        }
2402        async fn create_table(
2403            &self,
2404            identifier: &Identifier,
2405            creation: PaimonSchema,
2406            ignore_if_exists: bool,
2407        ) -> paimon::Result<()> {
2408            self.calls.lock().unwrap().push(CatalogCall::CreateTable {
2409                identifier: identifier.clone(),
2410                schema: creation,
2411                ignore_if_exists,
2412            });
2413            Ok(())
2414        }
2415        async fn drop_table(
2416            &self,
2417            _identifier: &Identifier,
2418            _ignore_if_not_exists: bool,
2419        ) -> paimon::Result<()> {
2420            Ok(())
2421        }
2422        async fn rename_table(
2423            &self,
2424            from: &Identifier,
2425            to: &Identifier,
2426            ignore_if_not_exists: bool,
2427        ) -> paimon::Result<()> {
2428            self.calls.lock().unwrap().push(CatalogCall::RenameTable {
2429                from: from.clone(),
2430                to: to.clone(),
2431                ignore_if_not_exists,
2432            });
2433            Ok(())
2434        }
2435        async fn alter_table(
2436            &self,
2437            identifier: &Identifier,
2438            changes: Vec<SchemaChange>,
2439            ignore_if_not_exists: bool,
2440        ) -> paimon::Result<()> {
2441            self.calls.lock().unwrap().push(CatalogCall::AlterTable {
2442                identifier: identifier.clone(),
2443                changes,
2444                ignore_if_not_exists,
2445            });
2446            Ok(())
2447        }
2448    }
2449
2450    async fn make_sql_context(catalog: Arc<MockCatalog>) -> SQLContext {
2451        let mut ctx = SQLContext::new();
2452        ctx.register_catalog("paimon", catalog).await.unwrap();
2453        ctx
2454    }
2455
2456    fn assert_sql_type_to_paimon(
2457        sql_type: datafusion::sql::sqlparser::ast::DataType,
2458        expected: PaimonDataType,
2459    ) {
2460        assert_eq!(
2461            sql_data_type_to_paimon_type(&sql_type, true).unwrap(),
2462            expected
2463        );
2464    }
2465
2466    // ==================== sql_data_type_to_paimon_type tests ====================
2467
2468    #[test]
2469    fn test_sql_type_boolean() {
2470        use datafusion::sql::sqlparser::ast::DataType as SqlType;
2471        assert_sql_type_to_paimon(
2472            SqlType::Boolean,
2473            PaimonDataType::Boolean(BooleanType::new()),
2474        );
2475    }
2476
2477    #[test]
2478    fn test_sql_type_integers() {
2479        use datafusion::sql::sqlparser::ast::DataType as SqlType;
2480        assert_sql_type_to_paimon(
2481            SqlType::TinyInt(None),
2482            PaimonDataType::TinyInt(TinyIntType::new()),
2483        );
2484        assert_sql_type_to_paimon(
2485            SqlType::SmallInt(None),
2486            PaimonDataType::SmallInt(SmallIntType::new()),
2487        );
2488        assert_sql_type_to_paimon(SqlType::Int(None), PaimonDataType::Int(IntType::new()));
2489        assert_sql_type_to_paimon(SqlType::Integer(None), PaimonDataType::Int(IntType::new()));
2490        assert_sql_type_to_paimon(
2491            SqlType::BigInt(None),
2492            PaimonDataType::BigInt(BigIntType::new()),
2493        );
2494    }
2495
2496    #[test]
2497    fn test_sql_type_floats() {
2498        use datafusion::sql::sqlparser::ast::{DataType as SqlType, ExactNumberInfo};
2499        assert_sql_type_to_paimon(
2500            SqlType::Float(ExactNumberInfo::None),
2501            PaimonDataType::Float(FloatType::new()),
2502        );
2503        assert_sql_type_to_paimon(SqlType::Real, PaimonDataType::Float(FloatType::new()));
2504        assert_sql_type_to_paimon(
2505            SqlType::DoublePrecision,
2506            PaimonDataType::Double(DoubleType::new()),
2507        );
2508    }
2509
2510    #[test]
2511    fn test_sql_type_string_variants() {
2512        use datafusion::sql::sqlparser::ast::DataType as SqlType;
2513        for sql_type in [SqlType::Varchar(None), SqlType::Text, SqlType::String(None)] {
2514            assert_sql_type_to_paimon(
2515                sql_type.clone(),
2516                PaimonDataType::VarChar(
2517                    VarCharType::with_nullable(true, VarCharType::MAX_LENGTH).unwrap(),
2518                ),
2519            );
2520        }
2521    }
2522
2523    #[test]
2524    fn test_sql_type_binary() {
2525        use datafusion::sql::sqlparser::ast::DataType as SqlType;
2526        assert_sql_type_to_paimon(
2527            SqlType::Bytea,
2528            PaimonDataType::VarBinary(
2529                VarBinaryType::try_new(true, VarBinaryType::MAX_LENGTH).unwrap(),
2530            ),
2531        );
2532    }
2533
2534    #[test]
2535    fn test_sql_type_date() {
2536        use datafusion::sql::sqlparser::ast::DataType as SqlType;
2537        assert_sql_type_to_paimon(SqlType::Date, PaimonDataType::Date(DateType::new()));
2538    }
2539
2540    #[test]
2541    fn test_sql_type_timestamp_default() {
2542        use datafusion::sql::sqlparser::ast::{DataType as SqlType, TimezoneInfo};
2543        assert_sql_type_to_paimon(
2544            SqlType::Timestamp(None, TimezoneInfo::None),
2545            PaimonDataType::Timestamp(TimestampType::with_nullable(true, 3).unwrap()),
2546        );
2547    }
2548
2549    #[test]
2550    fn test_sql_type_timestamp_with_precision() {
2551        use datafusion::sql::sqlparser::ast::{DataType as SqlType, TimezoneInfo};
2552        assert_sql_type_to_paimon(
2553            SqlType::Timestamp(Some(0), TimezoneInfo::None),
2554            PaimonDataType::Timestamp(TimestampType::with_nullable(true, 0).unwrap()),
2555        );
2556        assert_sql_type_to_paimon(
2557            SqlType::Timestamp(Some(3), TimezoneInfo::None),
2558            PaimonDataType::Timestamp(TimestampType::with_nullable(true, 3).unwrap()),
2559        );
2560        assert_sql_type_to_paimon(
2561            SqlType::Timestamp(Some(6), TimezoneInfo::None),
2562            PaimonDataType::Timestamp(TimestampType::with_nullable(true, 6).unwrap()),
2563        );
2564        assert_sql_type_to_paimon(
2565            SqlType::Timestamp(Some(9), TimezoneInfo::None),
2566            PaimonDataType::Timestamp(TimestampType::with_nullable(true, 9).unwrap()),
2567        );
2568    }
2569
2570    #[test]
2571    fn test_sql_type_timestamp_with_tz() {
2572        use datafusion::sql::sqlparser::ast::{DataType as SqlType, TimezoneInfo};
2573        assert_sql_type_to_paimon(
2574            SqlType::Timestamp(None, TimezoneInfo::WithTimeZone),
2575            PaimonDataType::LocalZonedTimestamp(
2576                LocalZonedTimestampType::with_nullable(true, 3).unwrap(),
2577            ),
2578        );
2579    }
2580
2581    #[test]
2582    fn test_sql_type_decimal() {
2583        use datafusion::sql::sqlparser::ast::{DataType as SqlType, ExactNumberInfo};
2584        assert_sql_type_to_paimon(
2585            SqlType::Decimal(ExactNumberInfo::PrecisionAndScale(18, 2)),
2586            PaimonDataType::Decimal(DecimalType::with_nullable(true, 18, 2).unwrap()),
2587        );
2588        assert_sql_type_to_paimon(
2589            SqlType::Decimal(ExactNumberInfo::Precision(10)),
2590            PaimonDataType::Decimal(DecimalType::with_nullable(true, 10, 0).unwrap()),
2591        );
2592        assert_sql_type_to_paimon(
2593            SqlType::Decimal(ExactNumberInfo::None),
2594            PaimonDataType::Decimal(DecimalType::with_nullable(true, 10, 0).unwrap()),
2595        );
2596    }
2597
2598    #[test]
2599    fn test_sql_type_unsupported() {
2600        use datafusion::sql::sqlparser::ast::DataType as SqlType;
2601        assert!(sql_data_type_to_paimon_type(&SqlType::Regclass, true).is_err());
2602    }
2603
2604    #[test]
2605    fn test_sql_type_array() {
2606        use datafusion::sql::sqlparser::ast::{ArrayElemTypeDef, DataType as SqlType};
2607        assert_sql_type_to_paimon(
2608            SqlType::Array(ArrayElemTypeDef::AngleBracket(Box::new(SqlType::Int(None)))),
2609            PaimonDataType::Array(PaimonArrayType::with_nullable(
2610                true,
2611                PaimonDataType::Int(IntType::new()),
2612            )),
2613        );
2614    }
2615
2616    #[test]
2617    fn test_sql_type_array_no_element() {
2618        use datafusion::sql::sqlparser::ast::{ArrayElemTypeDef, DataType as SqlType};
2619        assert!(
2620            sql_data_type_to_paimon_type(&SqlType::Array(ArrayElemTypeDef::None), true).is_err()
2621        );
2622    }
2623
2624    #[test]
2625    fn test_sql_type_map() {
2626        use datafusion::sql::sqlparser::ast::DataType as SqlType;
2627        assert_sql_type_to_paimon(
2628            SqlType::Map(
2629                Box::new(SqlType::Varchar(None)),
2630                Box::new(SqlType::Int(None)),
2631            ),
2632            PaimonDataType::Map(PaimonMapType::with_nullable(
2633                true,
2634                PaimonDataType::VarChar(
2635                    VarCharType::with_nullable(false, VarCharType::MAX_LENGTH).unwrap(),
2636                ),
2637                PaimonDataType::Int(IntType::new()),
2638            )),
2639        );
2640    }
2641
2642    #[test]
2643    fn test_sql_type_struct() {
2644        use datafusion::sql::sqlparser::ast::{
2645            DataType as SqlType, Ident, StructBracketKind, StructField,
2646        };
2647        assert_sql_type_to_paimon(
2648            SqlType::Struct(
2649                vec![
2650                    StructField {
2651                        field_name: Some(Ident::new("name")),
2652                        field_type: SqlType::Varchar(None),
2653                        options: None,
2654                    },
2655                    StructField {
2656                        field_name: Some(Ident::new("age")),
2657                        field_type: SqlType::Int(None),
2658                        options: None,
2659                    },
2660                ],
2661                StructBracketKind::AngleBrackets,
2662            ),
2663            PaimonDataType::Row(PaimonRowType::with_nullable(
2664                true,
2665                vec![
2666                    PaimonDataField::new(
2667                        0,
2668                        "name".to_string(),
2669                        PaimonDataType::VarChar(
2670                            VarCharType::with_nullable(true, VarCharType::MAX_LENGTH).unwrap(),
2671                        ),
2672                    ),
2673                    PaimonDataField::new(1, "age".to_string(), PaimonDataType::Int(IntType::new())),
2674                ],
2675            )),
2676        );
2677    }
2678
2679    // ==================== resolve_table_name tests ====================
2680
2681    #[tokio::test]
2682    async fn test_resolve_three_part_name() {
2683        let catalog = Arc::new(MockCatalog::new());
2684        let sql_context = make_sql_context(catalog).await;
2685        let dialect = GenericDialect {};
2686        let stmts = Parser::parse_sql(&dialect, "SELECT * FROM paimon.mydb.mytable").unwrap();
2687        if let Statement::Query(q) = &stmts[0] {
2688            if let datafusion::sql::sqlparser::ast::SetExpr::Select(sel) = q.body.as_ref() {
2689                if let datafusion::sql::sqlparser::ast::TableFactor::Table { name, .. } =
2690                    &sel.from[0].relation
2691                {
2692                    let id = sql_context.resolve_table_name(name).unwrap();
2693                    assert_eq!(id.database(), "mydb");
2694                    assert_eq!(id.object(), "mytable");
2695                }
2696            }
2697        }
2698    }
2699
2700    #[tokio::test]
2701    async fn test_resolve_two_part_name() {
2702        let catalog = Arc::new(MockCatalog::new());
2703        let sql_context = make_sql_context(catalog).await;
2704        let dialect = GenericDialect {};
2705        let stmts = Parser::parse_sql(&dialect, "SELECT * FROM mydb.mytable").unwrap();
2706        if let Statement::Query(q) = &stmts[0] {
2707            if let datafusion::sql::sqlparser::ast::SetExpr::Select(sel) = q.body.as_ref() {
2708                if let datafusion::sql::sqlparser::ast::TableFactor::Table { name, .. } =
2709                    &sel.from[0].relation
2710                {
2711                    let id = sql_context.resolve_table_name(name).unwrap();
2712                    assert_eq!(id.database(), "mydb");
2713                    assert_eq!(id.object(), "mytable");
2714                }
2715            }
2716        }
2717    }
2718
2719    #[tokio::test]
2720    async fn test_resolve_wrong_catalog_name() {
2721        let catalog = Arc::new(MockCatalog::new());
2722        let sql_context = make_sql_context(catalog).await;
2723        let dialect = GenericDialect {};
2724        let stmts = Parser::parse_sql(&dialect, "SELECT * FROM other.mydb.mytable").unwrap();
2725        if let Statement::Query(q) = &stmts[0] {
2726            if let datafusion::sql::sqlparser::ast::SetExpr::Select(sel) = q.body.as_ref() {
2727                if let datafusion::sql::sqlparser::ast::TableFactor::Table { name, .. } =
2728                    &sel.from[0].relation
2729                {
2730                    let err = sql_context.resolve_table_name(name).unwrap_err();
2731                    assert!(err.to_string().contains("Unknown catalog"));
2732                }
2733            }
2734        }
2735    }
2736
2737    #[tokio::test]
2738    async fn test_resolve_single_part_name_uses_default_schema() {
2739        let catalog = Arc::new(MockCatalog::new());
2740        let sql_context = make_sql_context(catalog).await;
2741        let dialect = GenericDialect {};
2742        let stmts = Parser::parse_sql(&dialect, "SELECT * FROM mytable").unwrap();
2743        if let Statement::Query(q) = &stmts[0] {
2744            if let datafusion::sql::sqlparser::ast::SetExpr::Select(sel) = q.body.as_ref() {
2745                if let datafusion::sql::sqlparser::ast::TableFactor::Table { name, .. } =
2746                    &sel.from[0].relation
2747                {
2748                    let id = sql_context.resolve_table_name(name).unwrap();
2749                    assert_eq!(id.database(), "default");
2750                    assert_eq!(id.object(), "mytable");
2751                }
2752            }
2753        }
2754    }
2755
2756    // ==================== extract_options tests ====================
2757
2758    #[test]
2759    fn test_extract_options_none() {
2760        let opts = extract_options(&CreateTableOptions::None).unwrap();
2761        assert!(opts.is_empty());
2762    }
2763
2764    #[test]
2765    fn test_extract_options_with_kv() {
2766        // Parse a CREATE TABLE with WITH options to get a real CreateTableOptions
2767        let dialect = GenericDialect {};
2768        let stmts =
2769            Parser::parse_sql(&dialect, "CREATE TABLE t (id INT) WITH ('bucket' = '4')").unwrap();
2770        if let Statement::CreateTable(ct) = &stmts[0] {
2771            let opts = extract_options(&ct.table_options).unwrap();
2772            assert_eq!(opts.len(), 1);
2773            assert_eq!(opts[0].0, "bucket");
2774            assert_eq!(opts[0].1, "4");
2775        } else {
2776            panic!("expected CreateTable");
2777        }
2778    }
2779
2780    // ==================== SQLContext::sql integration tests ====================
2781
2782    #[tokio::test]
2783    async fn test_create_table_basic() {
2784        let catalog = Arc::new(MockCatalog::new());
2785        let sql_context = make_sql_context(catalog.clone()).await;
2786
2787        sql_context
2788            .sql("CREATE TABLE mydb.t1 (id INT NOT NULL, name VARCHAR, PRIMARY KEY (id))")
2789            .await
2790            .unwrap();
2791
2792        let calls = catalog.take_calls();
2793        assert_eq!(calls.len(), 1);
2794        if let CatalogCall::CreateTable {
2795            identifier,
2796            schema,
2797            ignore_if_exists,
2798        } = &calls[0]
2799        {
2800            assert_eq!(identifier.database(), "mydb");
2801            assert_eq!(identifier.object(), "t1");
2802            assert!(!ignore_if_exists);
2803            assert_eq!(schema.primary_keys(), &["id"]);
2804        } else {
2805            panic!("expected CreateTable call");
2806        }
2807    }
2808
2809    #[tokio::test]
2810    async fn test_create_table_if_not_exists() {
2811        let catalog = Arc::new(MockCatalog::new());
2812        let sql_context = make_sql_context(catalog.clone()).await;
2813
2814        sql_context
2815            .sql("CREATE TABLE IF NOT EXISTS mydb.t1 (id INT)")
2816            .await
2817            .unwrap();
2818
2819        let calls = catalog.take_calls();
2820        assert_eq!(calls.len(), 1);
2821        if let CatalogCall::CreateTable {
2822            ignore_if_exists, ..
2823        } = &calls[0]
2824        {
2825            assert!(ignore_if_exists);
2826        } else {
2827            panic!("expected CreateTable call");
2828        }
2829    }
2830
2831    #[tokio::test]
2832    async fn test_create_table_with_options() {
2833        let catalog = Arc::new(MockCatalog::new());
2834        let sql_context = make_sql_context(catalog.clone()).await;
2835
2836        sql_context
2837            .sql("CREATE TABLE mydb.t1 (id INT) WITH ('bucket' = '4', 'file.format' = 'parquet')")
2838            .await
2839            .unwrap();
2840
2841        let calls = catalog.take_calls();
2842        assert_eq!(calls.len(), 1);
2843        if let CatalogCall::CreateTable { schema, .. } = &calls[0] {
2844            let opts = schema.options();
2845            assert_eq!(opts.get("bucket").unwrap(), "4");
2846            assert_eq!(opts.get("file.format").unwrap(), "parquet");
2847        } else {
2848            panic!("expected CreateTable call");
2849        }
2850    }
2851
2852    #[tokio::test]
2853    async fn test_create_table_three_part_name() {
2854        let catalog = Arc::new(MockCatalog::new());
2855        let sql_context = make_sql_context(catalog.clone()).await;
2856
2857        sql_context
2858            .sql("CREATE TABLE paimon.mydb.t1 (id INT)")
2859            .await
2860            .unwrap();
2861
2862        let calls = catalog.take_calls();
2863        if let CatalogCall::CreateTable { identifier, .. } = &calls[0] {
2864            assert_eq!(identifier.database(), "mydb");
2865            assert_eq!(identifier.object(), "t1");
2866        } else {
2867            panic!("expected CreateTable call");
2868        }
2869    }
2870
2871    #[tokio::test]
2872    async fn test_create_table_blob_type_preserved() {
2873        let catalog = Arc::new(MockCatalog::new());
2874        let sql_context = make_sql_context(catalog.clone()).await;
2875
2876        sql_context
2877            .sql("CREATE TABLE mydb.t1 (id INT, payload BLOB NOT NULL) WITH ('data-evolution.enabled' = 'true')")
2878            .await
2879            .unwrap();
2880
2881        let calls = catalog.take_calls();
2882        assert_eq!(calls.len(), 1);
2883        if let CatalogCall::CreateTable { schema, .. } = &calls[0] {
2884            assert_eq!(schema.fields().len(), 2);
2885            assert!(matches!(
2886                schema.fields()[1].data_type(),
2887                PaimonDataType::Blob(_)
2888            ));
2889            assert!(!schema.fields()[1].data_type().is_nullable());
2890        } else {
2891            panic!("expected CreateTable call");
2892        }
2893    }
2894
2895    #[tokio::test]
2896    async fn test_alter_table_add_column() {
2897        let catalog = Arc::new(MockCatalog::new());
2898        let sql_context = make_sql_context(catalog.clone()).await;
2899
2900        sql_context
2901            .sql("ALTER TABLE mydb.t1 ADD COLUMN age INT")
2902            .await
2903            .unwrap();
2904
2905        let calls = catalog.take_calls();
2906        assert_eq!(calls.len(), 1);
2907        if let CatalogCall::AlterTable {
2908            identifier,
2909            changes,
2910            ..
2911        } = &calls[0]
2912        {
2913            assert_eq!(identifier.database(), "mydb");
2914            assert_eq!(identifier.object(), "t1");
2915            assert_eq!(changes.len(), 1);
2916            assert!(
2917                matches!(&changes[0], SchemaChange::AddColumn { field_name, .. } if field_name == "age")
2918            );
2919        } else {
2920            panic!("expected AlterTable call");
2921        }
2922    }
2923
2924    #[tokio::test]
2925    async fn test_alter_table_add_blob_column() {
2926        let catalog = Arc::new(MockCatalog::new());
2927        let sql_context = make_sql_context(catalog.clone()).await;
2928
2929        sql_context
2930            .sql("ALTER TABLE mydb.t1 ADD COLUMN payload BLOB")
2931            .await
2932            .unwrap();
2933
2934        let calls = catalog.take_calls();
2935        assert_eq!(calls.len(), 1);
2936        if let CatalogCall::AlterTable { changes, .. } = &calls[0] {
2937            assert_eq!(changes.len(), 1);
2938            assert!(matches!(
2939                &changes[0],
2940                SchemaChange::AddColumn {
2941                    field_name,
2942                    data_type,
2943                    ..
2944                } if field_name == "payload" && matches!(data_type, PaimonDataType::Blob(_))
2945            ));
2946        } else {
2947            panic!("expected AlterTable call");
2948        }
2949    }
2950
2951    #[tokio::test]
2952    async fn test_alter_table_drop_column() {
2953        let catalog = Arc::new(MockCatalog::new());
2954        let sql_context = make_sql_context(catalog.clone()).await;
2955
2956        sql_context
2957            .sql("ALTER TABLE mydb.t1 DROP COLUMN age")
2958            .await
2959            .unwrap();
2960
2961        let calls = catalog.take_calls();
2962        assert_eq!(calls.len(), 1);
2963        if let CatalogCall::AlterTable { changes, .. } = &calls[0] {
2964            assert_eq!(changes.len(), 1);
2965            assert!(
2966                matches!(&changes[0], SchemaChange::DropColumn { field_name } if field_name == "age")
2967            );
2968        } else {
2969            panic!("expected AlterTable call");
2970        }
2971    }
2972
2973    #[tokio::test]
2974    async fn test_alter_table_rename_column() {
2975        let catalog = Arc::new(MockCatalog::new());
2976        let sql_context = make_sql_context(catalog.clone()).await;
2977
2978        sql_context
2979            .sql("ALTER TABLE mydb.t1 RENAME COLUMN old_name TO new_name")
2980            .await
2981            .unwrap();
2982
2983        let calls = catalog.take_calls();
2984        assert_eq!(calls.len(), 1);
2985        if let CatalogCall::AlterTable { changes, .. } = &calls[0] {
2986            assert_eq!(changes.len(), 1);
2987            assert!(matches!(
2988                &changes[0],
2989                SchemaChange::RenameColumn { field_name, new_name }
2990                    if field_name == "old_name" && new_name == "new_name"
2991            ));
2992        } else {
2993            panic!("expected AlterTable call");
2994        }
2995    }
2996
2997    #[tokio::test]
2998    async fn test_alter_table_rename_table() {
2999        let catalog = Arc::new(MockCatalog::new());
3000        let sql_context = make_sql_context(catalog.clone()).await;
3001
3002        sql_context
3003            .sql("ALTER TABLE mydb.t1 RENAME TO t2")
3004            .await
3005            .unwrap();
3006
3007        let calls = catalog.take_calls();
3008        assert_eq!(calls.len(), 1);
3009        if let CatalogCall::RenameTable { from, to, .. } = &calls[0] {
3010            assert_eq!(from.database(), "mydb");
3011            assert_eq!(from.object(), "t1");
3012            assert_eq!(to.database(), "mydb");
3013            assert_eq!(to.object(), "t2");
3014        } else {
3015            panic!("expected RenameTable call");
3016        }
3017    }
3018
3019    #[tokio::test]
3020    async fn test_alter_table_if_exists_add_column() {
3021        let catalog = Arc::new(MockCatalog::new());
3022        let sql_context = make_sql_context(catalog.clone()).await;
3023
3024        sql_context
3025            .sql("ALTER TABLE IF EXISTS mydb.t1 ADD COLUMN age INT")
3026            .await
3027            .unwrap();
3028
3029        let calls = catalog.take_calls();
3030        assert_eq!(calls.len(), 1);
3031        if let CatalogCall::AlterTable {
3032            ignore_if_not_exists,
3033            ..
3034        } = &calls[0]
3035        {
3036            assert!(ignore_if_not_exists);
3037        } else {
3038            panic!("expected AlterTable call");
3039        }
3040    }
3041
3042    #[tokio::test]
3043    async fn test_alter_table_without_if_exists() {
3044        let catalog = Arc::new(MockCatalog::new());
3045        let sql_context = make_sql_context(catalog.clone()).await;
3046
3047        sql_context
3048            .sql("ALTER TABLE mydb.t1 ADD COLUMN age INT")
3049            .await
3050            .unwrap();
3051
3052        let calls = catalog.take_calls();
3053        if let CatalogCall::AlterTable {
3054            ignore_if_not_exists,
3055            ..
3056        } = &calls[0]
3057        {
3058            assert!(!ignore_if_not_exists);
3059        } else {
3060            panic!("expected AlterTable call");
3061        }
3062    }
3063
3064    #[tokio::test]
3065    async fn test_alter_table_if_exists_rename() {
3066        let catalog = Arc::new(MockCatalog::new());
3067        let sql_context = make_sql_context(catalog.clone()).await;
3068
3069        sql_context
3070            .sql("ALTER TABLE IF EXISTS mydb.t1 RENAME TO t2")
3071            .await
3072            .unwrap();
3073
3074        let calls = catalog.take_calls();
3075        assert_eq!(calls.len(), 1);
3076        if let CatalogCall::RenameTable {
3077            from,
3078            to,
3079            ignore_if_not_exists,
3080        } = &calls[0]
3081        {
3082            assert!(ignore_if_not_exists);
3083            assert_eq!(from.object(), "t1");
3084            assert_eq!(to.object(), "t2");
3085        } else {
3086            panic!("expected RenameTable call");
3087        }
3088    }
3089
3090    #[tokio::test]
3091    async fn test_alter_table_rename_three_part_name() {
3092        let catalog = Arc::new(MockCatalog::new());
3093        let sql_context = make_sql_context(catalog.clone()).await;
3094
3095        sql_context
3096            .sql("ALTER TABLE paimon.mydb.t1 RENAME TO t2")
3097            .await
3098            .unwrap();
3099
3100        let calls = catalog.take_calls();
3101        assert_eq!(calls.len(), 1);
3102        if let CatalogCall::RenameTable { from, to, .. } = &calls[0] {
3103            assert_eq!(from.database(), "mydb");
3104            assert_eq!(from.object(), "t1");
3105            assert_eq!(to.database(), "mydb");
3106            assert_eq!(to.object(), "t2");
3107        } else {
3108            panic!("expected RenameTable call");
3109        }
3110    }
3111
3112    #[tokio::test]
3113    async fn test_sql_parse_error() {
3114        let catalog = Arc::new(MockCatalog::new());
3115        let sql_context = make_sql_context(catalog).await;
3116        let result = sql_context.sql("NOT VALID SQL !!!").await;
3117        assert!(result.is_err());
3118        assert!(result.unwrap_err().to_string().contains("SQL parse error"));
3119    }
3120
3121    #[tokio::test]
3122    async fn test_multiple_statements_error() {
3123        let catalog = Arc::new(MockCatalog::new());
3124        let sql_context = make_sql_context(catalog).await;
3125        let result = sql_context.sql("SELECT 1; SELECT 2").await;
3126        assert!(result.is_err());
3127        assert!(result
3128            .unwrap_err()
3129            .to_string()
3130            .contains("exactly one SQL statement"));
3131    }
3132
3133    #[tokio::test]
3134    async fn test_create_external_table_rejected() {
3135        let catalog = Arc::new(MockCatalog::new());
3136        let sql_context = make_sql_context(catalog).await;
3137        let result = sql_context
3138            .sql("CREATE EXTERNAL TABLE mydb.t1 (id INT) STORED AS PARQUET")
3139            .await;
3140        assert!(result.is_err());
3141        assert!(result
3142            .unwrap_err()
3143            .to_string()
3144            .contains("CREATE EXTERNAL TABLE is not supported"));
3145    }
3146
3147    #[tokio::test]
3148    async fn test_non_ddl_delegates_to_datafusion() {
3149        let catalog = Arc::new(MockCatalog::new());
3150        let sql_context = make_sql_context(catalog.clone()).await;
3151        // SELECT should be delegated to DataFusion, not intercepted
3152        let df = sql_context.sql("SELECT 1 AS x").await.unwrap();
3153        let batches = df.collect().await.unwrap();
3154        assert_eq!(batches.len(), 1);
3155        assert_eq!(batches[0].num_rows(), 1);
3156        // No catalog calls
3157        assert!(catalog.take_calls().is_empty());
3158    }
3159
3160    // ==================== extract_partition_by tests ====================
3161
3162    #[test]
3163    fn test_extract_partition_by_no_clause() {
3164        let (rewritten, keys) = extract_partition_by("CREATE TABLE t (id INT)").unwrap();
3165        assert_eq!(rewritten, "CREATE TABLE t (id INT)");
3166        assert!(keys.is_empty());
3167    }
3168
3169    #[test]
3170    fn test_extract_partition_by_single_column() {
3171        let (rewritten, keys) = extract_partition_by(
3172            "CREATE TABLE t (id INT, dt STRING) PARTITIONED BY (dt) WITH ('k'='v')",
3173        )
3174        .unwrap();
3175        assert_eq!(keys, vec!["dt"]);
3176        assert!(!rewritten.contains("PARTITIONED"));
3177        assert!(rewritten.contains("WITH"));
3178    }
3179
3180    #[test]
3181    fn test_extract_partition_by_multiple_columns() {
3182        let (_, keys) =
3183            extract_partition_by("CREATE TABLE t (a INT, b INT, c INT) PARTITIONED BY (a, b)")
3184                .unwrap();
3185        assert_eq!(keys, vec!["a", "b"]);
3186    }
3187
3188    #[test]
3189    fn test_extract_partition_by_mixed_case() {
3190        let (_, keys) =
3191            extract_partition_by("CREATE TABLE t (dt INT) Partitioned by (dt)").unwrap();
3192        assert_eq!(keys, vec!["dt"]);
3193    }
3194
3195    #[test]
3196    fn test_extract_partition_by_rejects_typed_column() {
3197        let err = extract_partition_by("CREATE TABLE t (dt STRING) PARTITIONED BY (dt STRING)")
3198            .unwrap_err();
3199        assert!(err.to_string().contains("should not specify a type"));
3200    }
3201
3202    #[test]
3203    fn test_extract_partition_by_empty_parens() {
3204        let err = extract_partition_by("CREATE TABLE t (id INT) PARTITIONED BY ()").unwrap_err();
3205        assert!(err.to_string().contains("at least one column"));
3206    }
3207
3208    #[test]
3209    fn test_extract_partition_by_unmatched_paren() {
3210        let err = extract_partition_by("CREATE TABLE t (id INT) PARTITIONED BY (dt").unwrap_err();
3211        assert!(err.to_string().contains("Unmatched"));
3212    }
3213
3214    #[test]
3215    fn test_extract_partition_by_skips_string_literal() {
3216        let sql =
3217            "CREATE TABLE t (id INT) WITH ('note' = 'PARTITIONED BY (x)') PARTITIONED BY (id)";
3218        let (rewritten, keys) = extract_partition_by(sql).unwrap();
3219        assert_eq!(keys, vec!["id"]);
3220        assert!(rewritten.contains("WITH"));
3221        assert!(rewritten.contains("'PARTITIONED BY (x)'"));
3222    }
3223
3224    #[test]
3225    fn test_extract_partition_by_skips_line_comment() {
3226        let sql = "CREATE TABLE t (id INT) -- PARTITIONED BY (x)\nPARTITIONED BY (id)";
3227        let (_, keys) = extract_partition_by(sql).unwrap();
3228        assert_eq!(keys, vec!["id"]);
3229    }
3230
3231    #[test]
3232    fn test_extract_partition_by_double_quoted_identifier() {
3233        let (_, keys) =
3234            extract_partition_by("CREATE TABLE t (\"order\" INT) PARTITIONED BY (\"order\")")
3235                .unwrap();
3236        assert_eq!(keys, vec!["order"]);
3237    }
3238
3239    #[test]
3240    fn test_extract_partition_by_backtick_quoted_identifier() {
3241        let (_, keys) =
3242            extract_partition_by("CREATE TABLE t (`order` INT) PARTITIONED BY (`order`)").unwrap();
3243        assert_eq!(keys, vec!["order"]);
3244    }
3245
3246    #[test]
3247    fn test_extract_partition_by_no_paren_after_by() {
3248        let err = extract_partition_by("CREATE TABLE t (id INT) PARTITIONED BY dt").unwrap_err();
3249        assert!(err.to_string().contains("Expected '('"));
3250    }
3251
3252    #[test]
3253    fn test_extract_partition_by_only_partitioned_no_by() {
3254        let (rewritten, keys) = extract_partition_by("CREATE TABLE partitioned (id INT)").unwrap();
3255        assert_eq!(rewritten, "CREATE TABLE partitioned (id INT)");
3256        assert!(keys.is_empty());
3257    }
3258
3259    #[test]
3260    fn test_extract_partition_by_skips_block_comment() {
3261        let sql = "CREATE TABLE t (id INT) /* PARTITIONED BY (x) */ PARTITIONED BY (id)";
3262        let (rewritten, keys) = extract_partition_by(sql).unwrap();
3263        assert_eq!(keys, vec!["id"]);
3264        assert!(rewritten.contains("/* PARTITIONED BY (x) */"));
3265    }
3266
3267    #[test]
3268    fn test_looks_like_create_table() {
3269        assert!(looks_like_create_table("CREATE TABLE t (id INT)"));
3270        assert!(looks_like_create_table("  create  table t (id INT)"));
3271        assert!(looks_like_create_table(
3272            "CREATE TABLE IF NOT EXISTS t (id INT)",
3273        ));
3274        assert!(looks_like_create_table(
3275            "/* note */ CREATE TABLE t (id INT)",
3276        ));
3277        assert!(looks_like_create_table(
3278            "-- comment\nCREATE TABLE t (id INT)",
3279        ));
3280        assert!(looks_like_create_table(
3281            "/* a */ /* b */ CREATE TABLE t (id INT)",
3282        ));
3283        assert!(!looks_like_create_table("ALTER TABLE t ADD COLUMN x INT"));
3284        assert!(!looks_like_create_table("SELECT 1"));
3285        assert!(!looks_like_create_table(
3286            "SELECT aaaaaaaaaaaaaaaaaaaa中文 FROM t",
3287        ));
3288    }
3289
3290    // ==================== partition key validation tests ====================
3291
3292    #[tokio::test]
3293    async fn test_create_table_partition_key_not_in_columns() {
3294        let catalog = Arc::new(MockCatalog::new());
3295        let sql_context = make_sql_context(catalog).await;
3296        let err = sql_context
3297            .sql("CREATE TABLE mydb.t (id INT, dt STRING) PARTITIONED BY (nonexistent)")
3298            .await
3299            .unwrap_err();
3300        assert!(err.to_string().contains("is not defined in the table"));
3301    }
3302
3303    #[tokio::test]
3304    async fn test_create_table_partition_key_matches_column() {
3305        let catalog = Arc::new(MockCatalog::new());
3306        let sql_context = make_sql_context(catalog.clone()).await;
3307        sql_context
3308            .sql("CREATE TABLE mydb.t (id INT, dt STRING) PARTITIONED BY (dt)")
3309            .await
3310            .unwrap();
3311        let calls = catalog.take_calls();
3312        assert_eq!(calls.len(), 1);
3313        if let CatalogCall::CreateTable { schema, .. } = &calls[0] {
3314            assert_eq!(schema.partition_keys(), &["dt"]);
3315        } else {
3316            panic!("expected CreateTable call");
3317        }
3318    }
3319
3320    // ==================== SET / RESET dynamic options tests ====================
3321
3322    #[tokio::test]
3323    async fn test_set_paimon_option() {
3324        let catalog = Arc::new(MockCatalog::new());
3325        let sql_context = make_sql_context(catalog).await;
3326        sql_context
3327            .sql("SET 'paimon.scan.version' = '1'")
3328            .await
3329            .unwrap();
3330        let opts = sql_context.dynamic_options().read().unwrap();
3331        assert_eq!(opts.get("scan.version").unwrap(), "1");
3332    }
3333
3334    #[tokio::test]
3335    async fn test_set_paimon_option_overwrites() {
3336        let catalog = Arc::new(MockCatalog::new());
3337        let sql_context = make_sql_context(catalog).await;
3338        sql_context
3339            .sql("SET 'paimon.scan.version' = '1'")
3340            .await
3341            .unwrap();
3342        sql_context
3343            .sql("SET 'paimon.scan.version' = '2'")
3344            .await
3345            .unwrap();
3346        let opts = sql_context.dynamic_options().read().unwrap();
3347        assert_eq!(opts.get("scan.version").unwrap(), "2");
3348    }
3349
3350    #[tokio::test]
3351    async fn test_reset_paimon_option() {
3352        let catalog = Arc::new(MockCatalog::new());
3353        let sql_context = make_sql_context(catalog).await;
3354        sql_context
3355            .sql("SET 'paimon.scan.version' = '1'")
3356            .await
3357            .unwrap();
3358        sql_context
3359            .sql("RESET 'paimon.scan.version'")
3360            .await
3361            .unwrap();
3362        let opts = sql_context.dynamic_options().read().unwrap();
3363        assert!(opts.get("scan.version").is_none());
3364    }
3365
3366    #[tokio::test]
3367    async fn test_set_non_paimon_option_delegates() {
3368        let catalog = Arc::new(MockCatalog::new());
3369        let sql_context = make_sql_context(catalog).await;
3370        // DataFusion handles non-paimon SET; should not error and should not
3371        // appear in dynamic_options.
3372        let _ = sql_context
3373            .sql("SET datafusion.optimizer.max_passes = 3")
3374            .await;
3375        let opts = sql_context.dynamic_options().read().unwrap();
3376        assert!(opts.is_empty());
3377    }
3378
3379    #[tokio::test]
3380    async fn test_set_multiple_paimon_options() {
3381        let catalog = Arc::new(MockCatalog::new());
3382        let sql_context = make_sql_context(catalog).await;
3383        sql_context
3384            .sql("SET 'paimon.scan.version' = '1'")
3385            .await
3386            .unwrap();
3387        sql_context
3388            .sql("SET 'paimon.scan.timestamp-millis' = '1000'")
3389            .await
3390            .unwrap();
3391        let opts = sql_context.dynamic_options().read().unwrap();
3392        assert_eq!(opts.len(), 2);
3393        assert_eq!(opts.get("scan.version").unwrap(), "1");
3394        assert_eq!(opts.get("scan.timestamp-millis").unwrap(), "1000");
3395    }
3396
3397    #[tokio::test]
3398    async fn test_reset_nonexistent_paimon_option_is_noop() {
3399        let catalog = Arc::new(MockCatalog::new());
3400        let sql_context = make_sql_context(catalog).await;
3401        sql_context
3402            .sql("RESET 'paimon.scan.version'")
3403            .await
3404            .unwrap();
3405        let opts = sql_context.dynamic_options().read().unwrap();
3406        assert!(opts.is_empty());
3407    }
3408
3409    // ==================== TRUNCATE TABLE / DROP PARTITIONS tests ====================
3410
3411    async fn setup_fs_sql_context() -> (tempfile::TempDir, SQLContext) {
3412        use paimon::{CatalogOptions, FileSystemCatalog, Options};
3413
3414        let temp_dir = tempfile::TempDir::new().unwrap();
3415        let warehouse = format!("file://{}", temp_dir.path().display());
3416        let mut options = Options::new();
3417        options.set(CatalogOptions::WAREHOUSE, warehouse);
3418        let catalog = Arc::new(FileSystemCatalog::new(options).unwrap());
3419
3420        let mut sql_context = SQLContext::new();
3421        sql_context
3422            .register_catalog("paimon", catalog.clone())
3423            .await
3424            .unwrap();
3425        sql_context
3426            .sql("CREATE SCHEMA paimon.test_db")
3427            .await
3428            .unwrap();
3429
3430        (temp_dir, sql_context)
3431    }
3432
3433    #[tokio::test]
3434    async fn test_truncate_table() {
3435        let (_tmp, sql_context) = setup_fs_sql_context().await;
3436
3437        sql_context
3438            .sql("CREATE TABLE paimon.test_db.t1 (id INT, value INT)")
3439            .await
3440            .unwrap();
3441        sql_context
3442            .sql("INSERT INTO paimon.test_db.t1 VALUES (1, 10), (2, 20)")
3443            .await
3444            .unwrap()
3445            .collect()
3446            .await
3447            .unwrap();
3448
3449        sql_context
3450            .sql("TRUNCATE TABLE paimon.test_db.t1")
3451            .await
3452            .unwrap();
3453
3454        let batches = sql_context
3455            .sql("SELECT * FROM paimon.test_db.t1")
3456            .await
3457            .unwrap()
3458            .collect()
3459            .await
3460            .unwrap();
3461        let total: usize = batches.iter().map(|b| b.num_rows()).sum();
3462        assert_eq!(total, 0);
3463    }
3464
3465    #[tokio::test]
3466    async fn test_truncate_table_partition() {
3467        let (_tmp, sql_context) = setup_fs_sql_context().await;
3468
3469        sql_context
3470            .sql("CREATE TABLE paimon.test_db.t2 (pt VARCHAR, id INT) PARTITIONED BY (pt)")
3471            .await
3472            .unwrap();
3473        sql_context
3474            .sql("INSERT INTO paimon.test_db.t2 VALUES ('a', 1), ('a', 2), ('b', 3), ('b', 4)")
3475            .await
3476            .unwrap()
3477            .collect()
3478            .await
3479            .unwrap();
3480
3481        sql_context
3482            .sql("TRUNCATE TABLE paimon.test_db.t2 PARTITION (pt = 'a')")
3483            .await
3484            .unwrap();
3485
3486        let batches = sql_context
3487            .sql("SELECT pt, id FROM paimon.test_db.t2 ORDER BY id")
3488            .await
3489            .unwrap()
3490            .collect()
3491            .await
3492            .unwrap();
3493
3494        let mut rows = Vec::new();
3495        for batch in &batches {
3496            let pts = batch
3497                .column(0)
3498                .as_any()
3499                .downcast_ref::<StringArray>()
3500                .unwrap();
3501            let ids = batch
3502                .column(1)
3503                .as_any()
3504                .downcast_ref::<Int32Array>()
3505                .unwrap();
3506            for i in 0..batch.num_rows() {
3507                rows.push((pts.value(i).to_string(), ids.value(i)));
3508            }
3509        }
3510        assert_eq!(rows, vec![("b".to_string(), 3), ("b".to_string(), 4)]);
3511    }
3512
3513    #[tokio::test]
3514    async fn test_alter_table_drop_partitions() {
3515        let (_tmp, sql_context) = setup_fs_sql_context().await;
3516
3517        sql_context
3518            .sql("CREATE TABLE paimon.test_db.t3 (pt VARCHAR, id INT) PARTITIONED BY (pt)")
3519            .await
3520            .unwrap();
3521        sql_context
3522            .sql("INSERT INTO paimon.test_db.t3 VALUES ('a', 1), ('a', 2), ('b', 3), ('b', 4)")
3523            .await
3524            .unwrap()
3525            .collect()
3526            .await
3527            .unwrap();
3528
3529        sql_context
3530            .sql("ALTER TABLE paimon.test_db.t3 DROP PARTITION (pt = 'b')")
3531            .await
3532            .unwrap();
3533
3534        let batches = sql_context
3535            .sql("SELECT pt, id FROM paimon.test_db.t3 ORDER BY id")
3536            .await
3537            .unwrap()
3538            .collect()
3539            .await
3540            .unwrap();
3541
3542        let mut rows = Vec::new();
3543        for batch in &batches {
3544            let pts = batch
3545                .column(0)
3546                .as_any()
3547                .downcast_ref::<StringArray>()
3548                .unwrap();
3549            let ids = batch
3550                .column(1)
3551                .as_any()
3552                .downcast_ref::<Int32Array>()
3553                .unwrap();
3554            for i in 0..batch.num_rows() {
3555                rows.push((pts.value(i).to_string(), ids.value(i)));
3556            }
3557        }
3558        assert_eq!(rows, vec![("a".to_string(), 1), ("a".to_string(), 2)]);
3559    }
3560
3561    #[tokio::test]
3562    async fn test_truncate_table_incomplete_partition_spec() {
3563        let (_tmp, sql_context) = setup_fs_sql_context().await;
3564
3565        sql_context
3566            .sql("CREATE TABLE paimon.test_db.t_multi (pt1 VARCHAR, pt2 VARCHAR, id INT) PARTITIONED BY (pt1, pt2)")
3567            .await
3568            .unwrap();
3569        sql_context
3570            .sql("INSERT INTO paimon.test_db.t_multi VALUES ('a', 'x', 1)")
3571            .await
3572            .unwrap()
3573            .collect()
3574            .await
3575            .unwrap();
3576
3577        let err = sql_context
3578            .sql("TRUNCATE TABLE paimon.test_db.t_multi PARTITION (pt1 = 'a')")
3579            .await
3580            .unwrap_err();
3581        assert!(
3582            err.to_string().contains("Incomplete partition spec"),
3583            "Expected incomplete partition spec error, got: {err}"
3584        );
3585    }
3586
3587    #[tokio::test]
3588    async fn test_truncate_table_if_exists_nonexistent() {
3589        let (_tmp, sql_context) = setup_fs_sql_context().await;
3590
3591        sql_context
3592            .sql("TRUNCATE TABLE IF EXISTS paimon.test_db.nonexistent")
3593            .await
3594            .unwrap();
3595    }
3596
3597    #[tokio::test]
3598    async fn test_truncate_table_nonexistent_without_if_exists() {
3599        let (_tmp, sql_context) = setup_fs_sql_context().await;
3600
3601        let err = sql_context
3602            .sql("TRUNCATE TABLE paimon.test_db.nonexistent")
3603            .await
3604            .unwrap_err();
3605        assert!(
3606            err.to_string().contains("does not exist"),
3607            "Expected table-not-exist error, got: {err}"
3608        );
3609    }
3610
3611    #[tokio::test]
3612    async fn test_alter_table_if_exists_drop_partition_nonexistent() {
3613        let (_tmp, sql_context) = setup_fs_sql_context().await;
3614
3615        sql_context
3616            .sql("ALTER TABLE IF EXISTS paimon.test_db.nonexistent DROP PARTITION (pt = 'a')")
3617            .await
3618            .unwrap();
3619    }
3620
3621    #[tokio::test]
3622    async fn test_drop_partition_incomplete_spec() {
3623        let (_tmp, sql_context) = setup_fs_sql_context().await;
3624
3625        sql_context
3626            .sql("CREATE TABLE paimon.test_db.t_dp (pt1 VARCHAR, pt2 VARCHAR, id INT) PARTITIONED BY (pt1, pt2)")
3627            .await
3628            .unwrap();
3629        sql_context
3630            .sql("INSERT INTO paimon.test_db.t_dp VALUES ('a', 'x', 1)")
3631            .await
3632            .unwrap()
3633            .collect()
3634            .await
3635            .unwrap();
3636
3637        let err = sql_context
3638            .sql("ALTER TABLE paimon.test_db.t_dp DROP PARTITION (pt1 = 'a')")
3639            .await
3640            .unwrap_err();
3641        assert!(
3642            err.to_string().contains("Incomplete partition spec"),
3643            "Expected incomplete partition spec error, got: {err}"
3644        );
3645    }
3646
3647    #[tokio::test]
3648    async fn test_create_temp_table_if_not_exists() {
3649        let catalog = Arc::new(MockCatalog::new());
3650        let sql_context = make_sql_context(catalog).await;
3651
3652        // First creation succeeds
3653        sql_context
3654            .sql("CREATE TEMPORARY TABLE mydb.t1 (id INT)")
3655            .await
3656            .unwrap();
3657
3658        // Second creation without IF NOT EXISTS should fail
3659        let err = sql_context
3660            .sql("CREATE TEMPORARY TABLE mydb.t1 (id INT)")
3661            .await
3662            .unwrap_err();
3663        assert!(
3664            err.to_string().contains("already exists"),
3665            "Expected already-exists error, got: {err}"
3666        );
3667
3668        // With IF NOT EXISTS, it should succeed silently
3669        sql_context
3670            .sql("CREATE TEMPORARY TABLE IF NOT EXISTS mydb.t1 (id INT)")
3671            .await
3672            .unwrap();
3673    }
3674
3675    #[tokio::test]
3676    async fn test_create_temp_table_if_not_exists_as_select() {
3677        let catalog = Arc::new(MockCatalog::new());
3678        let sql_context = make_sql_context(catalog).await;
3679
3680        // Create temp table with AS SELECT
3681        sql_context
3682            .sql("CREATE TEMPORARY TABLE mydb.t2 AS SELECT 1 AS id")
3683            .await
3684            .unwrap();
3685
3686        // IF NOT EXISTS should skip when the table already exists
3687        sql_context
3688            .sql("CREATE TEMPORARY TABLE IF NOT EXISTS mydb.t2 AS SELECT 2 AS id")
3689            .await
3690            .unwrap();
3691
3692        // Verify the original data is still there (not overwritten)
3693        let df = sql_context.sql("SELECT * FROM mydb.t2").await.unwrap();
3694        let batches = df.collect().await.unwrap();
3695        let val = batches[0]
3696            .column(0)
3697            .as_any()
3698            .downcast_ref::<Int64Array>()
3699            .unwrap();
3700        assert_eq!(val.value(0), 1);
3701    }
3702
3703    #[tokio::test]
3704    async fn test_create_temp_view_if_not_exists() {
3705        let catalog = Arc::new(MockCatalog::new());
3706        let sql_context = make_sql_context(catalog).await;
3707
3708        // First creation succeeds
3709        sql_context
3710            .sql("CREATE TEMPORARY VIEW mydb.v1 AS SELECT 1 AS id")
3711            .await
3712            .unwrap();
3713
3714        // Second creation without IF NOT EXISTS should fail
3715        let err = sql_context
3716            .sql("CREATE TEMPORARY VIEW mydb.v1 AS SELECT 2 AS id")
3717            .await
3718            .unwrap_err();
3719        assert!(
3720            err.to_string().contains("already exists"),
3721            "Expected already-exists error, got: {err}"
3722        );
3723
3724        // With IF NOT EXISTS, it should succeed silently
3725        sql_context
3726            .sql("CREATE TEMPORARY VIEW IF NOT EXISTS mydb.v1 AS SELECT 3 AS id")
3727            .await
3728            .unwrap();
3729
3730        // Verify the original view is still intact
3731        let df = sql_context.sql("SELECT * FROM mydb.v1").await.unwrap();
3732        let batches = df.collect().await.unwrap();
3733        let val = batches[0]
3734            .column(0)
3735            .as_any()
3736            .downcast_ref::<Int64Array>()
3737            .unwrap();
3738        assert_eq!(val.value(0), 1);
3739    }
3740
3741    #[tokio::test]
3742    async fn test_drop_temp_table_if_exists() {
3743        let catalog = Arc::new(MockCatalog::new());
3744        let sql_context = make_sql_context(catalog).await;
3745
3746        // Dropping a nonexistent temp table without IF EXISTS should error
3747        let err = sql_context
3748            .sql("DROP TEMPORARY TABLE mydb.nonexistent")
3749            .await
3750            .unwrap_err();
3751        let msg = err.to_string();
3752        assert!(
3753            msg.contains("doesn't exist")
3754                || msg.contains("does not exist")
3755                || msg.contains("Unknown temp database"),
3756            "Expected table-not-exist error, got: {msg}"
3757        );
3758
3759        // Dropping with IF EXISTS should succeed silently
3760        sql_context
3761            .sql("DROP TEMPORARY TABLE IF EXISTS mydb.nonexistent")
3762            .await
3763            .unwrap();
3764
3765        // Create, then drop with IF EXISTS should actually drop it
3766        sql_context
3767            .sql("CREATE TEMPORARY TABLE mydb.t1 (id INT)")
3768            .await
3769            .unwrap();
3770
3771        sql_context
3772            .sql("DROP TEMPORARY TABLE IF EXISTS mydb.t1")
3773            .await
3774            .unwrap();
3775
3776        // Verify the table is gone
3777        assert!(
3778            !sql_context.temp_table_exist("mydb.t1").unwrap(),
3779            "Expected temp table to be gone after DROP"
3780        );
3781    }
3782
3783    #[tokio::test]
3784    async fn test_drop_temp_view_if_exists() {
3785        let catalog = Arc::new(MockCatalog::new());
3786        let sql_context = make_sql_context(catalog).await;
3787
3788        // Dropping a nonexistent temp view without IF EXISTS should error
3789        let err = sql_context
3790            .sql("DROP TEMPORARY VIEW mydb.nonexistent")
3791            .await
3792            .unwrap_err();
3793        let msg = err.to_string();
3794        assert!(
3795            msg.contains("doesn't exist")
3796                || msg.contains("does not exist")
3797                || msg.contains("Unknown temp database"),
3798            "Expected view-not-exist error, got: {msg}"
3799        );
3800
3801        // Dropping with IF EXISTS should succeed silently
3802        sql_context
3803            .sql("DROP TEMPORARY VIEW IF EXISTS mydb.nonexistent")
3804            .await
3805            .unwrap();
3806
3807        // Create a temp view, then drop with IF EXISTS
3808        sql_context
3809            .sql("CREATE TEMPORARY VIEW mydb.v1 AS SELECT 1 AS id")
3810            .await
3811            .unwrap();
3812
3813        sql_context
3814            .sql("DROP TEMPORARY VIEW IF EXISTS mydb.v1")
3815            .await
3816            .unwrap();
3817
3818        // Verify the view is gone
3819        assert!(
3820            !sql_context.temp_table_exist("mydb.v1").unwrap(),
3821            "Expected temp view to be gone after DROP"
3822        );
3823    }
3824
3825    #[test]
3826    fn test_extract_version_as_of() {
3827        let sql = "SELECT id, name FROM paimon.default.time_travel_table VERSION AS OF 1";
3828        let infos = extract_all_version_as_of(sql);
3829        assert_eq!(infos.len(), 1);
3830        let info = &infos[0];
3831        assert_eq!(info.version, "1");
3832        assert_eq!(info.table_name, "paimon.default.time_travel_table");
3833        let rewritten = format!(
3834            "{}__uuid{}",
3835            &sql[..info.clause_range.0],
3836            &sql[info.clause_range.1..]
3837        );
3838        assert_eq!(rewritten, "SELECT id, name FROM __uuid");
3839    }
3840
3841    #[test]
3842    fn test_extract_version_as_of_multi_digit() {
3843        let sql = "SELECT * FROM mydb.t VERSION AS OF 42";
3844        let infos = extract_all_version_as_of(sql);
3845        assert_eq!(infos.len(), 1);
3846        let info = &infos[0];
3847        assert_eq!(info.version, "42");
3848        assert_eq!(info.table_name, "mydb.t");
3849        let rewritten = format!(
3850            "{}__uuid{}",
3851            &sql[..info.clause_range.0],
3852            &sql[info.clause_range.1..]
3853        );
3854        assert_eq!(rewritten, "SELECT * FROM __uuid");
3855    }
3856
3857    #[test]
3858    fn test_extract_version_as_of_case_insensitive() {
3859        let sql = "SELECT * FROM t version as of 5";
3860        let infos = extract_all_version_as_of(sql);
3861        assert_eq!(infos.len(), 1);
3862        let info = &infos[0];
3863        assert_eq!(info.version, "5");
3864        assert_eq!(info.table_name, "t");
3865        let rewritten = format!(
3866            "{}__uuid{}",
3867            &sql[..info.clause_range.0],
3868            &sql[info.clause_range.1..]
3869        );
3870        assert_eq!(rewritten, "SELECT * FROM __uuid");
3871    }
3872
3873    #[test]
3874    fn test_extract_version_as_of_not_present() {
3875        let sql = "SELECT * FROM t";
3876        assert!(extract_all_version_as_of(sql).is_empty());
3877    }
3878
3879    #[test]
3880    fn test_extract_version_as_of_tag() {
3881        let sql = "SELECT id, name FROM paimon.default.t VERSION AS OF 'snapshot1'";
3882        let infos = extract_all_version_as_of(sql);
3883        assert_eq!(infos.len(), 1);
3884        let info = &infos[0];
3885        assert_eq!(info.version, "snapshot1");
3886        assert_eq!(info.table_name, "paimon.default.t");
3887        let rewritten = format!(
3888            "{}__uuid{}",
3889            &sql[..info.clause_range.0],
3890            &sql[info.clause_range.1..]
3891        );
3892        assert_eq!(rewritten, "SELECT id, name FROM __uuid");
3893    }
3894
3895    #[test]
3896    fn test_extract_version_as_of_tag_case_insensitive() {
3897        let sql = "SELECT * FROM t version as of 'my_tag'";
3898        let infos = extract_all_version_as_of(sql);
3899        assert_eq!(infos.len(), 1);
3900        let info = &infos[0];
3901        assert_eq!(info.version, "my_tag");
3902        assert_eq!(info.table_name, "t");
3903        let rewritten = format!(
3904            "{}__uuid{}",
3905            &sql[..info.clause_range.0],
3906            &sql[info.clause_range.1..]
3907        );
3908        assert_eq!(rewritten, "SELECT * FROM __uuid");
3909    }
3910
3911    #[test]
3912    fn test_extract_version_as_of_numeric_still_works() {
3913        let sql = "SELECT * FROM t VERSION AS OF 123";
3914        let infos = extract_all_version_as_of(sql);
3915        assert_eq!(infos.len(), 1);
3916        assert_eq!(infos[0].version, "123");
3917        assert_eq!(infos[0].table_name, "t");
3918    }
3919
3920    #[test]
3921    fn test_extract_version_as_of_multiple() {
3922        // JOIN two time-travel tables
3923        let sql = "SELECT * FROM t1 VERSION AS OF 1 JOIN t2 VERSION AS OF 2 ON t1.id = t2.id";
3924        let infos = extract_all_version_as_of(sql);
3925        assert_eq!(infos.len(), 2);
3926        assert_eq!(infos[0].version, "1");
3927        assert_eq!(infos[0].table_name, "t1");
3928        assert_eq!(infos[1].version, "2");
3929        assert_eq!(infos[1].table_name, "t2");
3930    }
3931
3932    #[test]
3933    fn test_extract_version_as_of_skips_string_literal() {
3934        let sql = "SELECT * FROM t WHERE note = 'version as of 1'";
3935        let infos = extract_all_version_as_of(sql);
3936        assert!(infos.is_empty());
3937    }
3938
3939    #[test]
3940    fn test_extract_version_as_of_skips_comment() {
3941        let sql = "SELECT * FROM t -- version as of 1\n WHERE id > 0";
3942        let infos = extract_all_version_as_of(sql);
3943        assert!(infos.is_empty());
3944    }
3945
3946    #[test]
3947    fn test_contains_time_travel_keyword() {
3948        assert!(contains_time_travel_keyword(
3949            "SELECT * FROM t VERSION AS OF 1"
3950        ));
3951        assert!(contains_time_travel_keyword(
3952            "SELECT * FROM t TIMESTAMP AS OF '2024-01-01 00:00:00'"
3953        ));
3954        // Inside string literal — should NOT match
3955        assert!(!contains_time_travel_keyword(
3956            "SELECT * FROM t WHERE note = 'version as of 1'"
3957        ));
3958        // Inside comment — should NOT match
3959        assert!(!contains_time_travel_keyword(
3960            "SELECT * FROM t -- version as of 1"
3961        ));
3962        assert!(!contains_time_travel_keyword(
3963            "SELECT * FROM t /* timestamp as of now */ WHERE id > 0"
3964        ));
3965        // No keyword at all
3966        assert!(!contains_time_travel_keyword("SELECT * FROM t"));
3967    }
3968
3969    #[test]
3970    fn test_extract_timestamp_as_of() {
3971        let sql = "SELECT * FROM paimon.default.t TIMESTAMP AS OF '2024-01-15 10:30:00'";
3972        let infos = extract_all_timestamp_as_of(sql);
3973        assert_eq!(infos.len(), 1);
3974        let info = &infos[0];
3975        assert_eq!(info.timestamp, "2024-01-15 10:30:00");
3976        assert_eq!(info.table_name, "paimon.default.t");
3977        let rewritten = format!(
3978            "{}__uuid{}",
3979            &sql[..info.clause_range.0],
3980            &sql[info.clause_range.1..]
3981        );
3982        assert_eq!(rewritten, "SELECT * FROM __uuid");
3983    }
3984
3985    #[test]
3986    fn test_extract_timestamp_as_of_case_insensitive() {
3987        let sql = "SELECT * FROM t timestamp as of '2024-06-01 00:00:00'";
3988        let infos = extract_all_timestamp_as_of(sql);
3989        assert_eq!(infos.len(), 1);
3990        let info = &infos[0];
3991        assert_eq!(info.timestamp, "2024-06-01 00:00:00");
3992        assert_eq!(info.table_name, "t");
3993        let rewritten = format!(
3994            "{}__uuid{}",
3995            &sql[..info.clause_range.0],
3996            &sql[info.clause_range.1..]
3997        );
3998        assert_eq!(rewritten, "SELECT * FROM __uuid");
3999    }
4000
4001    #[test]
4002    fn test_extract_timestamp_as_of_not_present() {
4003        let sql = "SELECT * FROM t";
4004        assert!(extract_all_timestamp_as_of(sql).is_empty());
4005    }
4006}