This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-rust.git


The following commit(s) were added to refs/heads/main by this push:
     new 149337b9 fix: report unsupported time travel instead of returning 
current data (#753)
149337b9 is described below

commit 149337b913ae4bcf12d5ba7b9875df1f9d96266d
Author: Jiajia Li <[email protected]>
AuthorDate: Fri Sep 11 14:25:36 2026 +0800

    fix: report unsupported time travel instead of returning current data (#753)
---
 .../datafusion/src/relation_planner.rs             |  35 +++-
 .../datafusion/src/system_tables/branches.rs       |   2 +-
 .../datafusion/src/system_tables/consumers.rs      |   2 +-
 .../datafusion/src/system_tables/files.rs          |   2 +-
 .../datafusion/src/system_tables/manifests.rs      |   2 +-
 .../datafusion/src/system_tables/mod.rs            |  20 +++
 .../datafusion/src/system_tables/options.rs        |   2 +-
 .../datafusion/src/system_tables/partitions.rs     |   2 +-
 .../src/system_tables/physical_files_size.rs       |   2 +-
 .../src/system_tables/referenced_files_size.rs     |   2 +-
 .../datafusion/src/system_tables/schemas.rs        |   2 +-
 .../datafusion/src/system_tables/snapshots.rs      |   2 +-
 .../datafusion/src/system_tables/table_indexes.rs  |   2 +-
 .../datafusion/src/system_tables/tags.rs           |   2 +-
 crates/integrations/datafusion/src/table_loader.rs |  31 +++-
 .../datafusion/tests/table_type_routing.rs         | 188 +++++++++++++++++++++
 16 files changed, 274 insertions(+), 24 deletions(-)

diff --git a/crates/integrations/datafusion/src/relation_planner.rs 
b/crates/integrations/datafusion/src/relation_planner.rs
index 60978e3d..d9cf0492 100644
--- a/crates/integrations/datafusion/src/relation_planner.rs
+++ b/crates/integrations/datafusion/src/relation_planner.rs
@@ -33,7 +33,7 @@ use datafusion::sql::sqlparser::ast::{self, TableFactor, 
TableVersion};
 use paimon::spec::{SCAN_TIMESTAMP_MILLIS_OPTION, SCAN_VERSION_OPTION};
 
 use crate::catalog::ReadOnlyTableProvider;
-use crate::table::PaimonTableProvider;
+use crate::table::{ObjectTableProvider, PaimonTableProvider};
 
 /// A [`RelationPlanner`] that intercepts `VERSION AS OF` and `TIMESTAMP AS OF`
 /// clauses on Paimon tables and resolves them to time travel options.
@@ -93,17 +93,36 @@ impl RelationPlanner for PaimonRelationPlanner {
             ));
         }
 
-        let extra_options = match version {
-            TableVersion::VersionAsOf(expr) => resolve_version_as_of(expr)?,
-            TableVersion::TimestampAsOf(expr) => 
resolve_timestamp_as_of(expr)?,
-            _ => return Ok(RelationPlanning::Original(Box::new(relation))),
-        };
-
-        // Check if this is a Paimon table.
         let Some(paimon_provider) = 
provider.downcast_ref::<PaimonTableProvider>() else {
+            // Ours, but with no snapshot to rewrite onto: refuse, because
+            // handing them on reaches the default planner, which drops the
+            // clause. Any other provider is another engine's, clause and all.
+            if provider.is::<ObjectTableProvider>()
+                || 
crate::system_tables::is_system_table_provider(provider.as_ref())
+            {
+                return Err(plan_datafusion_err!(
+                    "time travel is not supported for '{table_ref}'"
+                ));
+            }
             return Ok(RelationPlanning::Original(Box::new(relation)));
         };
 
+        let extra_options = match version {
+            TableVersion::VersionAsOf(expr) => resolve_version_as_of(expr)?,
+            // Same timestamp expression as `TIMESTAMP AS OF`.
+            TableVersion::TimestampAsOf(expr) | 
TableVersion::ForSystemTimeAsOf(expr) => {
+                resolve_timestamp_as_of(expr)?
+            }
+            // `AT(...)` and `CHANGES(...)` name neither a snapshot nor a
+            // timestamp; dropping the clause would answer with current rows.
+            _ => {
+                return Err(plan_datafusion_err!(
+                    "this time-travel syntax is not supported for Paimon 
tables; \
+                     use VERSION AS OF, TIMESTAMP AS OF or FOR SYSTEM_TIME AS 
OF"
+                ))
+            }
+        };
+
         // Resolving time travel may switch the table to the snapshot's schema,
         // which requires async IO; this planner hook is synchronous, so bridge
         // through the shared runtime like other sync DataFusion callbacks.
diff --git a/crates/integrations/datafusion/src/system_tables/branches.rs 
b/crates/integrations/datafusion/src/system_tables/branches.rs
index 01d942c5..c925def7 100644
--- a/crates/integrations/datafusion/src/system_tables/branches.rs
+++ b/crates/integrations/datafusion/src/system_tables/branches.rs
@@ -53,7 +53,7 @@ fn branches_schema() -> SchemaRef {
 }
 
 #[derive(Debug)]
-struct BranchesTable {
+pub(super) struct BranchesTable {
     table: Table,
 }
 
diff --git a/crates/integrations/datafusion/src/system_tables/consumers.rs 
b/crates/integrations/datafusion/src/system_tables/consumers.rs
index b8a33d4d..40c922ce 100644
--- a/crates/integrations/datafusion/src/system_tables/consumers.rs
+++ b/crates/integrations/datafusion/src/system_tables/consumers.rs
@@ -51,7 +51,7 @@ fn consumers_schema() -> SchemaRef {
 }
 
 #[derive(Debug)]
-struct ConsumersTable {
+pub(super) struct ConsumersTable {
     table: Table,
 }
 
diff --git a/crates/integrations/datafusion/src/system_tables/files.rs 
b/crates/integrations/datafusion/src/system_tables/files.rs
index 07264a3d..e9749007 100644
--- a/crates/integrations/datafusion/src/system_tables/files.rs
+++ b/crates/integrations/datafusion/src/system_tables/files.rs
@@ -84,7 +84,7 @@ fn files_schema() -> SchemaRef {
 }
 
 #[derive(Debug)]
-struct FilesTable {
+pub(super) struct FilesTable {
     table: Table,
 }
 
diff --git a/crates/integrations/datafusion/src/system_tables/manifests.rs 
b/crates/integrations/datafusion/src/system_tables/manifests.rs
index 6f8748f7..9380b316 100644
--- a/crates/integrations/datafusion/src/system_tables/manifests.rs
+++ b/crates/integrations/datafusion/src/system_tables/manifests.rs
@@ -61,7 +61,7 @@ fn manifests_schema() -> SchemaRef {
 }
 
 #[derive(Debug)]
-struct ManifestsTable {
+pub(super) struct ManifestsTable {
     table: Table,
 }
 
diff --git a/crates/integrations/datafusion/src/system_tables/mod.rs 
b/crates/integrations/datafusion/src/system_tables/mod.rs
index 3b3a0b09..c81b8403 100644
--- a/crates/integrations/datafusion/src/system_tables/mod.rs
+++ b/crates/integrations/datafusion/src/system_tables/mod.rs
@@ -97,6 +97,26 @@ pub(crate) fn is_registered(name: &str) -> bool {
 }
 
 /// Wraps an already-loaded base table as the system table `name`.
+/// Does `provider` serve one of the [`TABLES`] above?
+///
+/// By type, not by the `base$name` spelling — another engine may name a table
+/// with a `$`. Keep in step with `TABLES`: one missing here goes back to
+/// having its time-travel clause silently dropped.
+pub(crate) fn is_system_table_provider(provider: &dyn TableProvider) -> bool {
+    provider.is::<branches::BranchesTable>()
+        || provider.is::<consumers::ConsumersTable>()
+        || provider.is::<files::FilesTable>()
+        || provider.is::<manifests::ManifestsTable>()
+        || provider.is::<options::OptionsTable>()
+        || provider.is::<partitions::PartitionsTable>()
+        || provider.is::<physical_files_size::PhysicalFilesSizeTable>()
+        || provider.is::<referenced_files_size::ReferencedFilesSizeTable>()
+        || provider.is::<schemas::SchemasTable>()
+        || provider.is::<snapshots::SnapshotsTable>()
+        || provider.is::<table_indexes::TableIndexesTable>()
+        || provider.is::<tags::TagsTable>()
+}
+
 fn wrap_to_system_table(name: &str, base_table: Table) -> 
Option<DFResult<Arc<dyn TableProvider>>> {
     TABLES
         .iter()
diff --git a/crates/integrations/datafusion/src/system_tables/options.rs 
b/crates/integrations/datafusion/src/system_tables/options.rs
index b611ba37..04d85f87 100644
--- a/crates/integrations/datafusion/src/system_tables/options.rs
+++ b/crates/integrations/datafusion/src/system_tables/options.rs
@@ -47,7 +47,7 @@ fn options_schema() -> SchemaRef {
 }
 
 #[derive(Debug)]
-struct OptionsTable {
+pub(super) struct OptionsTable {
     table: Table,
 }
 
diff --git a/crates/integrations/datafusion/src/system_tables/partitions.rs 
b/crates/integrations/datafusion/src/system_tables/partitions.rs
index a0b86c2b..749bb282 100644
--- a/crates/integrations/datafusion/src/system_tables/partitions.rs
+++ b/crates/integrations/datafusion/src/system_tables/partitions.rs
@@ -88,7 +88,7 @@ fn partitions_schema() -> SchemaRef {
         .clone()
 }
 
-struct PartitionsTable {
+pub(super) struct PartitionsTable {
     catalog: Arc<dyn Catalog>,
     identifier: Identifier,
     table: Table,
diff --git 
a/crates/integrations/datafusion/src/system_tables/physical_files_size.rs 
b/crates/integrations/datafusion/src/system_tables/physical_files_size.rs
index a3e242dd..01ef43c1 100644
--- a/crates/integrations/datafusion/src/system_tables/physical_files_size.rs
+++ b/crates/integrations/datafusion/src/system_tables/physical_files_size.rs
@@ -54,7 +54,7 @@ fn output_schema() -> SchemaRef {
 }
 
 #[derive(Debug)]
-struct PhysicalFilesSizeTable {
+pub(super) struct PhysicalFilesSizeTable {
     table: Table,
 }
 
diff --git 
a/crates/integrations/datafusion/src/system_tables/referenced_files_size.rs 
b/crates/integrations/datafusion/src/system_tables/referenced_files_size.rs
index c8f2b40a..568663ca 100644
--- a/crates/integrations/datafusion/src/system_tables/referenced_files_size.rs
+++ b/crates/integrations/datafusion/src/system_tables/referenced_files_size.rs
@@ -55,7 +55,7 @@ fn output_schema() -> SchemaRef {
 }
 
 #[derive(Debug)]
-struct ReferencedFilesSizeTable {
+pub(super) struct ReferencedFilesSizeTable {
     table: Table,
 }
 
diff --git a/crates/integrations/datafusion/src/system_tables/schemas.rs 
b/crates/integrations/datafusion/src/system_tables/schemas.rs
index 800188d5..7575b3b0 100644
--- a/crates/integrations/datafusion/src/system_tables/schemas.rs
+++ b/crates/integrations/datafusion/src/system_tables/schemas.rs
@@ -59,7 +59,7 @@ fn schemas_schema() -> SchemaRef {
 }
 
 #[derive(Debug)]
-struct SchemasTable {
+pub(super) struct SchemasTable {
     table: Table,
 }
 
diff --git a/crates/integrations/datafusion/src/system_tables/snapshots.rs 
b/crates/integrations/datafusion/src/system_tables/snapshots.rs
index da3428f5..040c51c3 100644
--- a/crates/integrations/datafusion/src/system_tables/snapshots.rs
+++ b/crates/integrations/datafusion/src/system_tables/snapshots.rs
@@ -65,7 +65,7 @@ fn snapshots_schema() -> SchemaRef {
 }
 
 #[derive(Debug)]
-struct SnapshotsTable {
+pub(super) struct SnapshotsTable {
     table: Table,
 }
 
diff --git a/crates/integrations/datafusion/src/system_tables/table_indexes.rs 
b/crates/integrations/datafusion/src/system_tables/table_indexes.rs
index 2533b828..cbd1c2c1 100644
--- a/crates/integrations/datafusion/src/system_tables/table_indexes.rs
+++ b/crates/integrations/datafusion/src/system_tables/table_indexes.rs
@@ -83,7 +83,7 @@ fn dv_meta_fields() -> Fields {
 }
 
 #[derive(Debug)]
-struct TableIndexesTable {
+pub(super) struct TableIndexesTable {
     table: Table,
 }
 
diff --git a/crates/integrations/datafusion/src/system_tables/tags.rs 
b/crates/integrations/datafusion/src/system_tables/tags.rs
index 09af4231..433d59f1 100644
--- a/crates/integrations/datafusion/src/system_tables/tags.rs
+++ b/crates/integrations/datafusion/src/system_tables/tags.rs
@@ -62,7 +62,7 @@ fn tags_schema() -> SchemaRef {
 }
 
 #[derive(Debug)]
-struct TagsTable {
+pub(super) struct TagsTable {
     table: Table,
 }
 
diff --git a/crates/integrations/datafusion/src/table_loader.rs 
b/crates/integrations/datafusion/src/table_loader.rs
index c72d0385..664a58c0 100644
--- a/crates/integrations/datafusion/src/table_loader.rs
+++ b/crates/integrations/datafusion/src/table_loader.rs
@@ -66,10 +66,33 @@ pub(crate) async fn load_table_for_read(
         identifier.database().to_string(),
         parsed.table().to_string(),
     );
-    let mut table = catalog
-        .get_table(&base_identifier)
-        .await
-        .map_err(to_datafusion_error)?;
+    let mut table = match catalog.load_table(&base_identifier).await {
+        Ok(paimon::catalog::LoadedTable::Paimon(table)) => *table,
+        Ok(paimon::catalog::LoadedTable::Object(_)) => {
+            // The search UDTFs land here too, so the message names the table
+            // rather than what the caller asked of it.
+            return Err(DataFusionError::Plan(format!(
+                "table '{}' is declared 'object-table' and cannot be read as a 
Paimon table",
+                base_identifier.full_name()
+            )));
+        }
+        Ok(paimon::catalog::LoadedTable::External(external)) => {
+            return Err(DataFusionError::Plan(format!(
+                "table '{}' is declared '{}' and cannot be read as a Paimon 
table",
+                base_identifier.full_name(),
+                external.declared()
+            )));
+        }
+        // `LoadedTable` is non_exhaustive: a variant added upstream is not a
+        // Paimon table until this path says how to read one.
+        Ok(_) => {
+            return Err(DataFusionError::Plan(format!(
+                "table '{}' cannot be read as a Paimon table",
+                base_identifier.full_name()
+            )));
+        }
+        Err(err) => return Err(to_datafusion_error(err)),
+    };
     let system_table = parsed.system_table().map(str::to_string);
     if let Some(branch) = parsed.branch() {
         let is_branches_table = system_table
diff --git a/crates/integrations/datafusion/tests/table_type_routing.rs 
b/crates/integrations/datafusion/tests/table_type_routing.rs
index 5620d6bc..3e979637 100644
--- a/crates/integrations/datafusion/tests/table_type_routing.rs
+++ b/crates/integrations/datafusion/tests/table_type_routing.rs
@@ -647,6 +647,113 @@ async fn object_and_lance_tables_route_to_engines() {
     assert_eq!(column_i32(&batches), vec![1, 3]);
 }
 
+#[tokio::test]
+async fn an_unsupported_time_travel_clause_on_a_paimon_table_is_rejected() {
+    use datafusion::prelude::SessionContext;
+    use paimon_datafusion::PaimonCatalogProvider;
+
+    let paimon_dir = TempDir::new().unwrap();
+    let warehouse = format!("file://{}", paimon_dir.path().display());
+    let mut options = Options::new();
+    options.set(CatalogOptions::WAREHOUSE, warehouse);
+    let fs_catalog = Arc::new(FileSystemCatalog::new(options).unwrap());
+    fs_catalog
+        .create_database(DB, false, HashMap::new())
+        .await
+        .unwrap();
+    let plain = PaimonSchema::builder()
+        .column(
+            "id",
+            paimon::spec::DataType::Int(paimon::spec::IntType::new()),
+        )
+        .build()
+        .unwrap();
+    fs_catalog
+        .create_table(&Identifier::new(DB, "pt"), plain, false)
+        .await
+        .unwrap();
+
+    let ctx = SessionContext::new();
+    ctx.register_catalog(
+        CATALOG,
+        Arc::new(PaimonCatalogProvider::new(
+            Some(CATALOG.to_string()),
+            fs_catalog,
+            Default::default(),
+            Default::default(),
+            None,
+        )),
+    );
+    paimon_datafusion::register_catalog_table_engine(
+        &ctx,
+        CATALOG,
+        TableType::IcebergTable,
+        Arc::new(FakeEngineResolver),
+    )
+    .unwrap();
+    for dialect in ["databricks", "mssql", "bigquery", "snowflake"] {
+        ctx.sql(&format!("SET datafusion.sql_parser.dialect = '{dialect}'"))
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap();
+
+        let run = |sql: String| {
+            let ctx = &ctx;
+            async move {
+                match ctx.sql(&sql).await {
+                    Err(err) => Err(err.to_string()),
+                    Ok(df) => df.collect().await.map(|_| ()).map_err(|e| 
e.to_string()),
+                }
+            }
+        };
+        let ts = "2020-01-01 00:00:00";
+        let system_time = run(format!(
+            "SELECT * FROM {CATALOG}.{DB}.pt FOR SYSTEM_TIME AS OF '{ts}'"
+        ))
+        .await;
+        let timestamp = run(format!(
+            "SELECT * FROM {CATALOG}.{DB}.pt TIMESTAMP AS OF '{ts}'"
+        ))
+        .await;
+        assert_eq!(
+            system_time, timestamp,
+            "[{dialect}] the two spellings diverged"
+        );
+
+        let Err(msg) = run(format!("SELECT * FROM {CATALOG}.{DB}.pt 
AT('{ts}')")).await else {
+            panic!("[{dialect}] a historical clause must not answer with 
current rows");
+        };
+        assert!(
+            msg.contains("this time-travel syntax is not supported"),
+            "[{dialect}] {msg}"
+        );
+
+        let err = ctx
+            .sql(&format!("SELECT * FROM {CATALOG}.{DB}.pt VERSION AS OF 1"))
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .err()
+            .unwrap_or_else(|| panic!("[{dialect}] snapshot 1 does not exist 
in this fixture"));
+        assert!(err.to_string().contains("Snapshot 1"), "[{dialect}] {err}");
+
+        let Err(msg) = run(format!(
+            "SELECT * FROM {CATALOG}.{DB}.\"pt$schemas\" VERSION AS OF 1"
+        ))
+        .await
+        else {
+            panic!("[{dialect}] a system table answered a historical clause 
with current rows");
+        };
+        assert!(
+            msg.contains("time travel is not supported for"),
+            "[{dialect}] {msg}"
+        );
+    }
+}
+
 #[tokio::test]
 async fn time_travel_on_routed_tables_is_rejected() {
     let env = setup().await;
@@ -1252,3 +1359,84 @@ async fn 
a_rejected_external_table_does_not_pollute_the_blob_registry() {
         "a rejected table must leave no registration behind"
     );
 }
+
+#[derive(Debug)]
+struct ForeignRelationPlanner;
+
+impl datafusion::logical_expr::planner::RelationPlanner for 
ForeignRelationPlanner {
+    fn plan_relation(
+        &self,
+        relation: datafusion::sql::sqlparser::ast::TableFactor,
+        _context: &mut dyn 
datafusion::logical_expr::planner::RelationPlannerContext,
+    ) -> DFResult<datafusion::logical_expr::planner::RelationPlanning> {
+        use datafusion::sql::sqlparser::ast::TableFactor;
+        if matches!(
+            relation,
+            TableFactor::Table {
+                version: Some(_),
+                ..
+            }
+        ) {
+            return Err(DataFusionError::Plan("foreign planner 
reached".into()));
+        }
+        
Ok(datafusion::logical_expr::planner::RelationPlanning::Original(Box::new(relation)))
+    }
+}
+
+#[tokio::test]
+async fn a_foreign_provider_keeps_its_own_version_clause() {
+    use datafusion::prelude::SessionContext;
+
+    let ctx = SessionContext::new();
+    for name in ["foreign_table", "foreign$history", "foreign$schemas"] {
+        let schema = Arc::new(ArrowSchema::new(vec![Field::new(
+            "id",
+            DataType::Int32,
+            false,
+        )]));
+        let batch = RecordBatch::try_new(
+            Arc::clone(&schema),
+            vec![Arc::new(Int32Array::from(vec![1, 2])) as _],
+        )
+        .unwrap();
+        ctx.register_table(
+            datafusion::common::TableReference::bare(name),
+            Arc::new(MemTable::try_new(schema, vec![vec![batch]]).unwrap()),
+        )
+        .unwrap();
+    }
+    ctx.register_relation_planner(Arc::new(ForeignRelationPlanner))
+        .unwrap();
+    
ctx.register_relation_planner(Arc::new(paimon_datafusion::PaimonRelationPlanner::new()))
+        .unwrap();
+    ctx.sql("SET datafusion.sql_parser.dialect = 'databricks'")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+
+    for table in [
+        "foreign_table",
+        "\"foreign$history\"",
+        "\"foreign$schemas\"",
+    ] {
+        for clause in [
+            "FOR SYSTEM_TIME AS OF CURRENT_TIMESTAMP() - INTERVAL '1' DAY",
+            "TIMESTAMP AS OF '2020-01-01T00:00:00Z'",
+            "VERSION AS OF 1",
+        ] {
+            let err = ctx
+                .sql(&format!("SELECT * FROM {table} {clause}"))
+                .await
+                .err()
+                .unwrap_or_else(|| {
+                    panic!("[{table} {clause}] expected the foreign planner to 
claim it")
+                });
+            assert!(
+                err.to_string().contains("foreign planner reached"),
+                "[{table} {clause}] Paimon took a clause that is not its own: 
{err}"
+            );
+        }
+    }
+}

Reply via email to