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 1812e0de feat: route tables by declared TableType to pluggable engines 
(#733)
1812e0de is described below

commit 1812e0de03238e61e24b1f106d8ce78fdea31411
Author: Jiajia Li <[email protected]>
AuthorDate: Sat Aug 22 10:43:49 2026 +0800

    feat: route tables by declared TableType to pluggable engines (#733)
---
 crates/integrations/datafusion/src/catalog.rs      | 322 ++++++++-
 crates/integrations/datafusion/src/lib.rs          |   5 +-
 .../datafusion/src/relation_planner.rs             |  24 +-
 crates/integrations/datafusion/src/sql_context.rs  |  18 +
 .../datafusion/tests/table_type_routing.rs         | 780 +++++++++++++++++++++
 crates/paimon-rest-server/src/lib.rs               |  11 +-
 crates/paimon-rest-server/tests/e2e.rs             |  96 +++
 crates/paimon/src/catalog/filesystem.rs            | 359 +++++++++-
 crates/paimon/src/catalog/mod.rs                   |  64 +-
 crates/paimon/src/catalog/rest/rest_catalog.rs     |  25 +
 crates/paimon/src/spec/core_options.rs             |  45 +-
 crates/paimon/src/spec/mod.rs                      |   3 +
 crates/paimon/src/spec/schema.rs                   |   2 +
 crates/paimon/src/spec/table_type.rs               | 143 ++++
 crates/paimon/src/table/rest_env.rs                |  46 +-
 crates/paimon/tests/rest_catalog_test.rs           | 137 ++++
 16 files changed, 2015 insertions(+), 65 deletions(-)

diff --git a/crates/integrations/datafusion/src/catalog.rs 
b/crates/integrations/datafusion/src/catalog.rs
index 83ef810f..cbee1a6e 100644
--- a/crates/integrations/datafusion/src/catalog.rs
+++ b/crates/integrations/datafusion/src/catalog.rs
@@ -29,11 +29,13 @@ use datafusion::datasource::{TableProvider, TableType};
 use datafusion::error::Result as DFResult;
 use datafusion::execution::SessionState;
 use datafusion::logical_expr::{expr_fn::cast, Expr, LogicalPlan, 
LogicalPlanBuilder};
+use datafusion::prelude::SessionContext;
 use datafusion::sql::planner::IdentNormalizer;
 use datafusion::sql::sqlparser::ast::{Ident, ObjectName, Query, Statement, 
Visit, Visitor};
 use datafusion::sql::sqlparser::dialect::GenericDialect;
 use datafusion::sql::sqlparser::parser::Parser;
 use paimon::catalog::{Catalog, Identifier, View};
+use paimon::spec::TableType as PaimonTableType;
 
 use crate::error::to_datafusion_error;
 use crate::runtime::{await_with_runtime, block_on_with_runtime};
@@ -43,11 +45,154 @@ use crate::{BlobReaderRegistry, DynamicOptions};
 
 pub(crate) type SessionStateProvider = Arc<dyn Fn() -> Option<SessionState> + 
Send + Sync>;
 
+/// Engine registry shared between the catalog provider and its schema
+/// providers, so registrations stay visible to schemas obtained earlier.
+type TableEngines = Arc<RwLock<HashMap<PaimonTableType, Arc<dyn 
TableEngineResolver>>>>;
+
+/// What an engine is asked to resolve. Non-exhaustive so later releases can
+/// carry more of the request — a snapshot selector, say — without breaking
+/// existing resolvers.
+#[derive(Debug, Clone)]
+#[non_exhaustive]
+pub struct EngineTableRequest {
+    pub database: String,
+    pub table: String,
+    /// The type the table's metadata declares.
+    pub declared: PaimonTableType,
+}
+
+impl EngineTableRequest {
+    pub fn new(database: String, table: String, declared: PaimonTableType) -> 
Self {
+        Self {
+            database,
+            table,
+            declared,
+        }
+    }
+}
+
+/// Resolves tables owned by another engine (see
+/// [`PaimonCatalogProvider::register_table_engine`]). `Ok(None)` means not
+/// found; errors propagate, so an engine failure never looks like a missing
+/// table.
+#[async_trait]
+pub trait TableEngineResolver: Debug + Send + Sync {
+    /// Resolve a request to the engine's table provider.
+    async fn resolve_table(
+        &self,
+        request: &EngineTableRequest,
+    ) -> DFResult<Option<Arc<dyn TableProvider>>>;
+}
+
+/// Read-only wrapper around an engine-resolved provider: reads delegate,
+/// DML is rejected even when the engine's own provider is writable.
+#[derive(Debug)]
+pub(crate) struct ReadOnlyTableProvider {
+    inner: Arc<dyn TableProvider>,
+    pub(crate) declared: PaimonTableType,
+    pub(crate) table_name: String,
+}
+
+#[async_trait]
+impl TableProvider for ReadOnlyTableProvider {
+    fn schema(&self) -> datafusion::arrow::datatypes::SchemaRef {
+        self.inner.schema()
+    }
+
+    fn constraints(&self) -> Option<&datafusion::common::Constraints> {
+        self.inner.constraints()
+    }
+
+    fn table_type(&self) -> TableType {
+        self.inner.table_type()
+    }
+
+    fn get_table_definition(&self) -> Option<&str> {
+        self.inner.get_table_definition()
+    }
+
+    fn get_logical_plan(&self) -> Option<std::borrow::Cow<'_, LogicalPlan>> {
+        self.inner.get_logical_plan()
+    }
+
+    fn get_column_default(&self, column: &str) -> Option<&Expr> {
+        self.inner.get_column_default(column)
+    }
+
+    async fn scan(
+        &self,
+        state: &dyn datafusion::catalog::Session,
+        projection: Option<&Vec<usize>>,
+        filters: &[Expr],
+        limit: Option<usize>,
+    ) -> DFResult<Arc<dyn datafusion::physical_plan::ExecutionPlan>> {
+        self.inner.scan(state, projection, filters, limit).await
+    }
+
+    fn supports_filters_pushdown(
+        &self,
+        filters: &[&Expr],
+    ) -> DFResult<Vec<datafusion::logical_expr::TableProviderFilterPushDown>> {
+        self.inner.supports_filters_pushdown(filters)
+    }
+
+    fn statistics(&self) -> Option<datafusion::common::Statistics> {
+        self.inner.statistics()
+    }
+
+    async fn insert_into(
+        &self,
+        _state: &dyn datafusion::catalog::Session,
+        _input: Arc<dyn datafusion::physical_plan::ExecutionPlan>,
+        _insert_op: datafusion::logical_expr::dml::InsertOp,
+    ) -> DFResult<Arc<dyn datafusion::physical_plan::ExecutionPlan>> {
+        Err(plan_datafusion_err!(
+            "write is not supported for routed '{}' tables ('{}')",
+            self.declared,
+            self.table_name
+        ))
+    }
+}
+
+/// Register `resolver` as the engine for `table_type` on the Paimon catalog
+/// named `catalog_name`.
+///
+/// Also installs [`PaimonRelationPlanner`](crate::PaimonRelationPlanner), so
+/// version clauses cannot slip past this crate (see 
[`PaimonCatalogProvider`]).
+/// 
[`SQLContext::register_catalog_table_engine`](crate::SQLContext::register_catalog_table_engine)
+/// is equivalent.
+pub fn register_catalog_table_engine(
+    ctx: &SessionContext,
+    catalog_name: &str,
+    table_type: PaimonTableType,
+    resolver: Arc<dyn TableEngineResolver>,
+) -> DFResult<()> {
+    
ctx.register_relation_planner(Arc::new(crate::PaimonRelationPlanner::new()))?;
+    let provider = ctx
+        .catalog(catalog_name)
+        .ok_or_else(|| plan_datafusion_err!("Unknown catalog 
'{catalog_name}'"))?;
+    provider
+        .downcast_ref::<PaimonCatalogProvider>()
+        .ok_or_else(|| plan_datafusion_err!("Catalog '{catalog_name}' is not a 
Paimon catalog"))?
+        .register_table_engine(table_type, resolver)
+}
+
 /// Provides an interface to manage and access multiple schemas (databases)
 /// within a Paimon [`Catalog`].
 ///
 /// This provider uses lazy loading - databases and tables are fetched
 /// on-demand from the catalog, ensuring data is always fresh.
+///
+/// # Table-version clauses
+///
+/// SQL queries using `VERSION`/`TIMESTAMP AS OF` need
+/// [`PaimonRelationPlanner`](crate::PaimonRelationPlanner) installed on the
+/// session; DataFusion's default planner drops the clause before this crate
+/// sees it, so the query would read current data. 
[`SQLContext`](crate::SQLContext)
+/// installs it, as does
+/// [`register_catalog_table_engine`]. A plain `SessionContext` querying Paimon
+/// tables directly must install it with
+/// `ctx.register_relation_planner(Arc::new(PaimonRelationPlanner::new()))`.
 pub struct PaimonCatalogProvider {
     catalog_name: Option<String>,
     /// Reference to the Paimon catalog.
@@ -65,6 +210,9 @@ pub struct PaimonCatalogProvider {
     blob_reader_registry: BlobReaderRegistry,
     session_state: Option<SessionStateProvider>,
     schema_force_view_types: bool,
+    /// Engines for table types served elsewhere, keyed by declared
+    /// [`PaimonTableType`]. Same poison-recovery stance as `temp_tables`.
+    table_engines: TableEngines,
 }
 
 impl Debug for PaimonCatalogProvider {
@@ -90,6 +238,7 @@ impl PaimonCatalogProvider {
             blob_reader_registry,
             session_state,
             schema_force_view_types: true,
+            table_engines: Arc::new(RwLock::new(HashMap::new())),
         }
     }
 
@@ -102,24 +251,39 @@ impl PaimonCatalogProvider {
         self.schema_force_view_types = schema_force_view_types;
         self
     }
-}
 
-impl CatalogProvider for PaimonCatalogProvider {
-    fn schema_names(&self) -> Vec<String> {
-        let catalog = Arc::clone(&self.catalog);
-        block_on_with_runtime(
-            async move {
-                catalog.list_databases().await.unwrap_or_else(|e| {
-                    log::error!("failed to list databases: {e}");
-                    vec![]
-                })
-            },
-            "paimon catalog access thread panicked",
-        )
+    /// Register an engine for a table type the Paimon reader cannot serve
+    /// (e.g. [`PaimonTableType::IcebergTable`]); everything else takes the
+    /// Paimon path unchanged. Kept inside the provider so the registered
+    /// catalog type never changes and downcast-based paths (temp tables,
+    /// time travel) keep working.
+    pub(crate) fn register_table_engine(
+        &self,
+        table_type: PaimonTableType,
+        resolver: Arc<dyn TableEngineResolver>,
+    ) -> DFResult<()> {
+        // Routing a Paimon-served type would split it between engines:
+        // reads via the resolver, raw get_table paths via Paimon.
+        if !table_type.requires_table_engine() {
+            return Err(plan_datafusion_err!(
+                "table type '{table_type}' is served by the Paimon reader and 
cannot be \
+                 routed to a table engine"
+            ));
+        }
+        self.table_engines
+            .write()
+            .unwrap_or_else(|e| e.into_inner())
+            .insert(table_type, resolver);
+        Ok(())
     }
 
-    fn schema(&self, name: &str) -> Option<Arc<dyn SchemaProvider>> {
+    fn table_engines(&self) -> TableEngines {
+        Arc::clone(&self.table_engines)
+    }
+
+    fn paimon_schema(&self, name: &str) -> Option<Arc<dyn SchemaProvider>> {
         let catalog = Arc::clone(&self.catalog);
+        let table_engines = self.table_engines();
         let dynamic_options = Arc::clone(&self.dynamic_options);
         let blob_reader_registry = self.blob_reader_registry.clone();
         let catalog_name = self.catalog_name.clone();
@@ -145,7 +309,8 @@ impl CatalogProvider for PaimonCatalogProvider {
                             blob_reader_registry,
                             session_state,
                         )
-                        .with_schema_force_view_types(schema_force_view_types),
+                        .with_schema_force_view_types(schema_force_view_types)
+                        .with_table_engines(Arc::clone(&table_engines)),
                     ) as Arc<dyn SchemaProvider>),
                     Err(paimon::Error::DatabaseNotExist { .. }) => {
                         if temp_provider.is_some() {
@@ -159,7 +324,8 @@ impl CatalogProvider for PaimonCatalogProvider {
                                     blob_reader_registry,
                                     session_state,
                                 )
-                                
.with_schema_force_view_types(schema_force_view_types),
+                                
.with_schema_force_view_types(schema_force_view_types)
+                                
.with_table_engines(Arc::clone(&table_engines)),
                             ) as Arc<dyn SchemaProvider>)
                         } else {
                             None
@@ -174,6 +340,25 @@ impl CatalogProvider for PaimonCatalogProvider {
             "paimon catalog access thread panicked",
         )
     }
+}
+
+impl CatalogProvider for PaimonCatalogProvider {
+    fn schema_names(&self) -> Vec<String> {
+        let catalog = Arc::clone(&self.catalog);
+        block_on_with_runtime(
+            async move {
+                catalog.list_databases().await.unwrap_or_else(|e| {
+                    log::error!("failed to list databases: {e}");
+                    vec![]
+                })
+            },
+            "paimon catalog access thread panicked",
+        )
+    }
+
+    fn schema(&self, name: &str) -> Option<Arc<dyn SchemaProvider>> {
+        self.paimon_schema(name)
+    }
 
     fn register_schema(
         &self,
@@ -343,6 +528,8 @@ pub struct PaimonSchemaProvider {
     blob_reader_registry: BlobReaderRegistry,
     session_state: Option<SessionStateProvider>,
     schema_force_view_types: bool,
+    /// Engines for table types served elsewhere; empty without routing.
+    table_engines: TableEngines,
 }
 
 impl Debug for PaimonSchemaProvider {
@@ -375,6 +562,7 @@ impl PaimonSchemaProvider {
             blob_reader_registry,
             session_state,
             schema_force_view_types: true,
+            table_engines: Arc::new(RwLock::new(HashMap::new())),
         }
     }
 
@@ -382,6 +570,11 @@ impl PaimonSchemaProvider {
         self.schema_force_view_types = schema_force_view_types;
         self
     }
+
+    pub(crate) fn with_table_engines(mut self, table_engines: TableEngines) -> 
Self {
+        self.table_engines = table_engines;
+        self
+    }
 }
 
 #[async_trait]
@@ -465,9 +658,55 @@ impl SchemaProvider for PaimonSchemaProvider {
         let schema_force_view_types = self.schema_force_view_types;
         let identifier = Identifier::new(self.database.clone(), 
object.table().to_string());
         let branch = object.branch().map(str::to_string);
+        let table_engines: HashMap<PaimonTableType, Arc<dyn 
TableEngineResolver>> = self
+            .table_engines
+            .read()
+            .unwrap_or_else(|e| e.into_inner())
+            .clone();
         await_with_runtime(async move {
-            match catalog.get_table(&identifier).await {
-                Ok(mut table) => {
+            match catalog.load_table(&identifier).await {
+                Ok(paimon::catalog::LoadedTable::External(external)) => {
+                    let declared = external.declared();
+                    if branch.is_some() {
+                        return Err(plan_datafusion_err!(
+                            "branches are not supported for '{}' tables 
('{}')",
+                            declared,
+                            identifier.full_name()
+                        ));
+                    }
+                    // The Paimon arm below applies these; an engine would
+                    // ignore them and answer from current data.
+                    let session_options = dynamic_options
+                        .read()
+                        .unwrap_or_else(|e| e.into_inner())
+                        .clone();
+                    paimon::spec::CoreOptions::new(&session_options)
+                        .ensure_engine_can_serve(&identifier.full_name())
+                        .map_err(to_datafusion_error)?;
+                    let resolver = table_engines.get(&declared).ok_or_else(|| {
+                        plan_datafusion_err!(
+                            "no table engine is registered for '{}' tables 
('{}')",
+                            declared,
+                            identifier.full_name()
+                        )
+                    })?;
+                    let resolved = resolver
+                        .resolve_table(&EngineTableRequest::new(
+                            identifier.database().to_string(),
+                            identifier.object().to_string(),
+                            declared,
+                        ))
+                        .await?;
+                    Ok(resolved.map(|inner| {
+                        Arc::new(ReadOnlyTableProvider {
+                            inner,
+                            declared,
+                            table_name: identifier.full_name(),
+                        }) as Arc<dyn TableProvider>
+                    }))
+                }
+                Ok(paimon::catalog::LoadedTable::Paimon(table)) => {
+                    let mut table = *table;
                     if let Some(branch) = branch.as_deref() {
                         table = table
                             .copy_with_branch(branch)
@@ -524,7 +763,8 @@ impl SchemaProvider for PaimonSchemaProvider {
                             identifier.full_name()
                         )
                     })?;
-                    validate_view_dependencies(&catalog, &catalog_name, 
&view).await?;
+                    validate_view_dependencies(&catalog, &catalog_name, &view)
+                        .await?;
                     let mut state = session_state
                         .and_then(|provider| provider())
                         .ok_or_else(|| {
@@ -606,15 +846,50 @@ impl SchemaProvider for PaimonSchemaProvider {
         let is_branches_table = object
             .system_table()
             .is_some_and(|name| name.eq_ignore_ascii_case("branches"));
+        let has_system_suffix = object.system_table().is_some();
+        let engines: HashMap<PaimonTableType, Arc<dyn TableEngineResolver>> = 
self
+            .table_engines
+            .read()
+            .unwrap_or_else(|e| e.into_inner())
+            .clone();
         block_on_with_runtime(
             async move {
-                match catalog.get_table(&identifier).await {
-                    Ok(table) => {
+                match catalog.load_table(&identifier).await {
+                    Ok(paimon::catalog::LoadedTable::External(external)) => {
+                        let declared = external.declared();
+                        // Paimon-only; `table()` rejects them here too.
+                        if branch.is_some() || has_system_suffix {
+                            return false;
+                        }
+                        match engines.get(&declared) {
+                            Some(resolver) => match resolver
+                                .resolve_table(&EngineTableRequest::new(
+                                    identifier.database().to_string(),
+                                    identifier.object().to_string(),
+                                    declared,
+                                ))
+                                .await
+                            {
+                                Ok(table) => table.is_some(),
+                                // Report failures as existing so `table()`
+                                // surfaces the real error.
+                                Err(err) => {
+                                    log::warn!(
+                                        "failed to probe engine table 
existence for '{}': {err}",
+                                        identifier.full_name()
+                                    );
+                                    true
+                                }
+                            },
+                            None => false,
+                        }
+                    }
+                    Ok(paimon::catalog::LoadedTable::Paimon(table)) => {
                         if let Some(branch) = branch.as_deref() {
                             if is_branches_table {
                                 return true;
                             }
-                            table.copy_with_branch(branch).await.is_ok()
+                            (*table).copy_with_branch(branch).await.is_ok()
                         } else {
                             true
                         }
@@ -721,7 +996,8 @@ async fn validate_view_dependencies(
         let candidates = view_relation_identifiers(&view, catalog_name)?;
         let mut view_dependencies = Vec::new();
         for identifier in candidates {
-            match catalog.get_table(&identifier).await {
+            // Routed engine tables count as existing dependencies.
+            match catalog.load_table(&identifier).await {
                 Ok(_) => continue,
                 Err(paimon::Error::TableNotExist { .. })
                 | Err(paimon::Error::Unsupported { .. }) => {}
diff --git a/crates/integrations/datafusion/src/lib.rs 
b/crates/integrations/datafusion/src/lib.rs
index d9044fbc..985ad31f 100644
--- a/crates/integrations/datafusion/src/lib.rs
+++ b/crates/integrations/datafusion/src/lib.rs
@@ -74,7 +74,10 @@ pub(crate) type DynamicOptions = Arc<RwLock<HashMap<String, 
String>>>;
 
 pub use blob_reader::BlobReaderRegistry;
 pub use blob_view::register_blob_view;
-pub use catalog::{PaimonCatalogProvider, PaimonSchemaProvider};
+pub use catalog::{
+    register_catalog_table_engine, EngineTableRequest, PaimonCatalogProvider, 
PaimonSchemaProvider,
+    TableEngineResolver,
+};
 pub use error::to_datafusion_error;
 #[cfg(feature = "fulltext")]
 pub use full_text_search::{register_full_text_search, FullTextSearchFunction};
diff --git a/crates/integrations/datafusion/src/relation_planner.rs 
b/crates/integrations/datafusion/src/relation_planner.rs
index e51bdcd1..60978e3d 100644
--- a/crates/integrations/datafusion/src/relation_planner.rs
+++ b/crates/integrations/datafusion/src/relation_planner.rs
@@ -22,6 +22,7 @@ use std::fmt::Debug;
 use std::sync::Arc;
 
 use datafusion::catalog::default_table_source::{provider_as_source, 
source_as_provider};
+use datafusion::common::plan_datafusion_err;
 use datafusion::common::TableReference;
 use datafusion::error::Result as DFResult;
 use datafusion::logical_expr::builder::LogicalPlanBuilder;
@@ -31,6 +32,7 @@ use datafusion::logical_expr::planner::{
 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;
 
 /// A [`RelationPlanner`] that intercepts `VERSION AS OF` and `TIMESTAMP AS OF`
@@ -70,10 +72,8 @@ impl RelationPlanner for PaimonRelationPlanner {
             return Ok(RelationPlanning::Original(Box::new(relation)));
         };
 
-        let extra_options = match version {
-            Some(TableVersion::VersionAsOf(expr)) => 
resolve_version_as_of(expr)?,
-            Some(TableVersion::TimestampAsOf(expr)) => 
resolve_timestamp_as_of(expr)?,
-            _ => return Ok(RelationPlanning::Original(Box::new(relation))),
+        let Some(version) = version else {
+            return Ok(RelationPlanning::Original(Box::new(relation)));
         };
 
         // Resolve the table reference.
@@ -83,6 +83,22 @@ impl RelationPlanner for PaimonRelationPlanner {
             .get_table_source(table_ref.clone())?;
         let provider = source_as_provider(&source)?;
 
+        // Every version clause, not just the two resolved below: falling
+        // through drops the clause silently.
+        if let Some(routed) = provider.downcast_ref::<ReadOnlyTableProvider>() 
{
+            return Err(plan_datafusion_err!(
+                "time travel is not supported for routed '{}' tables ('{}')",
+                routed.declared,
+                routed.table_name
+            ));
+        }
+
+        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 {
             return Ok(RelationPlanning::Original(Box::new(relation)));
diff --git a/crates/integrations/datafusion/src/sql_context.rs 
b/crates/integrations/datafusion/src/sql_context.rs
index ae02f0a9..cf306f2c 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -348,6 +348,24 @@ impl SQLContext {
         paimon_provider.register_temp_table(&database, &table_name, table)
     }
 
+    /// Whether `name` is a Paimon catalog, rather than one DataFusion holds.
+    pub fn is_paimon_catalog(&self, name: &str) -> bool {
+        self.catalogs.contains_key(name)
+    }
+
+    /// Register an engine for a table type served by another engine (e.g.
+    /// [`paimon::spec::TableType::IcebergTable`]) on a registered Paimon
+    /// catalog.
+    /// See [`crate::catalog::PaimonCatalogProvider::register_table_engine`].
+    pub fn register_catalog_table_engine(
+        &self,
+        catalog_name: &str,
+        table_type: paimon::spec::TableType,
+        resolver: Arc<dyn crate::catalog::TableEngineResolver>,
+    ) -> DFResult<()> {
+        crate::catalog::register_catalog_table_engine(&self.ctx, catalog_name, 
table_type, resolver)
+    }
+
     /// Deregisters a temporary table or view.
     ///
     /// Accepts the same flexible name format as `register_temp_table`.
diff --git a/crates/integrations/datafusion/tests/table_type_routing.rs 
b/crates/integrations/datafusion/tests/table_type_routing.rs
new file mode 100644
index 00000000..f75ae8f3
--- /dev/null
+++ b/crates/integrations/datafusion/tests/table_type_routing.rs
@@ -0,0 +1,780 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::collections::HashMap;
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use datafusion::arrow::array::{Array, Int32Array, StringArray};
+use datafusion::arrow::datatypes::{DataType, Field, Schema as ArrowSchema};
+use datafusion::arrow::record_batch::RecordBatch;
+use datafusion::datasource::{MemTable, TableProvider};
+use datafusion::error::{DataFusionError, Result as DFResult};
+use paimon::catalog::{Catalog, Database, Identifier, LoadedTable};
+use paimon::spec::{Schema as PaimonSchema, SchemaChange, TableType};
+use paimon::table::Table;
+use paimon::{CatalogOptions, FileSystemCatalog, Options, Result as 
PaimonResult};
+use paimon_datafusion::{EngineTableRequest, SQLContext, TableEngineResolver};
+use tempfile::TempDir;
+
+const CATALOG: &str = "cat";
+const DB: &str = "shared_db";
+
+#[derive(Debug)]
+struct TypedTestCatalog {
+    inner: Arc<FileSystemCatalog>,
+    declared_types: HashMap<String, TableType>,
+}
+
+#[async_trait]
+impl Catalog for TypedTestCatalog {
+    async fn list_databases(&self) -> PaimonResult<Vec<String>> {
+        self.inner.list_databases().await
+    }
+
+    async fn create_database(
+        &self,
+        name: &str,
+        ignore_if_exists: bool,
+        properties: HashMap<String, String>,
+    ) -> PaimonResult<()> {
+        self.inner
+            .create_database(name, ignore_if_exists, properties)
+            .await
+    }
+
+    async fn get_database(&self, name: &str) -> PaimonResult<Database> {
+        self.inner.get_database(name).await
+    }
+
+    async fn drop_database(
+        &self,
+        name: &str,
+        ignore_if_not_exists: bool,
+        cascade: bool,
+    ) -> PaimonResult<()> {
+        self.inner
+            .drop_database(name, ignore_if_not_exists, cascade)
+            .await
+    }
+
+    async fn get_table(&self, identifier: &Identifier) -> PaimonResult<Table> {
+        if let Some(declared) = self.declared_types.get(identifier.object()) {
+            return Err(paimon::Error::Unsupported {
+                message: format!(
+                    "table '{}' is declared '{declared}' and cannot be read as 
a Paimon table",
+                    identifier.full_name()
+                ),
+            });
+        }
+        self.inner.get_table(identifier).await
+    }
+
+    async fn load_table(&self, identifier: &Identifier) -> 
PaimonResult<LoadedTable> {
+        if let Some(declared) = self.declared_types.get(identifier.object()) {
+            if declared.requires_table_engine() {
+                let options = HashMap::new();
+                return LoadedTable::external(
+                    *declared,
+                    &paimon::spec::CoreOptions::new(&options),
+                    &identifier.full_name(),
+                );
+            }
+        }
+        Ok(LoadedTable::Paimon(Box::new(
+            self.get_table(identifier).await?,
+        )))
+    }
+
+    async fn list_tables(&self, database_name: &str) -> 
PaimonResult<Vec<String>> {
+        let mut names = self.inner.list_tables(database_name).await?;
+        names.extend(self.declared_types.keys().cloned());
+        Ok(names)
+    }
+
+    async fn create_table(
+        &self,
+        identifier: &Identifier,
+        creation: PaimonSchema,
+        ignore_if_exists: bool,
+    ) -> PaimonResult<()> {
+        self.inner
+            .create_table(identifier, creation, ignore_if_exists)
+            .await
+    }
+
+    async fn drop_table(
+        &self,
+        identifier: &Identifier,
+        ignore_if_not_exists: bool,
+    ) -> PaimonResult<()> {
+        self.inner
+            .drop_table(identifier, ignore_if_not_exists)
+            .await
+    }
+
+    async fn rename_table(
+        &self,
+        from: &Identifier,
+        to: &Identifier,
+        ignore_if_not_exists: bool,
+    ) -> PaimonResult<()> {
+        self.inner
+            .rename_table(from, to, ignore_if_not_exists)
+            .await
+    }
+
+    async fn alter_table(
+        &self,
+        identifier: &Identifier,
+        changes: Vec<SchemaChange>,
+        ignore_if_not_exists: bool,
+    ) -> PaimonResult<()> {
+        self.inner
+            .alter_table(identifier, changes, ignore_if_not_exists)
+            .await
+    }
+}
+
+#[derive(Debug)]
+struct FakeEngineResolver;
+
+#[async_trait]
+impl TableEngineResolver for FakeEngineResolver {
+    async fn resolve_table(
+        &self,
+        request: &EngineTableRequest,
+    ) -> DFResult<Option<Arc<dyn TableProvider>>> {
+        if request.table != "it" {
+            return Ok(None);
+        }
+        let schema = Arc::new(ArrowSchema::new(vec![
+            Field::new("id", DataType::Int32, false),
+            Field::new("payload", DataType::Utf8, false),
+        ]));
+        let batch = RecordBatch::try_new(
+            Arc::clone(&schema),
+            vec![
+                Arc::new(Int32Array::from(vec![1, 3])),
+                Arc::new(StringArray::from(vec!["x", "y"])),
+            ],
+        )
+        .map_err(DataFusionError::from)?;
+        let table = MemTable::try_new(schema, vec![vec![batch]])?;
+        Ok(Some(Arc::new(table)))
+    }
+}
+
+struct TestEnv {
+    _paimon_dir: TempDir,
+    ctx: SQLContext,
+}
+
+async fn setup() -> TestEnv {
+    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());
+    let typed_catalog = Arc::new(TypedTestCatalog {
+        inner: fs_catalog,
+        declared_types: HashMap::from([
+            ("it".to_string(), TableType::IcebergTable),
+            ("ghost".to_string(), TableType::IcebergTable),
+            ("ft".to_string(), TableType::IcebergTable),
+        ]),
+    });
+    let mut ctx = SQLContext::new();
+    ctx.register_catalog(CATALOG, typed_catalog).await.unwrap();
+    ctx.sql(&format!("CREATE SCHEMA {CATALOG}.{DB}"))
+        .await
+        .unwrap();
+    ctx.sql(&format!(
+        "CREATE TABLE {CATALOG}.{DB}.pt (id INT NOT NULL, name STRING)"
+    ))
+    .await
+    .unwrap();
+    for stmt in [
+        format!("INSERT INTO {CATALOG}.{DB}.pt VALUES (1, 'a')"),
+        format!("INSERT INTO {CATALOG}.{DB}.pt VALUES (2, 'b')"),
+    ] {
+        ctx.sql(&stmt).await.unwrap().collect().await.unwrap();
+    }
+
+    ctx.register_catalog_table_engine(
+        CATALOG,
+        TableType::IcebergTable,
+        Arc::new(FakeEngineResolver),
+    )
+    .unwrap();
+
+    TestEnv {
+        _paimon_dir: paimon_dir,
+        ctx,
+    }
+}
+
+fn column_i32(batches: &[RecordBatch]) -> Vec<i32> {
+    batches
+        .iter()
+        .flat_map(|b| {
+            let col = 
b.column(0).as_any().downcast_ref::<Int32Array>().unwrap();
+            (0..col.len()).map(|i| col.value(i)).collect::<Vec<_>>()
+        })
+        .collect()
+}
+
+#[tokio::test]
+async fn paimon_path_still_serves_paimon_tables() {
+    let env = setup().await;
+    let batches = env
+        .ctx
+        .sql(&format!("SELECT id FROM {CATALOG}.{DB}.pt ORDER BY id"))
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    assert_eq!(column_i32(&batches), vec![1, 2]);
+}
+
+#[tokio::test]
+async fn declared_engine_table_routes_to_engine() {
+    let env = setup().await;
+    let df = env
+        .ctx
+        .sql(&format!("SELECT id, payload FROM {CATALOG}.{DB}.it"))
+        .await
+        .unwrap();
+    let names: Vec<String> = df
+        .schema()
+        .fields()
+        .iter()
+        .map(|f| f.name().clone())
+        .collect();
+    assert_eq!(names, vec!["id".to_string(), "payload".to_string()]);
+    let batches = df.collect().await.unwrap();
+    let rows: usize = batches.iter().map(|b| b.num_rows()).sum();
+    assert_eq!(rows, 2);
+}
+
+#[tokio::test]
+async fn cross_engine_join_plans_and_runs() {
+    let env = setup().await;
+    let batches = env
+        .ctx
+        .sql(&format!(
+            "SELECT p.id FROM {CATALOG}.{DB}.pt p JOIN {CATALOG}.{DB}.it i ON 
p.id = i.id"
+        ))
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    assert_eq!(column_i32(&batches), vec![1]);
+}
+
+#[tokio::test]
+async fn missing_table_still_errors() {
+    let env = setup().await;
+    let Err(err) = env
+        .ctx
+        .sql(&format!("SELECT * FROM {CATALOG}.{DB}.does_not_exist"))
+        .await
+    else {
+        panic!("query against a missing table must fail");
+    };
+    let msg = err.to_string().to_lowercase();
+    assert!(
+        msg.contains("does_not_exist") || msg.contains("not found"),
+        "{msg}"
+    );
+}
+
+#[tokio::test]
+async fn time_travel_still_works_with_engines_registered() {
+    let env = setup().await;
+    let batches = env
+        .ctx
+        .sql(&format!("SELECT id FROM {CATALOG}.{DB}.pt VERSION AS OF 1"))
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    assert_eq!(column_i32(&batches), vec![1]);
+}
+
+#[tokio::test]
+async fn temp_tables_still_work_with_engines_registered() {
+    let env = setup().await;
+    let schema = 
Arc::new(datafusion::arrow::datatypes::Schema::new(vec![Field::new(
+        "id",
+        DataType::Int32,
+        false,
+    )]));
+    let mem = MemTable::try_new(schema, vec![vec![]]).unwrap();
+    env.ctx
+        .register_temp_table(format!("{CATALOG}.{DB}.tmp_t"), Arc::new(mem))
+        .expect("temp table registration must survive engine registration");
+    assert!(env
+        .ctx
+        .temp_table_exist(format!("{CATALOG}.{DB}.tmp_t"))
+        .unwrap());
+}
+
+#[tokio::test]
+async fn schemas_and_ddl_behave_with_engines_registered() {
+    let env = setup().await;
+    let provider = env.ctx.ctx().catalog(CATALOG).unwrap();
+    assert!(provider.schema(DB).is_some());
+    assert!(provider.schema("no_such_db").is_none());
+    env.ctx
+        .sql(&format!("CREATE SCHEMA {CATALOG}.fresh_db"))
+        .await
+        .expect("CREATE SCHEMA must work with engines registered");
+    env.ctx
+        .sql(&format!(
+            "CREATE TABLE {CATALOG}.fresh_db.t (id INT NOT NULL)"
+        ))
+        .await
+        .expect("CREATE TABLE in the fresh schema must work");
+}
+
+#[tokio::test]
+async fn table_names_come_from_the_catalog_listing() {
+    let env = setup().await;
+    let provider = env.ctx.ctx().catalog(CATALOG).unwrap();
+    let schema = provider.schema(DB).unwrap();
+    let mut names = schema.table_names();
+    names.sort();
+    names.dedup();
+    assert!(names.contains(&"pt".to_string()), "{names:?}");
+    assert!(names.contains(&"it".to_string()), "{names:?}");
+}
+
+#[derive(Debug)]
+struct BrokenResolver;
+
+#[async_trait]
+impl TableEngineResolver for BrokenResolver {
+    async fn resolve_table(
+        &self,
+        _request: &EngineTableRequest,
+    ) -> DFResult<Option<Arc<dyn TableProvider>>> {
+        Err(DataFusionError::Execution(
+            "engine backend exploded".to_string(),
+        ))
+    }
+}
+
+#[tokio::test]
+async fn engine_errors_are_surfaced() {
+    let env = setup().await;
+    env.ctx
+        .register_catalog_table_engine(CATALOG, TableType::IcebergTable, 
Arc::new(BrokenResolver))
+        .unwrap();
+    let Err(err) = env
+        .ctx
+        .sql(&format!("SELECT * FROM {CATALOG}.{DB}.it"))
+        .await
+    else {
+        panic!("broken engine must fail loudly");
+    };
+    let msg = err.to_string();
+    assert!(msg.contains("engine backend exploded"), "{msg}");
+}
+
+#[tokio::test]
+async fn paimon_served_type_takes_paimon_path() {
+    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());
+    let typed_catalog = Arc::new(TypedTestCatalog {
+        inner: fs_catalog,
+        declared_types: HashMap::from([("mt".to_string(), 
TableType::MaterializedTable)]),
+    });
+    let mut ctx = SQLContext::new();
+    ctx.register_catalog(CATALOG, typed_catalog).await.unwrap();
+    ctx.sql(&format!("CREATE SCHEMA {CATALOG}.{DB}"))
+        .await
+        .unwrap();
+    let Err(err) = ctx.sql(&format!("SELECT * FROM {CATALOG}.{DB}.mt")).await 
else {
+        panic!("a Paimon-served type must fall through to Paimon");
+    };
+    let msg = err.to_string().to_lowercase();
+    assert!(msg.contains("mt") || msg.contains("not found"), "{msg}");
+}
+
+#[tokio::test]
+async fn writes_to_routed_tables_fail_closed() {
+    let env = setup().await;
+    let Err(err) = env
+        .ctx
+        .sql(&format!("UPDATE {CATALOG}.{DB}.ft SET id = 1"))
+        .await
+    else {
+        panic!("UPDATE on a routed table must fail");
+    };
+    let msg = err.to_string();
+    assert!(msg.contains("cannot be read as a Paimon table"), "{msg}");
+    assert!(msg.contains(TableType::IcebergTable.as_str()), "{msg}");
+}
+
+#[tokio::test]
+async fn system_tables_on_routed_tables_error() {
+    let env = setup().await;
+    let Err(err) = env
+        .ctx
+        .sql(&format!("SELECT * FROM {CATALOG}.{DB}.\"it$snapshots\""))
+        .await
+    else {
+        panic!("system table on a routed table must fail");
+    };
+    let msg = err.to_string();
+    assert!(msg.contains("cannot be read as a Paimon table"), "{msg}");
+}
+
+#[tokio::test]
+async fn table_exist_mirrors_the_resolver() {
+    let env = setup().await;
+    let provider = env.ctx.ctx().catalog(CATALOG).unwrap();
+    let schema = provider.schema(DB).unwrap();
+    assert!(schema.table_exist("it"));
+    assert!(!schema.table_exist("ghost"));
+    assert!(!schema.table_exist("it$snapshots"));
+}
+
+#[tokio::test]
+async fn paimon_managed_types_cannot_be_routed() {
+    let env = setup().await;
+    let Err(err) = env.ctx.register_catalog_table_engine(
+        CATALOG,
+        TableType::FormatTable,
+        Arc::new(FakeEngineResolver),
+    ) else {
+        panic!("registering an engine for a Paimon-managed type must fail");
+    };
+    let msg = err.to_string();
+    assert!(msg.contains("served by the Paimon reader"), "{msg}");
+}
+
+#[tokio::test]
+async fn insert_into_routed_table_is_rejected() {
+    let env = setup().await;
+    let err = async {
+        env.ctx
+            .sql(&format!("INSERT INTO {CATALOG}.{DB}.it VALUES (9, 'z')"))
+            .await?
+            .collect()
+            .await
+    }
+    .await
+    .expect_err("insert into a routed table must fail");
+    assert!(
+        err.to_string()
+            .contains("write is not supported for routed 'iceberg-table' 
tables"),
+        "{err}"
+    );
+}
+
+#[tokio::test]
+async fn object_and_lance_tables_route_to_engines() {
+    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());
+    let typed_catalog = Arc::new(TypedTestCatalog {
+        inner: fs_catalog,
+        declared_types: HashMap::from([
+            ("it".to_string(), TableType::ObjectTable),
+            ("lt".to_string(), TableType::LanceTable),
+        ]),
+    });
+    let mut ctx = SQLContext::new();
+    ctx.register_catalog(CATALOG, typed_catalog).await.unwrap();
+    ctx.sql(&format!("CREATE SCHEMA {CATALOG}.{DB}"))
+        .await
+        .unwrap();
+    for declared in [TableType::ObjectTable, TableType::LanceTable] {
+        ctx.register_catalog_table_engine(CATALOG, declared, 
Arc::new(FakeEngineResolver))
+            .unwrap();
+    }
+
+    let batches = ctx
+        .sql(&format!("SELECT id FROM {CATALOG}.{DB}.it ORDER BY id"))
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    assert_eq!(column_i32(&batches), vec![1, 3]);
+}
+
+#[tokio::test]
+async fn time_travel_on_routed_tables_is_rejected() {
+    let env = setup().await;
+    env.ctx
+        .ctx()
+        .sql("SET datafusion.sql_parser.dialect = 'databricks'")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    let Err(err) = env
+        .ctx
+        .ctx()
+        .sql(&format!(
+            "SELECT * FROM {CATALOG}.{DB}.it VERSION AS OF 999999"
+        ))
+        .await
+    else {
+        panic!("time travel on a routed table must not silently read current 
data");
+    };
+    let msg = err.to_string();
+    assert!(msg.contains("time travel is not supported"), "{msg}");
+
+    let Err(err) = env
+        .ctx
+        .ctx()
+        .sql(&format!(
+            "SELECT * FROM {CATALOG}.{DB}.it TIMESTAMP AS OF '2020-01-01 
00:00:00'"
+        ))
+        .await
+    else {
+        panic!("timestamp travel on a routed table must be rejected too");
+    };
+    assert!(
+        err.to_string().contains("time travel is not supported"),
+        "{err}"
+    );
+}
+
+#[tokio::test]
+async fn session_time_travel_on_routed_tables_is_rejected() {
+    let env = setup().await;
+    env.ctx
+        .sql("SET 'paimon.scan.version' = '1'")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+
+    let Err(err) = env
+        .ctx
+        .sql(&format!("SELECT * FROM {CATALOG}.{DB}.it"))
+        .await
+    else {
+        panic!("a session scan selector must not silently read current data");
+    };
+    assert!(
+        err.to_string().contains("time travel is not supported"),
+        "{err}"
+    );
+
+    env.ctx
+        .sql("RESET 'paimon.scan.version'")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    env.ctx
+        .sql(&format!("SELECT * FROM {CATALOG}.{DB}.it"))
+        .await
+        .expect("routing works again once the selector is reset");
+
+    env.ctx
+        .sql("SET 'paimon.incremental-between' = '1,5'")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    let Err(err) = env
+        .ctx
+        .sql(&format!("SELECT * FROM {CATALOG}.{DB}.it"))
+        .await
+    else {
+        panic!("an unsupported scan option must not be silently ignored");
+    };
+    assert!(err.to_string().contains("incremental-between"), "{err}");
+}
+
+#[tokio::test]
+async fn every_version_clause_on_routed_tables_is_rejected() {
+    let env = setup().await;
+    env.ctx
+        .ctx()
+        .sql("SET datafusion.sql_parser.dialect = 'databricks'")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+
+    for clause in [
+        "VERSION AS OF 999999",
+        "TIMESTAMP AS OF '2020-01-01 00:00:00'",
+        "FOR SYSTEM_TIME AS OF '2020-01-01 00:00:00'",
+    ] {
+        let Err(err) = env
+            .ctx
+            .ctx()
+            .sql(&format!("SELECT * FROM {CATALOG}.{DB}.it {clause}"))
+            .await
+        else {
+            panic!("{clause} must not silently read current data");
+        };
+        assert!(
+            err.to_string().contains("time travel is not supported"),
+            "{clause}: {err}"
+        );
+    }
+}
+
+#[tokio::test]
+async fn show_create_on_routed_tables_reports_the_declared_type() {
+    let env = setup().await;
+    let Err(err) = env
+        .ctx
+        .sql(&format!("SHOW CREATE TABLE {CATALOG}.{DB}.it"))
+        .await
+    else {
+        panic!("Paimon DDL would misrepresent an engine-served table");
+    };
+    let msg = err.to_string();
+    assert!(msg.contains("iceberg-table"), "{msg}");
+    assert!(msg.contains("cannot be read as a Paimon table"), "{msg}");
+}
+
+#[tokio::test]
+async fn session_query_auth_blocks_routed_reads() {
+    let env = setup().await;
+    env.ctx
+        .sql("SET 'paimon.query-auth.enabled' = 'true'")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+
+    let Err(err) = env
+        .ctx
+        .sql(&format!("SELECT * FROM {CATALOG}.{DB}.it"))
+        .await
+    else {
+        panic!("query-auth must block a routed read, not just a Paimon one");
+    };
+    assert!(err.to_string().contains("query-auth"), "{err}");
+
+    env.ctx
+        .sql("RESET 'paimon.query-auth.enabled'")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+    env.ctx
+        .sql(&format!("SELECT * FROM {CATALOG}.{DB}.it"))
+        .await
+        .expect("routing works again once the flag is reset");
+}
+
+#[tokio::test]
+async fn registering_on_a_raw_session_installs_the_planner() {
+    use datafusion::prelude::SessionContext;
+    use paimon_datafusion::{register_catalog_table_engine, 
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 typed_catalog = Arc::new(TypedTestCatalog {
+        inner: fs_catalog,
+        declared_types: HashMap::from([("it".to_string(), 
TableType::IcebergTable)]),
+    });
+
+    // No SQLContext: the raw path a caller might take.
+    let ctx = SessionContext::new();
+    ctx.register_catalog(
+        CATALOG,
+        Arc::new(PaimonCatalogProvider::new(
+            Some(CATALOG.to_string()),
+            typed_catalog,
+            Default::default(),
+            Default::default(),
+            None,
+        )),
+    );
+    register_catalog_table_engine(
+        &ctx,
+        CATALOG,
+        TableType::IcebergTable,
+        Arc::new(FakeEngineResolver),
+    )
+    .unwrap();
+    ctx.sql("SET datafusion.sql_parser.dialect = 'databricks'")
+        .await
+        .unwrap()
+        .collect()
+        .await
+        .unwrap();
+
+    let Err(err) = ctx
+        .sql(&format!("SELECT * FROM {CATALOG}.{DB}.it VERSION AS OF 1"))
+        .await
+    else {
+        panic!("registration must install the planner, so this cannot read 
current data");
+    };
+    assert!(err.to_string().contains("not supported"), "{err}");
+}
+
+#[tokio::test]
+async fn an_external_type_without_an_engine_says_so() {
+    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());
+    let typed_catalog = Arc::new(TypedTestCatalog {
+        inner: fs_catalog,
+        declared_types: HashMap::from([("it".to_string(), 
TableType::IcebergTable)]),
+    });
+    let mut ctx = SQLContext::new();
+    ctx.register_catalog(CATALOG, typed_catalog).await.unwrap();
+    ctx.sql(&format!("CREATE SCHEMA {CATALOG}.{DB}"))
+        .await
+        .unwrap();
+
+    let Err(err) = ctx.sql(&format!("SELECT * FROM {CATALOG}.{DB}.it")).await 
else {
+        panic!("an external table without an engine must not resolve");
+    };
+    let msg = err.to_string();
+    assert!(msg.contains("no table engine is registered"), "{msg}");
+    assert!(msg.contains("iceberg-table"), "{msg}");
+}
diff --git a/crates/paimon-rest-server/src/lib.rs 
b/crates/paimon-rest-server/src/lib.rs
index 434f38de..403b4220 100644
--- a/crates/paimon-rest-server/src/lib.rs
+++ b/crates/paimon-rest-server/src/lib.rs
@@ -416,12 +416,13 @@ async fn create_table(
 async fn get_table(path: RestPath, Extension(state): Extension<Arc<AppState>>) 
-> Response {
     let table = path.get("table");
     let identifier = Identifier::new(path.get("db"), table.clone());
-    let resolved = match state.catalog.get_table(&identifier).await {
-        Ok(t) => t,
+    // Raw metadata, not a constructed table: engine-served types must stay
+    // describable so clients can route them.
+    let (location, loaded_schema) = match 
state.catalog.fetch_table_schema(&identifier).await {
+        Ok(loaded) => loaded,
         Err(e) => return error_response(e),
     };
-
-    let table_schema = resolved.schema();
+    let table_schema = &loaded_schema;
     // Convert the stored `TableSchema` into the DDL `Schema` the response
     // carries. `Schema` is a field subset of `TableSchema` (both camelCase),
     // and serde ignores the extra keys, preserving field ids exactly.
@@ -441,7 +442,7 @@ async fn get_table(path: RestPath, Extension(state): 
Extension<Arc<AppState>>) -
         // that satisfies the client's RESTEnv requirement.
         Some(identifier.full_name()),
         Some(table),
-        Some(resolved.location().to_string()),
+        Some(location),
         Some(false),
         Some(table_schema.id()),
         Some(schema),
diff --git a/crates/paimon-rest-server/tests/e2e.rs 
b/crates/paimon-rest-server/tests/e2e.rs
index 6edf274b..db27bb3e 100644
--- a/crates/paimon-rest-server/tests/e2e.rs
+++ b/crates/paimon-rest-server/tests/e2e.rs
@@ -429,3 +429,99 @@ async fn test_special_char_names() {
     cat.drop_database(plus_db, false, false).await.unwrap();
     assert!(cat.list_databases().await.unwrap().is_empty());
 }
+
+#[tokio::test]
+async fn declared_engine_type_routes_through_the_rest_catalog() {
+    use paimon::catalog::LoadedTable;
+    use paimon::spec::TableType;
+
+    let ctx = setup().await;
+    ctx.catalog
+        .create_database("db", false, HashMap::new())
+        .await
+        .expect("create database");
+
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .option("type", "iceberg-table")
+        .build()
+        .expect("build schema");
+    let identifier = Identifier::new("db", "ice_t");
+    ctx.catalog
+        .create_table(&identifier, schema, false)
+        .await
+        .expect("create table");
+
+    let routed = ctx
+        .catalog
+        .load_table(&identifier)
+        .await
+        .expect("routing must reach the declared type");
+    assert!(
+        matches!(routed, LoadedTable::External(ref e) if e.declared() == 
TableType::IcebergTable),
+        "{routed:?}"
+    );
+
+    let err = ctx
+        .catalog
+        .get_table(&identifier)
+        .await
+        .expect_err("an engine-served table must not construct as Paimon");
+    assert!(
+        err.to_string().contains("cannot be read as a Paimon table"),
+        "{err}"
+    );
+}
+
+#[tokio::test]
+async fn creating_a_table_with_an_unknown_type_is_rejected() {
+    let ctx = setup().await;
+    ctx.catalog
+        .create_database("db", false, HashMap::new())
+        .await
+        .expect("create database");
+
+    let mut payload = 
serde_json::to_value(append_only_schema()).expect("serialize schema");
+    payload["options"]["type"] = serde_json::json!("iceberg-tabel");
+    let schema: Schema = serde_json::from_value(payload).expect("deserialize 
schema");
+    let identifier = Identifier::new("db", "typo_t");
+
+    ctx.catalog
+        .create_table(&identifier, schema, false)
+        .await
+        .expect_err("a misspelled type must not create a table");
+    let tables = ctx.catalog.list_tables("db").await.expect("list tables");
+    assert!(!tables.contains(&"typo_t".to_string()), "{tables:?}");
+}
+
+#[tokio::test]
+async fn altering_the_declared_type_is_rejected() {
+    use paimon::spec::SchemaChange;
+
+    let ctx = setup().await;
+    ctx.catalog
+        .create_database("db", false, HashMap::new())
+        .await
+        .expect("create database");
+    let identifier = Identifier::new("db", "t");
+    ctx.catalog
+        .create_table(&identifier, append_only_schema(), false)
+        .await
+        .expect("create table");
+
+    ctx.catalog
+        .alter_table(
+            &identifier,
+            vec![SchemaChange::set_option(
+                "type".to_string(),
+                "iceberg-table".to_string(),
+            )],
+            false,
+        )
+        .await
+        .expect_err("changing 'type' over REST must be rejected");
+    ctx.catalog
+        .get_table(&identifier)
+        .await
+        .expect("still readable");
+}
diff --git a/crates/paimon/src/catalog/filesystem.rs 
b/crates/paimon/src/catalog/filesystem.rs
index ae015e25..0ebd8ab8 100644
--- a/crates/paimon/src/catalog/filesystem.rs
+++ b/crates/paimon/src/catalog/filesystem.rs
@@ -26,7 +26,7 @@ use crate::common::{CatalogOptions, Options};
 use crate::error::{ConfigInvalidSnafu, Error, Result};
 use crate::io::cache::create_local_cache;
 use crate::io::FileIO;
-use crate::spec::{Schema, TableSchema};
+use crate::spec::{CoreOptions, Schema, TableSchema, TableType, 
TABLE_TYPE_OPTION};
 use crate::table::{SchemaManager, Table};
 use async_trait::async_trait;
 use bytes::Bytes;
@@ -170,6 +170,61 @@ impl FileSystemCatalog {
         Ok(dirs)
     }
 
+    /// Fetch the stored path and schema of an existing table, bypassing the
+    /// engine-type guard in [`Self::build_table`]: routing and catalog servers
+    /// need the declared type before deciding anything.
+    pub async fn fetch_table_schema(
+        &self,
+        identifier: &Identifier,
+    ) -> Result<(String, TableSchema)> {
+        identifier.validate()?;
+
+        let table_path = self.table_path(identifier);
+
+        if !self.table_exists(identifier).await? {
+            return Err(Error::TableNotExist {
+                full_name: identifier.full_name(),
+            });
+        }
+
+        let schema = self
+            .load_latest_table_schema(&table_path)
+            .await?
+            .ok_or_else(|| Error::TableNotExist {
+                full_name: identifier.full_name(),
+            })?;
+
+        Ok((table_path, schema))
+    }
+
+    fn build_table(
+        &self,
+        identifier: &Identifier,
+        table_path: String,
+        schema: TableSchema,
+    ) -> Result<Table> {
+        // Fail closed: constructed as Paimon, raw `get_table` paths (writes,
+        // procedures, time travel) would misread it.
+        let declared = CoreOptions::new(schema.options()).table_type()?;
+        if declared.requires_table_engine() {
+            return Err(Error::Unsupported {
+                message: format!(
+                    "table '{}' is declared '{declared}' and cannot be read as 
a Paimon \
+                     table; only plain reads through a registered table engine 
are supported",
+                    identifier.full_name()
+                ),
+            });
+        }
+
+        Ok(Table::new(
+            self.file_io.clone(),
+            identifier.clone(),
+            table_path,
+            schema,
+            None,
+        ))
+    }
+
     /// Load the latest schema for a table (highest schema-{version} file 
under table_path/schema).
     async fn load_latest_table_schema(&self, table_path: &str) -> 
Result<Option<TableSchema>> {
         let manager = SchemaManager::new(self.file_io.clone(), 
table_path.to_string());
@@ -297,30 +352,23 @@ impl Catalog for FileSystemCatalog {
     }
 
     async fn get_table(&self, identifier: &Identifier) -> Result<Table> {
-        identifier.validate()?;
-
-        let table_path = self.table_path(identifier);
+        let (table_path, schema) = self.fetch_table_schema(identifier).await?;
+        self.build_table(identifier, table_path, schema)
+    }
 
-        if !self.table_exists(identifier).await? {
-            return Err(Error::TableNotExist {
-                full_name: identifier.full_name(),
-            });
+    async fn load_table(&self, identifier: &Identifier) -> 
Result<crate::catalog::LoadedTable> {
+        let (table_path, schema) = self.fetch_table_schema(identifier).await?;
+        let options = CoreOptions::new(schema.options());
+        let declared = options.table_type()?;
+        if declared.requires_table_engine() {
+            return crate::catalog::LoadedTable::external(
+                declared,
+                &options,
+                &identifier.full_name(),
+            );
         }
-
-        let schema = self
-            .load_latest_table_schema(&table_path)
-            .await?
-            .ok_or_else(|| Error::TableNotExist {
-                full_name: identifier.full_name(),
-            })?;
-
-        Ok(Table::new(
-            self.file_io.clone(),
-            identifier.clone(),
-            table_path,
-            schema,
-            None,
-        ))
+        self.build_table(identifier, table_path, schema)
+            .map(|table| crate::catalog::LoadedTable::Paimon(Box::new(table)))
     }
 
     async fn list_tables(&self, database_name: &str) -> Result<Vec<String>> {
@@ -351,6 +399,8 @@ impl Catalog for FileSystemCatalog {
         ignore_if_exists: bool,
     ) -> Result<()> {
         identifier.validate()?;
+        // Never persist a type nothing can load.
+        CoreOptions::new(creation.options()).table_type()?;
 
         let table_path = self.table_path(identifier);
 
@@ -458,6 +508,8 @@ impl Catalog for FileSystemCatalog {
                 full_name: identifier.full_name(),
             })?;
 
+        reject_table_type_changes(current.options(), &changes)?;
+
         let new_schema = current
             .apply_changes(changes)
             .map_err(|e| fill_table_name(e, identifier))?;
@@ -465,6 +517,39 @@ impl Catalog for FileSystemCatalog {
     }
 }
 
+/// The declared type picks the reader, so it is fixed at creation: flipping
+/// it strands a populated table behind a reader that cannot see its data.
+/// Only case-insensitive no-ops pass, matching Java `SchemaManager`.
+fn reject_table_type_changes(
+    current_options: &HashMap<String, String>,
+    changes: &[crate::spec::SchemaChange],
+) -> Result<()> {
+    let current_type = current_options
+        .get(TABLE_TYPE_OPTION)
+        .map(String::as_str)
+        .unwrap_or_else(|| TableType::default().as_str());
+    for change in changes {
+        match change {
+            crate::spec::SchemaChange::SetOption { key, value }
+                if key == TABLE_TYPE_OPTION && 
!value.eq_ignore_ascii_case(current_type) =>
+            {
+                return Err(Error::Unsupported {
+                    message: format!(
+                        "changing '{TABLE_TYPE_OPTION}' from '{current_type}' 
to '{value}' is not supported"
+                    ),
+                });
+            }
+            crate::spec::SchemaChange::RemoveOption { key } if key == 
TABLE_TYPE_OPTION => {
+                return Err(Error::Unsupported {
+                    message: format!("removing '{TABLE_TYPE_OPTION}' is not 
supported"),
+                });
+            }
+            _ => {}
+        }
+    }
+    Ok(())
+}
+
 /// `TableSchema::apply_changes` returns column errors without a table name;
 /// fill in the identifier's full name so the message identifies the table.
 fn fill_table_name(err: Error, identifier: &Identifier) -> Error {
@@ -665,6 +750,234 @@ mod tests {
         assert!(escaped_path.exists());
     }
 
+    #[tokio::test]
+    async fn test_alter_table_cannot_change_the_declared_type() {
+        use crate::spec::SchemaChange;
+
+        let (_temp_dir, catalog) = create_test_catalog();
+        catalog
+            .create_database("db1", false, HashMap::new())
+            .await
+            .unwrap();
+        let schema = Schema::builder()
+            .column(
+                "id",
+                crate::spec::DataType::Int(crate::spec::IntType::new()),
+            )
+            .build()
+            .unwrap();
+        let identifier = Identifier::new("db1", "t");
+        catalog
+            .create_table(&identifier, schema, false)
+            .await
+            .unwrap();
+
+        for change in [
+            SchemaChange::set_option("type".to_string(), 
"iceberg-table".to_string()),
+            SchemaChange::set_option("type".to_string(), 
"delta-table".to_string()),
+            SchemaChange::remove_option("type".to_string()),
+        ] {
+            let err = catalog
+                .alter_table(&identifier, vec![change], false)
+                .await
+                .unwrap_err();
+            assert!(matches!(err, Error::Unsupported { .. }), "{err:?}");
+        }
+        assert!(catalog
+            .alter_table(
+                &identifier,
+                vec![SchemaChange::set_option(
+                    "type".to_string(),
+                    "TABLE".to_string(),
+                )],
+                false,
+            )
+            .await
+            .is_ok());
+        catalog.get_table(&identifier).await.unwrap();
+    }
+
+    #[tokio::test]
+    async fn test_create_table_rejects_an_unknown_type() {
+        let (_temp_dir, catalog) = create_test_catalog();
+        catalog
+            .create_database("db1", false, HashMap::new())
+            .await
+            .unwrap();
+        let valid = Schema::builder()
+            .column(
+                "id",
+                crate::spec::DataType::Int(crate::spec::IntType::new()),
+            )
+            .build()
+            .unwrap();
+        let mut payload = serde_json::to_value(&valid).unwrap();
+        payload["options"] = serde_json::json!({ "type": "delta-table" });
+        let schema: Schema = serde_json::from_value(payload).unwrap();
+        let identifier = Identifier::new("db1", "delta_t");
+
+        let err = catalog
+            .create_table(&identifier, schema, false)
+            .await
+            .unwrap_err();
+        assert!(
+            matches!(err, Error::Unsupported { ref message } if 
message.contains("delta-table")),
+            "{err:?}"
+        );
+        assert!(!catalog.table_exists(&identifier).await.unwrap());
+    }
+
+    #[tokio::test]
+    async fn test_stored_scan_selectors_block_routing() {
+        use crate::catalog::LoadedTable;
+
+        let (_temp_dir, catalog) = create_test_catalog();
+        catalog
+            .create_database("db1", false, HashMap::new())
+            .await
+            .unwrap();
+
+        for (name, key, value) in [
+            ("ice_v", "scan.version", "1"),
+            ("ice_s", "scan.snapshot-id", "1"),
+            ("ice_i", "incremental-between", "1,5"),
+        ] {
+            let schema = Schema::builder()
+                .column(
+                    "id",
+                    crate::spec::DataType::Int(crate::spec::IntType::new()),
+                )
+                .option("type", "iceberg-table")
+                .option(key, value)
+                .build()
+                .unwrap();
+            let identifier = Identifier::new("db1", name);
+            catalog
+                .create_table(&identifier, schema, false)
+                .await
+                .unwrap();
+
+            let err = catalog.load_table(&identifier).await.unwrap_err();
+            assert!(matches!(err, Error::Unsupported { .. }), "{key}: 
{err:?}");
+        }
+
+        let schema = Schema::builder()
+            .column(
+                "id",
+                crate::spec::DataType::Int(crate::spec::IntType::new()),
+            )
+            .option("type", "iceberg-table")
+            .build()
+            .unwrap();
+        let identifier = Identifier::new("db1", "ice_plain");
+        catalog
+            .create_table(&identifier, schema, false)
+            .await
+            .unwrap();
+        let routed = catalog.load_table(&identifier).await.unwrap();
+        assert!(matches!(routed, LoadedTable::External(_)), "{routed:?}");
+    }
+
+    #[tokio::test]
+    async fn test_routing_cannot_skip_the_read_guards() {
+        use crate::catalog::LoadedTable;
+        use crate::spec::CoreOptions;
+
+        // The only constructor of the `Engine` variant, so a third-party
+        // catalog cannot route past these guards either.
+        for (key, value) in [
+            ("query-auth.enabled", "true"),
+            ("scan.version", "1"),
+            ("incremental-between", "1,5"),
+        ] {
+            let options = HashMap::from([(key.to_string(), 
value.to_string())]);
+            let err = LoadedTable::external(
+                TableType::IcebergTable,
+                &CoreOptions::new(&options),
+                "db1.t",
+            )
+            .unwrap_err();
+            assert!(matches!(err, Error::Unsupported { .. }), "{key}: 
{err:?}");
+        }
+
+        let options = HashMap::new();
+        assert!(LoadedTable::external(
+            TableType::IcebergTable,
+            &CoreOptions::new(&options),
+            "db1.t",
+        )
+        .is_ok());
+    }
+
+    #[tokio::test]
+    async fn test_types_without_a_paimon_reader_fail_closed() {
+        let (_temp_dir, catalog) = create_test_catalog();
+        catalog
+            .create_database("db1", false, HashMap::new())
+            .await
+            .unwrap();
+
+        for (name, declared) in [("obj_t", "object-table"), ("lance_t", 
"lance-table")] {
+            let schema = Schema::builder()
+                .column(
+                    "id",
+                    crate::spec::DataType::Int(crate::spec::IntType::new()),
+                )
+                .option("type", declared)
+                .build()
+                .unwrap();
+            let identifier = Identifier::new("db1", name);
+            catalog
+                .create_table(&identifier, schema, false)
+                .await
+                .unwrap();
+
+            let err = catalog.get_table(&identifier).await.unwrap_err();
+            assert!(
+                matches!(err, Error::Unsupported { ref message }
+                    if message.contains("cannot be read as a Paimon table")),
+                "{declared}: {err:?}"
+            );
+        }
+    }
+
+    #[tokio::test]
+    async fn test_declared_engine_type_routes_and_fails_closed() {
+        use crate::catalog::LoadedTable;
+
+        let (_temp_dir, catalog) = create_test_catalog();
+        catalog
+            .create_database("db1", false, HashMap::new())
+            .await
+            .unwrap();
+        let schema = Schema::builder()
+            .column(
+                "id",
+                crate::spec::DataType::Int(crate::spec::IntType::new()),
+            )
+            .option("type", "iceberg-table")
+            .build()
+            .unwrap();
+        let identifier = Identifier::new("db1", "ice_t");
+        catalog
+            .create_table(&identifier, schema, false)
+            .await
+            .unwrap();
+
+        let routed = catalog.load_table(&identifier).await.unwrap();
+        assert!(
+            matches!(routed, LoadedTable::External(ref e) if e.declared() == 
TableType::IcebergTable),
+            "{routed:?}"
+        );
+
+        let err = catalog.get_table(&identifier).await.unwrap_err();
+        assert!(
+            matches!(err, Error::Unsupported { ref message }
+                if message.contains("cannot be read as a Paimon table")),
+            "{err:?}"
+        );
+    }
+
     #[tokio::test]
     async fn test_table_operations() {
         let (_temp_dir, catalog) = create_test_catalog();
diff --git a/crates/paimon/src/catalog/mod.rs b/crates/paimon/src/catalog/mod.rs
index 38a414b9..b2a5cf23 100644
--- a/crates/paimon/src/catalog/mod.rs
+++ b/crates/paimon/src/catalog/mod.rs
@@ -262,9 +262,57 @@ impl fmt::Debug for Identifier {
 use async_trait::async_trait;
 
 use crate::api::PagedList;
-use crate::spec::{Partition, Schema, SchemaChange};
+use crate::spec::{Partition, Schema, SchemaChange, TableType};
 use crate::table::Table;
 
+/// Outcome of [`Catalog::load_table`].
+#[derive(Debug)]
+pub enum LoadedTable {
+    /// A constructed Paimon table (boxed: far larger than the other variant).
+    Paimon(Box<Table>),
+    /// A table this reader cannot construct.
+    External(ExternalTableMetadata),
+}
+
+/// What a caller needs to pick an engine for a table Paimon cannot construct.
+/// Only [`LoadedTable::external`] can build one, so the stored-metadata checks
+/// always run before a caller sees it.
+#[derive(Debug)]
+pub struct ExternalTableMetadata {
+    declared: TableType,
+}
+
+impl ExternalTableMetadata {
+    /// The type the table's metadata declares.
+    pub fn declared(&self) -> TableType {
+        self.declared
+    }
+}
+
+impl LoadedTable {
+    /// Classify `full_name` as external, refusing options no engine can honor
+    /// and reads this client cannot authorize.
+    ///
+    /// Errors if [`TableType::requires_table_engine`] rejects `declared`:
+    /// classifying a Paimon-served type as external would skip the reader that
+    /// can actually construct it.
+    pub fn external(
+        declared: TableType,
+        options: &crate::spec::CoreOptions<'_>,
+        full_name: &str,
+    ) -> Result<Self> {
+        if !declared.requires_table_engine() {
+            return Err(Error::Unsupported {
+                message: format!(
+                    "table '{full_name}' is declared '{declared}', which the 
Paimon reader serves"
+                ),
+            });
+        }
+        options.ensure_engine_can_serve(full_name)?;
+        Ok(Self::External(ExternalTableMetadata { declared }))
+    }
+}
+
 /// Catalog API for reading and writing metadata (databases, tables) in Paimon.
 ///
 /// Corresponds to 
[org.apache.paimon.catalog.Catalog](https://github.com/apache/paimon/blob/release-1.3/paimon-core/src/main/java/org/apache/paimon/catalog/Catalog.java).
@@ -323,6 +371,20 @@ pub trait Catalog: Send + Sync {
     /// * [`crate::Error::TableNotExist`] - table does not exist.
     async fn get_table(&self, identifier: &Identifier) -> Result<Table>;
 
+    /// Load a table, or classify it as [`LoadedTable::External`] when this
+    /// reader cannot construct it. One metadata round-trip either way, and the
+    /// outcome depends only on the table's own metadata. The default
+    /// implementation always constructs, for catalogs without a table-type
+    /// concept.
+    ///
+    /// # Errors
+    /// Same as [`Catalog::get_table`].
+    async fn load_table(&self, identifier: &Identifier) -> Result<LoadedTable> 
{
+        Ok(LoadedTable::Paimon(Box::new(
+            self.get_table(identifier).await?,
+        )))
+    }
+
     /// List table names in a database. System tables are not listed.
     ///
     /// # Errors
diff --git a/crates/paimon/src/catalog/rest/rest_catalog.rs 
b/crates/paimon/src/catalog/rest/rest_catalog.rs
index 401545cb..be41cfc8 100644
--- a/crates/paimon/src/catalog/rest/rest_catalog.rs
+++ b/crates/paimon/src/catalog/rest/rest_catalog.rs
@@ -215,6 +215,31 @@ impl Catalog for RESTCatalog {
         .await
     }
 
+    async fn load_table(&self, identifier: &Identifier) -> 
Result<crate::catalog::LoadedTable> {
+        let response = RESTEnv::fetch_table_response(identifier, 
&self.api).await?;
+        if let Some(schema) = response.schema.as_ref() {
+            let options = crate::spec::CoreOptions::new(schema.options());
+            let declared = options.table_type()?;
+            if declared.requires_table_engine() {
+                return crate::catalog::LoadedTable::external(
+                    declared,
+                    &options,
+                    &identifier.full_name(),
+                );
+            }
+        }
+        RESTEnv::build_table(
+            identifier,
+            response,
+            self.api.clone(),
+            self.options.clone(),
+            self.data_token_enabled,
+            self.local_cache.clone(),
+        )
+        .await
+        .map(|table| crate::catalog::LoadedTable::Paimon(Box::new(table)))
+    }
+
     async fn list_tables(&self, database_name: &str) -> Result<Vec<String>> {
         self.api
             .list_tables(database_name)
diff --git a/crates/paimon/src/spec/core_options.rs 
b/crates/paimon/src/spec/core_options.rs
index e9f34332..cc57ccef 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -17,6 +17,8 @@
 
 use std::collections::{HashMap, HashSet};
 
+use crate::spec::TableType;
+
 const DELETION_VECTORS_ENABLED_OPTION: &str = "deletion-vectors.enabled";
 const DELETION_VECTORS_MERGE_ON_READ_OPTION: &str = 
"deletion-vectors.merge-on-read";
 pub(crate) const QUERY_AUTH_ENABLED_OPTION: &str = "query-auth.enabled";
@@ -73,7 +75,7 @@ const STATS_MODE_SUFFIX: &str = "stats-mode";
 const ROW_TRACKING_ENABLED_OPTION: &str = "row-tracking.enabled";
 const CLUSTERING_INCREMENTAL_OPTION: &str = "clustering.incremental";
 pub(crate) const TABLE_TYPE_OPTION: &str = "type";
-pub(crate) const FORMAT_TABLE_TYPE: &str = "format-table";
+
 pub(crate) const PATH_OPTION: &str = "path";
 const MANIFEST_COMPRESSION_OPTION: &str = "manifest.compression";
 const MANIFEST_TARGET_FILE_SIZE_OPTION: &str = "manifest.target-file-size";
@@ -624,11 +626,17 @@ impl<'a> CoreOptions<'a> {
             .unwrap_or(false)
     }
 
+    /// The declared [`TableType`], defaulting to [`TableType::Table`].
+    /// Fails on a value this client does not know.
+    pub fn table_type(&self) -> crate::Result<TableType> {
+        match self.options.get(TABLE_TYPE_OPTION) {
+            Some(value) => value.parse(),
+            None => Ok(TableType::default()),
+        }
+    }
+
     pub fn is_format_table(&self) -> bool {
-        self.options
-            .get(TABLE_TYPE_OPTION)
-            .map(|value| value.eq_ignore_ascii_case(FORMAT_TABLE_TYPE))
-            .unwrap_or(false)
+        matches!(self.table_type(), Ok(TableType::FormatTable))
     }
 
     pub fn path(&self) -> Option<&str> {
@@ -917,6 +925,33 @@ impl<'a> CoreOptions<'a> {
     ///
     /// This is the semantic owner for selector mutual exclusion and strict
     /// numeric parsing.
+    /// Fails when these options forbid the read outright, or ask for
+    /// something a table engine cannot honor: a scan option this client does
+    /// not support, or a historical state. Dropping any of them would answer
+    /// with unfiltered or current data instead.
+    ///
+    /// Both the table's stored options and a session's options go through
+    /// here, so neither source can skip a check the other applies.
+    pub fn ensure_engine_can_serve(&self, full_name: &str) -> 
crate::Result<()> {
+        self.ensure_read_authorized()?;
+        self.validate_scan_options()?;
+        if self.has_time_travel_selector() {
+            return Err(crate::Error::Unsupported {
+                message: format!(
+                    "time travel is not supported for engine-served table 
'{full_name}'"
+                ),
+            });
+        }
+        Ok(())
+    }
+
+    /// Whether these options ask for a historical state of the table. A
+    /// malformed selector counts too, so callers that cannot honor time
+    /// travel reject rather than answer from the current state.
+    pub fn has_time_travel_selector(&self) -> bool {
+        !matches!(self.try_time_travel_selector(), Ok(None))
+    }
+
     pub(crate) fn try_time_travel_selector(&self) -> 
crate::Result<Option<TimeTravelSelector<'a>>> {
         let selectors = self.configured_time_travel_selectors();
         if selectors.len() > 1 {
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index 3fb3802e..a2613fec 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -31,6 +31,9 @@ pub use blob_view_struct::BlobViewStruct;
 mod data_file;
 pub use data_file::*;
 
+mod table_type;
+pub use table_type::*;
+
 mod core_options;
 pub(crate) use core_options::TimeTravelSelector;
 pub use core_options::*;
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index 3238ccee..0b52571b 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -1155,6 +1155,8 @@ impl Schema {
         primary_keys: &[String],
         options: &HashMap<String, String>,
     ) -> crate::Result<()> {
+        // Create and alter share this, so an unparsable type never lands.
+        CoreOptions::new(options).table_type()?;
         validate_no_reserved_field_names(fields)?;
         Self::validate_key_field_types(fields, primary_keys, options)?;
         Self::validate_row_tracking(primary_keys, options)?;
diff --git a/crates/paimon/src/spec/table_type.rs 
b/crates/paimon/src/spec/table_type.rs
new file mode 100644
index 00000000..e4a095d2
--- /dev/null
+++ b/crates/paimon/src/spec/table_type.rs
@@ -0,0 +1,143 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::fmt::{Display, Formatter};
+use std::str::FromStr;
+
+use crate::error::Error;
+
+/// Type of the table, declared by the `type` table option.
+///
+/// Mirrors `org.apache.paimon.TableType`.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
+pub enum TableType {
+    /// Normal Paimon table.
+    #[default]
+    Table,
+    /// A directory containing multiple files of the same format.
+    FormatTable,
+    /// A normal Paimon table combined with materialized SQL.
+    MaterializedTable,
+    /// A normal Paimon table combined with an object location.
+    ObjectTable,
+    /// A lance table, see <https://lancedb.github.io/lance/>.
+    LanceTable,
+    /// An iceberg table, see <https://iceberg.apache.org/>.
+    IcebergTable,
+}
+
+impl TableType {
+    /// The `type` option value of this table type.
+    pub fn as_str(&self) -> &'static str {
+        match self {
+            TableType::Table => "table",
+            TableType::FormatTable => "format-table",
+            TableType::MaterializedTable => "materialized-table",
+            TableType::ObjectTable => "object-table",
+            TableType::LanceTable => "lance-table",
+            TableType::IcebergTable => "iceberg-table",
+        }
+    }
+
+    /// Whether this type needs an engine of its own (see
+    /// [`Catalog::load_table`](crate::catalog::Catalog::load_table)). Java
+    /// builds a dedicated table for each; this client has none, so reading one
+    /// as Paimon misreads it and writing could put Paimon snapshots over
+    /// foreign data.
+    pub fn requires_table_engine(&self) -> bool {
+        matches!(
+            self,
+            TableType::ObjectTable | TableType::LanceTable | 
TableType::IcebergTable
+        )
+    }
+}
+
+impl Display for TableType {
+    fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+        f.write_str(self.as_str())
+    }
+}
+
+impl FromStr for TableType {
+    type Err = Error;
+
+    fn from_str(value: &str) -> Result<Self, Self::Err> {
+        [
+            TableType::Table,
+            TableType::FormatTable,
+            TableType::MaterializedTable,
+            TableType::ObjectTable,
+            TableType::LanceTable,
+            TableType::IcebergTable,
+        ]
+        .into_iter()
+        .find(|table_type| value.eq_ignore_ascii_case(table_type.as_str()))
+        .ok_or_else(|| Error::Unsupported {
+            message: format!("unknown table type: {value}"),
+        })
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn parses_every_declared_value() {
+        for table_type in [
+            TableType::Table,
+            TableType::FormatTable,
+            TableType::MaterializedTable,
+            TableType::ObjectTable,
+            TableType::LanceTable,
+            TableType::IcebergTable,
+        ] {
+            assert_eq!(
+                TableType::from_str(table_type.as_str()).unwrap(),
+                table_type
+            );
+            assert_eq!(
+                
TableType::from_str(&table_type.as_str().to_uppercase()).unwrap(),
+                table_type
+            );
+        }
+    }
+
+    #[test]
+    fn rejects_unknown_value() {
+        let err = TableType::from_str("delta-table").expect_err("unknown type 
must not parse");
+        assert!(err.to_string().contains("delta-table"), "{err}");
+    }
+
+    #[test]
+    fn types_without_a_paimon_reader_require_an_engine() {
+        for table_type in [
+            TableType::ObjectTable,
+            TableType::LanceTable,
+            TableType::IcebergTable,
+        ] {
+            assert!(table_type.requires_table_engine(), "{table_type}");
+        }
+        for table_type in [
+            TableType::Table,
+            TableType::FormatTable,
+            TableType::MaterializedTable,
+        ] {
+            assert!(!table_type.requires_table_engine(), "{table_type}");
+        }
+    }
+}
diff --git a/crates/paimon/src/table/rest_env.rs 
b/crates/paimon/src/table/rest_env.rs
index b61370b2..7d716cb7 100644
--- a/crates/paimon/src/table/rest_env.rs
+++ b/crates/paimon/src/table/rest_env.rs
@@ -106,11 +106,38 @@ impl RESTEnv {
         data_token_enabled: bool,
         local_cache: Option<Arc<LocalCache>>,
     ) -> Result<Table> {
-        let response = api
-            .get_table(identifier)
+        let response = Self::fetch_table_response(identifier, &api).await?;
+        Self::build_table(
+            identifier,
+            response,
+            api,
+            options,
+            data_token_enabled,
+            local_cache,
+        )
+        .await
+    }
+
+    /// Fetch the raw table metadata, mapping REST errors to catalog errors.
+    pub(crate) async fn fetch_table_response(
+        identifier: &Identifier,
+        api: &RESTApi,
+    ) -> Result<crate::api::GetTableResponse> {
+        api.get_table(identifier)
             .await
-            .map_err(|e| map_rest_error_for_table(e, identifier))?;
+            .map_err(|e| map_rest_error_for_table(e, identifier))
+    }
 
+    /// Build a Table from an already-fetched response, so routing can
+    /// inspect the declared type first.
+    pub(crate) async fn build_table(
+        identifier: &Identifier,
+        response: crate::api::GetTableResponse,
+        api: Arc<RESTApi>,
+        options: Options,
+        data_token_enabled: bool,
+        local_cache: Option<Arc<LocalCache>>,
+    ) -> Result<Table> {
         let schema = response.schema.ok_or_else(|| Error::DataInvalid {
             message: format!("Table {} response missing schema", 
identifier.full_name()),
             source: None,
@@ -128,6 +155,19 @@ impl RESTEnv {
             ),
             source: None,
         })?;
+        // Fail closed: constructed as Paimon, raw `get_table` paths (writes,
+        // procedures, time travel) would misread it.
+        let declared = CoreOptions::new(schema.options()).table_type()?;
+        if declared.requires_table_engine() {
+            return Err(Error::Unsupported {
+                message: format!(
+                    "table '{}' is declared '{declared}' and cannot be read as 
a Paimon \
+                     table; only plain reads through a registered table engine 
are supported",
+                    identifier.full_name()
+                ),
+            });
+        }
+
         let mut table_schema = TableSchema::new(schema_id, &schema);
         if CoreOptions::new(table_schema.options()).is_format_table() {
             table_schema = 
table_schema.copy_with_options(std::collections::HashMap::from([(
diff --git a/crates/paimon/tests/rest_catalog_test.rs 
b/crates/paimon/tests/rest_catalog_test.rs
index 2000f9e4..83da7c8a 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -1501,3 +1501,140 @@ async fn 
test_catalog_maps_unsupported_view_and_function_endpoints() {
         paimon::Error::Unsupported { .. }
     ));
 }
+
+#[tokio::test]
+async fn test_load_table_returns_external_for_declared_type() {
+    use paimon::catalog::LoadedTable;
+    use paimon::spec::TableType;
+
+    let ctx = setup_catalog(vec!["default"]).await;
+    let schema = Schema::builder()
+        .column("id", DataType::BigInt(BigIntType::new()))
+        .option("type", "iceberg-table")
+        .build()
+        .unwrap();
+    ctx.server
+        .add_table_with_schema("default", "ice_t", schema, "file:///unused");
+
+    let identifier = Identifier::new("default", "ice_t");
+    let routed = ctx.catalog.load_table(&identifier).await.unwrap();
+    assert!(
+        matches!(routed, LoadedTable::External(ref e) if e.declared() == 
TableType::IcebergTable),
+        "{routed:?}"
+    );
+}
+
+#[tokio::test]
+async fn test_load_table_fails_closed_on_query_auth() {
+    let ctx = setup_catalog(vec!["default"]).await;
+    let schema = Schema::builder()
+        .column("id", DataType::BigInt(BigIntType::new()))
+        .option("type", "iceberg-table")
+        .option("query-auth.enabled", "true")
+        .build()
+        .unwrap();
+    ctx.server
+        .add_table_with_schema("default", "authed_ice_t", schema, 
"file:///unused");
+
+    let identifier = Identifier::new("default", "authed_ice_t");
+    let err = ctx.catalog.load_table(&identifier).await.unwrap_err();
+    assert!(
+        matches!(err, paimon::Error::Unsupported { ref message } if 
message.contains("query-auth")),
+        "{err:?}"
+    );
+}
+
+#[tokio::test]
+async fn test_load_table_constructs_paimon_for_undeclared_type() {
+    use paimon::catalog::LoadedTable;
+
+    let ctx = setup_catalog(vec!["default"]).await;
+    let schema = Schema::builder()
+        .column("id", DataType::BigInt(BigIntType::new()))
+        .build()
+        .unwrap();
+    ctx.server
+        .add_table_with_schema("default", "plain_t", schema, "file:///unused");
+
+    let identifier = Identifier::new("default", "plain_t");
+    let routed = ctx.catalog.load_table(&identifier).await.unwrap();
+    match routed {
+        LoadedTable::Paimon(table) => {
+            assert_eq!(table.identifier().full_name(), "default.plain_t");
+        }
+        other => panic!("expected a constructed Paimon table, got {other:?}"),
+    }
+}
+
+#[tokio::test]
+async fn test_get_table_fails_closed_on_iceberg_table() {
+    let ctx = setup_catalog(vec!["default"]).await;
+    let schema = Schema::builder()
+        .column("id", DataType::BigInt(BigIntType::new()))
+        .option("type", "iceberg-table")
+        .build()
+        .unwrap();
+    ctx.server
+        .add_table_with_schema("default", "raw_ice_t", schema, 
"file:///unused");
+
+    let err = ctx
+        .catalog
+        .get_table(&Identifier::new("default", "raw_ice_t"))
+        .await
+        .unwrap_err();
+    assert!(
+        matches!(err, paimon::Error::Unsupported { ref message }
+            if message.contains("cannot be read as a Paimon table")),
+        "{err:?}"
+    );
+}
+
+#[tokio::test]
+async fn test_load_table_parses_declared_type_case_insensitively() {
+    use paimon::catalog::LoadedTable;
+    use paimon::spec::TableType;
+
+    let ctx = setup_catalog(vec!["default"]).await;
+    let schema = Schema::builder()
+        .column("id", DataType::BigInt(BigIntType::new()))
+        .option("type", "ICEBERG-TABLE")
+        .build()
+        .unwrap();
+    ctx.server
+        .add_table_with_schema("default", "upper_ice_t", schema, 
"file:///unused");
+
+    let routed = ctx
+        .catalog
+        .load_table(&Identifier::new("default", "upper_ice_t"))
+        .await
+        .unwrap();
+    assert!(
+        matches!(routed, LoadedTable::External(ref e) if e.declared() == 
TableType::IcebergTable),
+        "{routed:?}"
+    );
+}
+
+#[tokio::test]
+async fn test_load_table_rejects_unknown_declared_type() {
+    let ctx = setup_catalog(vec!["default"]).await;
+    let valid = Schema::builder()
+        .column("id", DataType::BigInt(BigIntType::new()))
+        .build()
+        .unwrap();
+    let mut payload = serde_json::to_value(&valid).unwrap();
+    payload["options"] = serde_json::json!({ "type": "delta-table" });
+    let schema: Schema = serde_json::from_value(payload).unwrap();
+    ctx.server
+        .add_table_with_schema("default", "delta_t", schema, "file:///unused");
+
+    let err = ctx
+        .catalog
+        .load_table(&Identifier::new("default", "delta_t"))
+        .await
+        .unwrap_err();
+    assert!(
+        matches!(err, paimon::Error::Unsupported { ref message }
+            if message.contains("unknown table type")),
+        "{err:?}"
+    );
+}

Reply via email to