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 68244878 feat(table): read the registered partitions of a 
catalog-managed format table (#814)
68244878 is described below

commit 6824487813c69b1d4975e1add8c37343b3ead75b
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Fri Sep 11 23:12:58 2026 +0800

    feat(table): read the registered partitions of a catalog-managed format 
table (#814)
---
 Cargo.lock                                         |   1 +
 crates/integrations/datafusion/Cargo.toml          |   1 +
 .../datafusion/tests/rest_format_table_scan.rs     | 446 ++++++++++++++++++
 crates/paimon/src/catalog/rest/rest_catalog.rs     |  29 +-
 crates/paimon/src/spec/core_options.rs             |  40 ++
 crates/paimon/src/spec/mod.rs                      |   4 +-
 crates/paimon/src/spec/partition_utils.rs          |   2 +-
 crates/paimon/src/table/format_partition.rs        | 352 ++++++++++++++
 crates/paimon/src/table/format_table_scan.rs       | 517 ++++++++++++++++-----
 crates/paimon/src/table/mod.rs                     |  65 +++
 crates/paimon/src/table/rest_env.rs                |  37 ++
 crates/paimon/tests/mock_server.rs                 |  47 ++
 crates/paimon/tests/rest_catalog_test.rs           | 367 +++++++++++++++
 13 files changed, 1769 insertions(+), 139 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 11ec61ea..f8374af8 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4745,6 +4745,7 @@ dependencies = [
  "arrow-schema",
  "arrow-select",
  "async-trait",
+ "axum",
  "bytes",
  "chrono",
  "constant_time_eq",
diff --git a/crates/integrations/datafusion/Cargo.toml 
b/crates/integrations/datafusion/Cargo.toml
index 17acaea0..f7072fc3 100644
--- a/crates/integrations/datafusion/Cargo.toml
+++ b/crates/integrations/datafusion/Cargo.toml
@@ -50,6 +50,7 @@ uuid = { version = "1", features = ["v4"] }
 [dev-dependencies]
 arrow-array = { workspace = true }
 arrow-schema = { workspace = true }
+axum = { version = "0.7", features = ["macros", "tokio", "http1", "http2"] }
 bytes = "1.7.1"
 flate2 = "1"
 paimon-ftindex-core = "0.1.0"
diff --git a/crates/integrations/datafusion/tests/rest_format_table_scan.rs 
b/crates/integrations/datafusion/tests/rest_format_table_scan.rs
new file mode 100644
index 00000000..a446147f
--- /dev/null
+++ b/crates/integrations/datafusion/tests/rest_format_table_scan.rs
@@ -0,0 +1,446 @@
+// 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.
+
+//! SQL reads of a Format Table whose partitions a REST catalog manages.
+//!
+//! Partitions are registered through the catalog API, the way any writer 
registers them, so
+//! these tests do not depend on how a partition came to be registered.
+
+#[path = "../../../paimon/tests/mock_server.rs"]
+mod mock_server;
+
+use std::collections::HashMap;
+use std::path::Path;
+use std::sync::Arc;
+
+use arrow_array::{Int64Array, RecordBatch};
+use arrow_schema::{DataType as ArrowDataType, Field, Schema as ArrowSchema};
+use axum::http::StatusCode;
+use paimon::api::ConfigResponse;
+use paimon::catalog::{Catalog, Identifier, RESTCatalog};
+use paimon::spec::{BigIntType, BooleanType, DataType, IntType, Schema, 
VarCharType};
+use paimon::{CatalogOptions, Options};
+use paimon_datafusion::SQLContext;
+use parquet::arrow::ArrowWriter;
+use tempfile::TempDir;
+
+use mock_server::{start_mock_server, RESTServer};
+
+const DATABASE: &str = "default";
+const TABLE: &str = "events";
+const WAREHOUSE: &str = "test_warehouse";
+
+/// A catalog-managed Format Table served by a mock REST catalog, reading its 
data from a
+/// temporary directory.
+struct ManagedTable {
+    server: RESTServer,
+    catalog: Arc<RESTCatalog>,
+    context: SQLContext,
+}
+
+impl ManagedTable {
+    async fn new(temp_dir: &TempDir, partition_columns: &[(&str, DataType)]) 
-> Self {
+        let server = start_mock_server(
+            WAREHOUSE.to_string(),
+            temp_dir.path().to_string_lossy().into_owned(),
+            ConfigResponse::new(HashMap::from([(
+                CatalogOptions::PREFIX.to_string(),
+                "mock-test".to_string(),
+            )])),
+            vec![DATABASE.to_string()],
+        )
+        .await;
+        server.add_table_with_schema(
+            DATABASE,
+            TABLE,
+            format_table_schema(partition_columns),
+            &format!("file://{}", temp_dir.path().display()),
+        );
+        server.set_table_external(DATABASE, TABLE, false);
+
+        let mut options = Options::new();
+        options.set(CatalogOptions::URI, server.url().unwrap());
+        options.set(CatalogOptions::WAREHOUSE, WAREHOUSE);
+        options.set(CatalogOptions::TOKEN_PROVIDER, "bear");
+        options.set(CatalogOptions::TOKEN, "test-token");
+        let catalog = Arc::new(RESTCatalog::new(options, true).await.unwrap());
+        let mut context = SQLContext::new();
+        context
+            .register_catalog("paimon", catalog.clone())
+            .await
+            .unwrap();
+        Self {
+            server,
+            catalog,
+            context,
+        }
+    }
+
+    /// Register partitions through the catalog API.
+    async fn register(&self, partitions: &[&[(&str, &str)]]) {
+        self.catalog
+            .create_partitions(
+                &Identifier::new(DATABASE, TABLE),
+                partitions.iter().map(|values| spec(values)).collect(),
+                true,
+            )
+            .await
+            .unwrap();
+    }
+
+    /// The sorted ids of the rows a filtered SELECT returns.
+    async fn ids(&self, predicate: &str) -> Vec<i64> {
+        let sql = format!("SELECT id FROM paimon.{DATABASE}.{TABLE} WHERE 
{predicate}");
+        let mut ids = Vec::new();
+        for batch in self
+            .context
+            .sql(&sql)
+            .await
+            .unwrap()
+            .collect()
+            .await
+            .unwrap()
+        {
+            let values = batch
+                .column(0)
+                .as_any()
+                .downcast_ref::<Int64Array>()
+                .unwrap();
+            ids.extend(values.iter().flatten());
+        }
+        ids.sort_unstable();
+        ids
+    }
+
+    /// The error a filtered SELECT fails with, whether planning or execution 
reports it.
+    async fn error(&self, predicate: &str) -> String {
+        let sql = format!("SELECT id FROM paimon.{DATABASE}.{TABLE} WHERE 
{predicate}");
+        match self.context.sql(&sql).await {
+            Ok(frame) => frame.collect().await.unwrap_err(),
+            Err(error) => error,
+        }
+        .to_string()
+    }
+
+    /// How many listings the table has received from each endpoint: plain, 
then by filter.
+    fn listing_counts(&self) -> (usize, usize) {
+        (
+            self.server
+                .table_partition_list_name_patterns(DATABASE, TABLE)
+                .len(),
+            self.server
+                .table_partition_list_by_filter_requests(DATABASE, TABLE)
+                .len(),
+        )
+    }
+
+    /// The name patterns of the listings received since `seen`, whichever 
endpoint served them.
+    fn name_patterns_since(&self, seen: (usize, usize)) -> Vec<Option<String>> 
{
+        let mut patterns = self
+            .server
+            .table_partition_list_name_patterns(DATABASE, TABLE)
+            .split_off(seen.0);
+        patterns.extend(
+            self.server
+                .table_partition_list_by_filter_requests(DATABASE, TABLE)
+                .into_iter()
+                .skip(seen.1)
+                .map(|request| request.partition_name_pattern),
+        );
+        patterns
+    }
+}
+
+fn format_table_schema(partition_columns: &[(&str, DataType)]) -> Schema {
+    let partition_keys = partition_columns
+        .iter()
+        .map(|(name, _)| (*name).to_string())
+        .collect::<Vec<_>>();
+    partition_columns
+        .iter()
+        .fold(Schema::builder(), |builder, (name, data_type)| {
+            builder.column(*name, data_type.clone())
+        })
+        .column("id", DataType::BigInt(BigIntType::new()))
+        .partition_keys(partition_keys)
+        .option("type", "format-table")
+        .option("file.format", "parquet")
+        .option("metastore.partitioned-table", "true")
+        .build()
+        .unwrap()
+}
+
+fn varchar() -> DataType {
+    DataType::VarChar(VarCharType::new(255).unwrap())
+}
+
+fn spec(values: &[(&str, &str)]) -> HashMap<String, String> {
+    values
+        .iter()
+        .map(|(key, value)| ((*key).to_string(), (*value).to_string()))
+        .collect()
+}
+
+fn write_ids(directory: &Path, ids: &[i64]) {
+    std::fs::create_dir_all(directory).unwrap();
+    let schema = Arc::new(ArrowSchema::new(vec![Field::new(
+        "id",
+        ArrowDataType::Int64,
+        true,
+    )]));
+    let batch = RecordBatch::try_new(
+        Arc::clone(&schema),
+        vec![Arc::new(Int64Array::from(ids.to_vec()))],
+    )
+    .unwrap();
+    let file = 
std::fs::File::create(directory.join("part-0.parquet")).unwrap();
+    let mut writer = ArrowWriter::try_new(file, schema, None).unwrap();
+    writer.write(&batch).unwrap();
+    writer.close().unwrap();
+}
+
+#[cfg(not(windows))]
+// Planning a SELECT resolves the table on a blocking catalog-access thread, 
so the mock
+// server needs a runtime thread of its own to answer while that one waits.
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_managed_scan_reads_only_registered_partitions() {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let table = ManagedTable::new(&temp_dir, &[("dt", varchar())]).await;
+    for (dt, id) in [("a", 1), ("b", 2), ("c", 3)] {
+        write_ids(&temp_dir.path().join(format!("dt={dt}")), &[id]);
+    }
+    table.register(&[&[("dt", "a")], &[("dt", "c")]]).await;
+
+    // `dt=b` holds a file but nobody registered it, so it is not part of the 
table.
+    assert_eq!(table.ids("TRUE").await, vec![1, 3]);
+    assert!(table.ids("dt = 'b'").await.is_empty());
+
+    table.register(&[&[("dt", "b")]]).await;
+    assert_eq!(table.ids("TRUE").await, vec![1, 2, 3]);
+}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_managed_scan_pushes_a_partition_name_pattern() {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let table = ManagedTable::new(&temp_dir, &[("dt", varchar()), ("hh", 
varchar())]).await;
+    table
+        .register(&[
+            &[("dt", "20260722"), ("hh", "10")],
+            &[("dt", "20260722"), ("hh", "11")],
+            &[("dt", "20260723"), ("hh", "10")],
+        ])
+        .await;
+
+    for (predicate, expected) in [
+        ("dt = '20260722' AND hh = '10'", Some("dt=20260722/hh=10")),
+        ("dt = '20260722'", Some("dt=20260722/%")),
+        (
+            "dt = '20260722' AND hh IN ('10', '11')",
+            Some("dt=20260722/%"),
+        ),
+        // Only a leading run of equalities becomes a prefix pattern.
+        ("hh = '10'", None),
+        ("dt > '20260722'", None),
+    ] {
+        let seen = table.listing_counts();
+        table.ids(predicate).await;
+        let pushed = table.name_patterns_since(seen);
+        assert!(!pushed.is_empty(), "{predicate} listed no partitions");
+        assert!(
+            pushed.iter().all(|pattern| pattern.as_deref() == expected),
+            "{predicate} pushed {pushed:?}, expected {expected:?}"
+        );
+    }
+}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_managed_scan_sends_its_partition_predicate_as_a_filter() {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let table = ManagedTable::new(&temp_dir, &[("dt", varchar()), ("hh", 
varchar())]).await;
+    for (dt, hh, id) in [
+        ("20260722", "10", 1),
+        ("20260722", "11", 2),
+        ("20260723", "10", 3),
+    ] {
+        write_ids(&temp_dir.path().join(format!("dt={dt}/hh={hh}")), &[id]);
+    }
+    table
+        .register(&[
+            &[("dt", "20260722"), ("hh", "10")],
+            &[("dt", "20260722"), ("hh", "11")],
+            &[("dt", "20260723"), ("hh", "10")],
+        ])
+        .await;
+
+    // No leading equality, so only the filter can narrow what the catalog 
returns.
+    assert_eq!(table.ids("hh = '10'").await, vec![1, 3]);
+    let requests = table
+        .server
+        .table_partition_list_by_filter_requests(DATABASE, TABLE);
+    let request = requests.last().expect("the scan should list by filter");
+    assert_eq!(request.partition_name_pattern, None);
+    assert_eq!(request.max_results, Some(1000));
+    let filter: serde_json::Value = 
serde_json::from_str(&request.filter).unwrap();
+    assert_eq!(filter["function"], "EQUAL");
+    assert_eq!(filter["transform"]["fieldRef"]["name"], "hh");
+    assert_eq!(filter["transform"]["fieldRef"]["index"], 1);
+    assert_eq!(filter["literals"], serde_json::json!(["10"]));
+
+    // A catalog that cannot list by filter is still asked, by pattern; the 
partition set never
+    // comes from the directory tree.
+    table
+        .server
+        
.set_list_partitions_by_filter_error_status(Some(StatusCode::NOT_IMPLEMENTED));
+    let listed = table
+        .server
+        .table_partition_list_name_patterns(DATABASE, TABLE)
+        .len();
+    assert_eq!(table.ids("dt = '20260722' AND hh > '10'").await, vec![2]);
+    assert_eq!(
+        table
+            .server
+            .table_partition_list_name_patterns(DATABASE, TABLE)[listed..],
+        [Some("dt=20260722/%".to_string())]
+    );
+}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_managed_scan_keeps_registrations_spelled_unlike_the_filter() {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let table = ManagedTable::new(
+        &temp_dir,
+        &[
+            ("month", DataType::Int(IntType::new())),
+            ("active", DataType::Boolean(BooleanType::new())),
+        ],
+    )
+    .await;
+    // Another engine, or a repair that keeps directory values as they are, 
can register
+    // spellings a typed literal never formats to.
+    write_ids(&temp_dir.path().join("month=01/active=TRUE"), &[1]);
+    write_ids(&temp_dir.path().join("month=2/active=false"), &[2]);
+    table
+        .register(&[
+            &[("month", "01"), ("active", "TRUE")],
+            &[("month", "2"), ("active", "false")],
+        ])
+        .await;
+
+    for (predicate, expected) in [
+        ("month = 1", vec![1]),
+        ("month = 1 AND active = true", vec![1]),
+        ("month = 2 AND active = false", vec![2]),
+    ] {
+        assert_eq!(table.ids(predicate).await, expected, "{predicate}");
+    }
+    // A pattern built from `month = 1` would have dropped `month=01` on the 
catalog side.
+    assert!(table
+        .name_patterns_since((0, 0))
+        .iter()
+        .all(Option::is_none));
+}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn 
test_managed_scan_filter_on_a_partition_column_reads_the_registered_value() {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let table = ManagedTable::new(
+        &temp_dir,
+        &[
+            ("dt", varchar()),
+            ("active", DataType::Boolean(BooleanType::new())),
+        ],
+    )
+    .await;
+    for (dt, active, id) in [("a", "true", 1), ("b", "false", 2)] {
+        write_ids(
+            &temp_dir.path().join(format!("dt={dt}/active={active}")),
+            &[id],
+        );
+    }
+    table
+        .register(&[
+            &[("dt", "a"), ("active", "true")],
+            &[("dt", "b"), ("active", "false")],
+        ])
+        .await;
+
+    // The data files hold no partition columns. A filter the scan cannot turn 
into a partition
+    // predicate still has to see the partition's value, not a missing column.
+    for (predicate, expected) in [
+        ("active", vec![1]),
+        ("NOT active", vec![2]),
+        ("upper(dt) = 'B'", vec![2]),
+        ("concat(dt, '-') = 'a-'", vec![1]),
+    ] {
+        assert_eq!(table.ids(predicate).await, expected, "{predicate}");
+    }
+}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn test_managed_scan_refuses_a_partition_at_a_custom_location() {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let external_dir = tempfile::tempdir().unwrap();
+    let table = ManagedTable::new(&temp_dir, &[("dt", varchar())]).await;
+    table.register(&[&[("dt", "a")], &[("dt", "b")]]).await;
+    write_ids(&temp_dir.path().join("dt=a"), &[1]);
+    // Another engine registered dt=b somewhere else; the table directory 
still has a stale copy.
+    write_ids(&temp_dir.path().join("dt=b"), &[2]);
+    write_ids(external_dir.path(), &[3]);
+    table.server.set_table_partition_options(
+        DATABASE,
+        TABLE,
+        &spec(&[("dt", "b")]),
+        HashMap::from([(
+            "path".to_string(),
+            format!("file://{}", external_dir.path().display()),
+        )]),
+    );
+
+    // Reading the default directory would return the stale row, so a scan 
that reaches the
+    // partition fails instead. One that does not reach it is unaffected.
+    for predicate in ["dt = 'b'", "TRUE"] {
+        let error = table.error(predicate).await;
+        assert!(error.contains("custom location"), "{predicate}: {error}");
+    }
+    assert_eq!(table.ids("dt = 'a'").await, vec![1]);
+}
+
+#[cfg(not(windows))]
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn 
test_managed_scan_fails_rather_than_reading_directories_when_listing_fails() {
+    let temp_dir = tempfile::tempdir().unwrap();
+    let table = ManagedTable::new(&temp_dir, &[("dt", varchar())]).await;
+    write_ids(&temp_dir.path().join("dt=a"), &[1]);
+    table.register(&[&[("dt", "a")]]).await;
+    assert_eq!(table.ids("TRUE").await, vec![1]);
+
+    table
+        .server
+        .set_list_partitions_error_status(Some(StatusCode::NOT_IMPLEMENTED));
+
+    // The directory still holds the row, but only the catalog says which 
partitions exist.
+    let error = table.error("TRUE").await;
+    assert!(
+        error.to_ascii_lowercase().contains("not implemented"),
+        "{error}"
+    );
+}
diff --git a/crates/paimon/src/catalog/rest/rest_catalog.rs 
b/crates/paimon/src/catalog/rest/rest_catalog.rs
index df068b2b..b4b9a510 100644
--- a/crates/paimon/src/catalog/rest/rest_catalog.rs
+++ b/crates/paimon/src/catalog/rest/rest_catalog.rs
@@ -468,8 +468,9 @@ impl Catalog for RESTCatalog {
                 .await
             {
                 Ok(found) => partitions.extend(found),
-                // A catalog without the lookup still answers the plain 
listing, which
-                // `list_partitions` falls back from the same way.
+                // A catalog without the lookup still answers the plain 
listing. That listing
+                // never stands in directories for the registrations of a 
table whose catalog
+                // manages its partitions, so neither does this lookup.
                 Err(Error::RestApi {
                     source: RestError::NotImplemented { .. },
                 }) => {
@@ -489,10 +490,17 @@ impl Catalog for RESTCatalog {
     async fn list_partitions(&self, identifier: &Identifier) -> 
Result<Vec<Partition>> {
         match self.api.list_partitions(identifier).await {
             Ok(parts) => Ok(parts),
-            Err(Error::RestApi {
-                source: RestError::NotImplemented { .. },
-            }) => {
+            Err(
+                error @ Error::RestApi {
+                    source: RestError::NotImplemented { .. },
+                },
+            ) => {
                 let table = self.get_table(identifier).await?;
+                // The registrations of a catalog-managed table are its 
partitions; the
+                // directories under it are not a substitute for them.
+                if table.has_catalog_managed_partitions() {
+                    return Err(error);
+                }
                 list_partitions_from_file_system(&table).await
             }
             Err(e) => Err(map_rest_error_for_table(e, identifier)),
@@ -511,10 +519,15 @@ impl Catalog for RESTCatalog {
             .await
         {
             Ok(page) => Ok(page),
-            Err(Error::RestApi {
-                source: RestError::NotImplemented { .. },
-            }) => {
+            Err(
+                error @ Error::RestApi {
+                    source: RestError::NotImplemented { .. },
+                },
+            ) => {
                 let table = self.get_table(identifier).await?;
+                if table.has_catalog_managed_partitions() {
+                    return Err(error);
+                }
                 let parts = list_partitions_from_file_system(&table).await?;
                 Ok(PagedList::new(parts, None))
             }
diff --git a/crates/paimon/src/spec/core_options.rs 
b/crates/paimon/src/spec/core_options.rs
index 967441cd..d3114fd4 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -44,6 +44,8 @@ const PARTITION_DEFAULT_NAME_OPTION: &str = 
"partition.default-name";
 const PARTITION_LEGACY_NAME_OPTION: &str = "partition.legacy-name";
 const FORMAT_TABLE_PARTITION_PATH_ONLY_VALUE_OPTION: &str =
     "format-table.partition-path-only-value";
+const METASTORE_PARTITIONED_TABLE_OPTION: &str = "metastore.partitioned-table";
+const FORMAT_TABLE_IMPLEMENTATION_OPTION: &str = "format-table.implementation";
 const FORMAT_TABLE_SCAN_LIST_PARALLELISM_OPTION: &str = 
"format-table.scan.list-parallelism";
 const DEFAULT_FORMAT_TABLE_SCAN_LIST_PARALLELISM: usize = 64;
 const MAX_FORMAT_TABLE_SCAN_LIST_PARALLELISM: i64 = 1000;
@@ -704,6 +706,22 @@ impl<'a> CoreOptions<'a> {
             .unwrap_or(false)
     }
 
+    /// Whether the catalog manages this Format Table's partitions 
(`metastore.partitioned-table`).
+    pub(crate) fn partitioned_table_in_metastore(&self) -> bool {
+        self.options
+            .get(METASTORE_PARTITIONED_TABLE_OPTION)
+            .map(|value| value.eq_ignore_ascii_case("true"))
+            .unwrap_or(false)
+    }
+
+    /// Whether the engine's own file source reads this Format Table instead 
of Paimon's reader
+    /// (`format-table.implementation=engine`).
+    pub(crate) fn format_table_implementation_is_engine(&self) -> bool {
+        self.options
+            .get(FORMAT_TABLE_IMPLEMENTATION_OPTION)
+            .is_some_and(|value| value.eq_ignore_ascii_case("engine"))
+    }
+
     pub fn global_index_enabled(&self) -> bool {
         self.options
             .get(GLOBAL_INDEX_ENABLED_OPTION)
@@ -2275,6 +2293,28 @@ mod tests {
         assert!(core.format_table_partition_only_value_in_path());
     }
 
+    #[test]
+    fn test_catalog_managed_format_table_options() {
+        let empty = HashMap::new();
+        let core = CoreOptions::new(&empty);
+        assert!(!core.partitioned_table_in_metastore());
+        assert!(!core.format_table_implementation_is_engine());
+
+        let options = HashMap::from([
+            (
+                METASTORE_PARTITIONED_TABLE_OPTION.to_string(),
+                "TrUe".to_string(),
+            ),
+            (
+                FORMAT_TABLE_IMPLEMENTATION_OPTION.to_string(),
+                "ENGINE".to_string(),
+            ),
+        ]);
+        let core = CoreOptions::new(&options);
+        assert!(core.partitioned_table_in_metastore());
+        assert!(core.format_table_implementation_is_engine());
+    }
+
     #[test]
     fn test_format_table_scan_list_parallelism() {
         let parallelism = |value: Option<&str>| {
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index 8eac9957..efc0634e 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -98,7 +98,9 @@ pub use types::*;
 mod partition;
 pub use partition::Partition;
 mod partition_utils;
-pub(crate) use partition_utils::{bucket_path, bucket_path_under, 
PartitionComputer};
+pub(crate) use partition_utils::{
+    bucket_path, bucket_path_under, escape_path_name, PartitionComputer,
+};
 mod predicate;
 pub(crate) use predicate::datum_cmp;
 pub(crate) use predicate::eval_row;
diff --git a/crates/paimon/src/spec/partition_utils.rs 
b/crates/paimon/src/spec/partition_utils.rs
index e4245c31..463ba653 100644
--- a/crates/paimon/src/spec/partition_utils.rs
+++ b/crates/paimon/src/spec/partition_utils.rs
@@ -498,7 +498,7 @@ fn format_timestamp_non_legacy(dt: NaiveDateTime, 
precision: u32) -> String {
 /// Escape a path component following Java `PartitionPathUtils.escapePathName`.
 ///
 /// Characters that need escaping are encoded as `%XX` (uppercase hex).
-fn escape_path_name(path: &str) -> String {
+pub(crate) fn escape_path_name(path: &str) -> String {
     if !path.chars().any(needs_escaping) {
         return path.to_string();
     }
diff --git a/crates/paimon/src/table/format_partition.rs 
b/crates/paimon/src/table/format_partition.rs
new file mode 100644
index 00000000..90133d3b
--- /dev/null
+++ b/crates/paimon/src/table/format_partition.rs
@@ -0,0 +1,352 @@
+// 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.
+
+//! Format Table partition names, paths and values, shared by the scan and the 
catalog
+//! registrations it reads.
+
+use std::collections::HashMap;
+
+use chrono::NaiveDate;
+
+use crate::spec::{escape_path_name, DataType, Datum};
+
+const UNIX_EPOCH_DAYS_FROM_CE: i32 = 719_163;
+
+/// Generates canonical names and physical paths for Format Table partitions.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) struct FormatTablePartitionPaths {
+    partition_keys: Vec<String>,
+    only_value_in_path: bool,
+}
+
+impl FormatTablePartitionPaths {
+    /// Create a helper for the declared partition-key order and physical 
layout.
+    pub(crate) fn new<I, S>(partition_keys: I, only_value_in_path: bool) -> 
Self
+    where
+        I: IntoIterator<Item = S>,
+        S: Into<String>,
+    {
+        Self {
+            partition_keys: 
partition_keys.into_iter().map(Into::into).collect(),
+            only_value_in_path,
+        }
+    }
+
+    /// Return the canonical logical partition name (`key=value/...`).
+    pub(crate) fn partition_name(&self, spec: &HashMap<String, String>) -> 
crate::Result<String> {
+        let values = self.ordered_values(spec)?;
+        Ok(self
+            .partition_keys
+            .iter()
+            .zip(values)
+            .map(|(key, value)| format!("{}={}", escape_path_name(key), 
escape_path_name(value)))
+            .collect::<Vec<_>>()
+            .join("/"))
+    }
+
+    /// Build the partition-name pattern that selects the partitions whose 
leading values
+    /// are `leading_values`, to push down to a partition-managing catalog.
+    ///
+    /// Pattern contract, shared by every engine talking to the catalog: 
partition names are
+    /// the escaped `key=value` form joined by `/`, `%` is the only wildcard 
and has no
+    /// escape sequence, and `_` stays literal. Values covering every 
partition key give the
+    /// one exact name; a shorter prefix is suffixed with `%`.
+    ///
+    /// `None` means pushdown has to be skipped and the caller must list every 
partition:
+    /// there are no leading values, one of them is blank, or escaping 
produced a literal
+    /// `%` that the contract cannot express.
+    ///
+    /// Mirrors Java `PartitionPathUtils.buildPartitionNamePrefixPattern`.
+    pub(crate) fn name_prefix_pattern(&self, leading_values: &[String]) -> 
Option<String> {
+        if leading_values.is_empty() || leading_values.len() > 
self.partition_keys.len() {
+            return None;
+        }
+        let mut segments = Vec::with_capacity(leading_values.len());
+        for (key, value) in self.partition_keys.iter().zip(leading_values) {
+            if value.trim().is_empty() {
+                return None;
+            }
+            segments.push(format!(
+                "{}={}",
+                escape_path_name(key),
+                escape_path_name(value)
+            ));
+        }
+        let pattern = segments.join("/");
+        if pattern.contains('%') {
+            return None;
+        }
+        if leading_values.len() == self.partition_keys.len() {
+            Some(pattern)
+        } else {
+            Some(format!("{pattern}/%"))
+        }
+    }
+
+    /// Return the physical partition path relative to the table location.
+    pub(crate) fn relative_path(&self, spec: &HashMap<String, String>) -> 
crate::Result<String> {
+        if !self.only_value_in_path {
+            return self.partition_name(spec);
+        }
+        Ok(self
+            .ordered_values(spec)?
+            .into_iter()
+            .map(escape_path_name)
+            .collect::<Vec<_>>()
+            .join("/"))
+    }
+
+    fn ordered_values<'a>(&self, spec: &'a HashMap<String, String>) -> 
crate::Result<Vec<&'a str>> {
+        if spec.len() != self.partition_keys.len() {
+            return Err(crate::Error::DataInvalid {
+                message: self.invalid_partition_keys_message(spec),
+                source: None,
+            });
+        }
+
+        let mut values = Vec::with_capacity(self.partition_keys.len());
+        for key in &self.partition_keys {
+            let Some(value) = spec.get(key).map(String::as_str) else {
+                return Err(crate::Error::DataInvalid {
+                    message: self.invalid_partition_keys_message(spec),
+                    source: None,
+                });
+            };
+            if value.is_empty() || (self.only_value_in_path && matches!(value, 
"." | "..")) {
+                return Err(crate::Error::DataInvalid {
+                    message: format!(
+                        "Partition value {value:?} cannot be used as a 
partition path component"
+                    ),
+                    source: None,
+                });
+            }
+            values.push(value);
+        }
+        Ok(values)
+    }
+
+    fn invalid_partition_keys_message(&self, spec: &HashMap<String, String>) 
-> String {
+        let mut actual_keys = spec.keys().collect::<Vec<_>>();
+        actual_keys.sort();
+        format!(
+            "Partition spec must contain exactly keys {:?}, but contains 
{actual_keys:?}",
+            self.partition_keys
+        )
+    }
+}
+
+/// Parse a raw Format Table partition value from a path or catalog 
registration.
+pub(crate) fn parse_format_partition_value(value: &str, data_type: &DataType) 
-> Option<Datum> {
+    match data_type {
+        DataType::Boolean(_) => parse_partition_bool(value).map(Datum::Bool),
+        DataType::TinyInt(_) => value.parse::<i8>().ok().map(Datum::TinyInt),
+        DataType::SmallInt(_) => 
value.parse::<i16>().ok().map(Datum::SmallInt),
+        DataType::Int(_) => value.parse::<i32>().ok().map(Datum::Int),
+        DataType::BigInt(_) => value.parse::<i64>().ok().map(Datum::Long),
+        DataType::Char(_) | DataType::VarChar(_) => 
Some(Datum::String(value.to_string())),
+        DataType::Date(_) => parse_partition_date(value).map(Datum::Date),
+        DataType::Time(_) => value.parse::<i32>().ok().map(Datum::Time),
+        _ => None,
+    }
+}
+
+/// Format a typed value for Format Table partition metadata and paths.
+pub(crate) fn format_partition_value(
+    datum: &Datum,
+    data_type: &DataType,
+    default_partition_name: &str,
+    legacy_partition_name: bool,
+) -> Option<String> {
+    match (datum, data_type) {
+        (Datum::Bool(value), DataType::Boolean(_)) => Some(value.to_string()),
+        (Datum::TinyInt(value), DataType::TinyInt(_)) => 
Some(value.to_string()),
+        (Datum::SmallInt(value), DataType::SmallInt(_)) => 
Some(value.to_string()),
+        (Datum::Int(value), DataType::Int(_)) => Some(value.to_string()),
+        (Datum::Long(value), DataType::BigInt(_)) => Some(value.to_string()),
+        (Datum::String(value), DataType::Char(_) | DataType::VarChar(_)) => {
+            if value.trim().is_empty() {
+                Some(default_partition_name.to_string())
+            } else {
+                Some(value.clone())
+            }
+        }
+        (Datum::Date(value), DataType::Date(_)) => {
+            if legacy_partition_name {
+                Some(value.to_string())
+            } else {
+                format_partition_date(*value)
+            }
+        }
+        (Datum::Time(value), DataType::Time(_)) => Some(value.to_string()),
+        _ => None,
+    }
+}
+
+/// Accept the boolean spellings Java accepts, so a partition another engine 
registered as
+/// `TRUE`, `t`, `yes` or `1` reads back here instead of failing as invalid 
metadata.
+///
+/// Mirrors Java `BinaryStringUtils.toBoolean`.
+fn parse_partition_bool(value: &str) -> Option<bool> {
+    const TRUE_VALUES: [&str; 5] = ["t", "true", "y", "yes", "1"];
+    const FALSE_VALUES: [&str; 5] = ["f", "false", "n", "no", "0"];
+    if TRUE_VALUES
+        .iter()
+        .any(|candidate| value.eq_ignore_ascii_case(candidate))
+    {
+        return Some(true);
+    }
+    if FALSE_VALUES
+        .iter()
+        .any(|candidate| value.eq_ignore_ascii_case(candidate))
+    {
+        return Some(false);
+    }
+    None
+}
+
+fn parse_partition_date(value: &str) -> Option<i32> {
+    if let Ok(epoch_days) = value.parse::<i32>() {
+        return Some(epoch_days);
+    }
+    let date = NaiveDate::parse_from_str(value, "%Y-%m-%d").ok()?;
+    let epoch = NaiveDate::from_num_days_from_ce_opt(UNIX_EPOCH_DAYS_FROM_CE)?;
+    date.signed_duration_since(epoch).num_days().try_into().ok()
+}
+
+fn format_partition_date(epoch_days: i32) -> Option<String> {
+    
NaiveDate::from_num_days_from_ce_opt(epoch_days.checked_add(UNIX_EPOCH_DAYS_FROM_CE)?)
+        .map(|date| date.format("%Y-%m-%d").to_string())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::spec::{BooleanType, DateType};
+
+    #[test]
+    fn test_parse_format_partition_value() {
+        assert_eq!(
+            parse_format_partition_value("true", 
&DataType::Boolean(BooleanType::new())),
+            Some(Datum::Bool(true))
+        );
+        assert_eq!(
+            parse_format_partition_value("2026-07-22", 
&DataType::Date(DateType::new())),
+            Some(Datum::Date(20_656))
+        );
+        assert_eq!(
+            parse_format_partition_value("20656", 
&DataType::Date(DateType::new())),
+            Some(Datum::Date(20_656))
+        );
+    }
+
+    #[test]
+    fn test_parse_format_partition_bool_accepts_every_java_spelling() {
+        let boolean = DataType::Boolean(BooleanType::new());
+        for value in ["t", "T", "true", "TRUE", "True", "y", "YES", "1"] {
+            assert_eq!(
+                parse_format_partition_value(value, &boolean),
+                Some(Datum::Bool(true)),
+                "{value} should read as true"
+            );
+        }
+        for value in ["f", "F", "false", "FALSE", "False", "n", "NO", "0"] {
+            assert_eq!(
+                parse_format_partition_value(value, &boolean),
+                Some(Datum::Bool(false)),
+                "{value} should read as false"
+            );
+        }
+        for value in ["", "2", "tru", "yes please", "null"] {
+            assert_eq!(
+                parse_format_partition_value(value, &boolean),
+                None,
+                "{value} is not a boolean"
+            );
+        }
+    }
+
+    #[test]
+    fn test_partition_paths_escape_values_and_honor_layout() {
+        let spec = HashMap::from([
+            ("dt".to_string(), "2026/07=22".to_string()),
+            ("hour".to_string(), "10".to_string()),
+        ]);
+
+        let keyed = FormatTablePartitionPaths::new(["dt", "hour"], false);
+        assert_eq!(
+            keyed.partition_name(&spec).unwrap(),
+            "dt=2026%2F07%3D22/hour=10"
+        );
+        assert_eq!(
+            keyed.relative_path(&spec).unwrap(),
+            "dt=2026%2F07%3D22/hour=10"
+        );
+
+        let value_only = FormatTablePartitionPaths::new(["dt", "hour"], true);
+        assert_eq!(
+            value_only.partition_name(&spec).unwrap(),
+            "dt=2026%2F07%3D22/hour=10"
+        );
+        assert_eq!(
+            value_only.relative_path(&spec).unwrap(),
+            "2026%2F07%3D22/10"
+        );
+
+        // A spec that does not name exactly the partition keys has no path.
+        let missing_hour = HashMap::from([("dt".to_string(), 
"a".to_string())]);
+        assert!(keyed.relative_path(&missing_hour).is_err());
+        let traversal = HashMap::from([
+            ("dt".to_string(), "..".to_string()),
+            ("hour".to_string(), "10".to_string()),
+        ]);
+        assert!(value_only.relative_path(&traversal).is_err());
+    }
+
+    #[test]
+    fn test_name_prefix_pattern() {
+        let paths = FormatTablePartitionPaths::new(["dt".to_string(), 
"hh".to_string()], false);
+
+        // A complete prefix names one partition, a shorter one takes the 
wildcard.
+        assert_eq!(
+            paths.name_prefix_pattern(&["20260722".to_string(), 
"10".to_string()]),
+            Some("dt=20260722/hh=10".to_string())
+        );
+        assert_eq!(
+            paths.name_prefix_pattern(&["20260722".to_string()]),
+            Some("dt=20260722/%".to_string())
+        );
+
+        // The physical layout never changes the pattern: catalog names are 
always key=value.
+        let value_only = FormatTablePartitionPaths::new(["dt".to_string(), 
"hh".to_string()], true);
+        assert_eq!(
+            value_only.name_prefix_pattern(&["20260722".to_string()]),
+            Some("dt=20260722/%".to_string())
+        );
+
+        // Nothing to push down.
+        assert_eq!(paths.name_prefix_pattern(&[]), None);
+        assert_eq!(paths.name_prefix_pattern(&["  ".to_string()]), None);
+        assert_eq!(
+            paths.name_prefix_pattern(&["a".to_string(), "b".to_string(), 
"c".to_string()]),
+            None
+        );
+
+        // Escaping a value would inject the pattern's only wildcard, so 
pushdown is skipped
+        // rather than silently widened.
+        assert_eq!(paths.name_prefix_pattern(&["2026/07".to_string()]), None);
+    }
+}
diff --git a/crates/paimon/src/table/format_table_scan.rs 
b/crates/paimon/src/table/format_table_scan.rs
index 569ac071..aabadcec 100644
--- a/crates/paimon/src/table/format_table_scan.rs
+++ b/crates/paimon/src/table/format_table_scan.rs
@@ -17,15 +17,21 @@
 
 //! Scan implementation for Java-compatible `type=format-table` metadata.
 
-use super::{Plan, ScanTrace, Table};
+use std::collections::{HashMap, HashSet};
+
+use super::format_partition::{
+    format_partition_value, parse_format_partition_value, 
FormatTablePartitionPaths,
+};
+use super::{Plan, RESTEnv, ScanTrace, Table};
+use crate::api::RestError;
 use crate::spec::stats::BinaryTableStats;
 use crate::spec::{
-    extract_datum, BinaryRow, BinaryRowBuilder, CoreOptions, DataField, 
DataFileMeta, DataType,
-    Datum, PartitionComputer, Predicate, PredicateOperator,
+    escape_path_name, extract_datum, BinaryRow, BinaryRowBuilder, CoreOptions, 
DataField,
+    DataFileMeta, DataType, Datum, Partition, PartitionComputer, Predicate, 
PredicateOperator,
+    PATH_OPTION,
 };
 use crate::table::partition_filter::PartitionFilter;
 use crate::table::source::{DataSplitBuilder, RowRange};
-use chrono::NaiveDate;
 use futures::{StreamExt, TryStreamExt};
 
 #[derive(Debug, Clone)]
@@ -90,7 +96,7 @@ impl<'a> FormatTableScan<'a> {
 
         let partition_fields = self.table.schema().partition_fields();
         let table_depth = path_segments(&table_path).len();
-        let scan_roots = self.scan_roots(&core_options, &table_path)?;
+        let scan_roots = self.scan_roots(&core_options, &table_path).await?;
         // A table with many partitions pays one listing per partition, so 
they run concurrently.
         // `buffered` keeps the roots in order and stops at the first failure.
         let table_path = table_path.as_str();
@@ -149,7 +155,7 @@ impl<'a> FormatTableScan<'a> {
         Ok(Plan::new(splits))
     }
 
-    fn scan_roots(
+    async fn scan_roots(
         &self,
         core_options: &CoreOptions<'_>,
         table_path: &str,
@@ -162,6 +168,15 @@ impl<'a> FormatTableScan<'a> {
                 partition: BinaryRow::new(0),
             }]);
         }
+        if let Some(rest_env) = self
+            .table
+            .rest_env()
+            .filter(|_| self.table.has_catalog_managed_partitions())
+        {
+            return self
+                .catalog_managed_scan_roots(rest_env, table_path, 
partition_keys, &partition_fields)
+                .await;
+        }
 
         let Some(PartitionFilter::PartitionSet { partitions, .. }) = 
&self.partition_filter else {
             if let Some(PartitionFilter::Predicate(predicate)) = 
&self.partition_filter {
@@ -216,6 +231,138 @@ impl<'a> FormatTableScan<'a> {
         Ok(roots)
     }
 
+    /// One scan root per registered partition this scan reaches, never one 
discovered from the
+    /// directory tree.
+    async fn catalog_managed_scan_roots(
+        &self,
+        rest_env: &RESTEnv,
+        table_path: &str,
+        partition_keys: &[String],
+        partition_fields: &[DataField],
+    ) -> crate::Result<Vec<ScanRoot>> {
+        let core_options = CoreOptions::new(self.table.schema().options());
+        let partition_paths = FormatTablePartitionPaths::new(
+            partition_keys.iter().cloned(),
+            core_options.format_table_partition_only_value_in_path(),
+        );
+        let default_partition_name = core_options.partition_default_name();
+        // Ask the catalog only for the partitions the filter can reach. 
Downloading every
+        // registration of a table with many partitions is what dominates 
planning time,
+        // and the local match below still decides what is actually scanned.
+        let pattern = match &self.partition_filter {
+            Some(filter) => {
+                let mut leading_values = leading_equality_partition_values(
+                    filter,
+                    partition_fields,
+                    default_partition_name,
+                    core_options.legacy_partition_name(),
+                )?;
+                // A name pattern compares spellings, so it can only stand in 
for an equality on a
+                // column whose values have one. Another engine may register 
`month=01` or
+                // `active=TRUE`, which would fall out of a pattern built from 
`month = 1` or
+                // `active = true` before the typed match below ever saw them.
+                let single_spelling = partition_fields
+                    .iter()
+                    .take_while(|field| {
+                        matches!(field.data_type(), DataType::Char(_) | 
DataType::VarChar(_))
+                    })
+                    .count();
+                leading_values.truncate(single_spelling);
+                partition_paths.name_prefix_pattern(&leading_values)
+            }
+            None => None,
+        };
+        let filter = match &self.partition_filter {
+            Some(PartitionFilter::Predicate(predicate)) => {
+                partition_filter_json(predicate).map(|filter| 
filter.to_string())
+            }
+            _ => None,
+        };
+        let partitions = self
+            .list_catalog_partitions(rest_env, pattern.as_deref(), 
filter.as_deref())
+            .await?;
+        let mut seen_paths = HashSet::with_capacity(partitions.len());
+        let mut roots = Vec::with_capacity(partitions.len());
+        for partition in partitions {
+            let partition_path = partition_paths
+                .relative_path(&partition.spec)
+                .map_err(|error| 
self.invalid_catalog_partition_metadata(error))?;
+            if !seen_paths.insert(partition_path.clone()) {
+                continue;
+            }
+            let path = join_path(table_path, &partition_path);
+            let row = partition_row_from_catalog_spec(
+                &partition.spec,
+                partition_fields,
+                partition_keys,
+                default_partition_name,
+            )
+            .map_err(|error| self.invalid_catalog_partition_metadata(error))?;
+            if !self.partition_matches(&row)? {
+                continue;
+            }
+            // The Rust reader cannot resolve a partition's own location yet, 
and reading the
+            // default directory in its place would return whatever happens to 
be there.
+            if partition
+                .options
+                .as_ref()
+                .is_some_and(|options| options.contains_key(PATH_OPTION))
+            {
+                return Err(crate::Error::Unsupported {
+                    message: format!(
+                        "Partition {:?} of Format Table {} is registered at a 
custom location, \
+                         which the Rust reader does not support yet",
+                        partition.spec,
+                        self.table.identifier().full_name()
+                    ),
+                });
+            }
+            roots.push(ScanRoot {
+                path,
+                partition: row,
+            });
+        }
+        roots.sort_by(|left, right| left.path.cmp(&right.path));
+        Ok(roots)
+    }
+
+    /// The registered partitions the catalog returns for this scan's pushdown 
hints.
+    ///
+    /// A catalog that cannot list by filter is still asked by pattern, so the 
catalog stays the
+    /// source of the partition set; the typed match on the result decides 
what is scanned.
+    async fn list_catalog_partitions(
+        &self,
+        rest_env: &RESTEnv,
+        pattern: Option<&str>,
+        filter: Option<&str>,
+    ) -> crate::Result<Vec<Partition>> {
+        let api = rest_env.api();
+        let identifier = rest_env.identifier();
+        if let Some(filter) = filter {
+            match api
+                .list_partitions_by_filter(identifier, filter, pattern)
+                .await
+            {
+                Err(crate::Error::RestApi {
+                    source: RestError::NotImplemented { .. },
+                }) => {}
+                result => return result,
+            }
+        }
+        api.list_partitions_by_name_pattern(identifier, pattern)
+            .await
+    }
+
+    fn invalid_catalog_partition_metadata(&self, source: crate::Error) -> 
crate::Error {
+        crate::Error::DataInvalid {
+            message: format!(
+                "Catalog returned invalid partition metadata for Format Table 
{}",
+                self.table.identifier().full_name()
+            ),
+            source: Some(Box::new(source)),
+        }
+    }
+
     async fn list_status_recursive_if_exists(
         &self,
         path: &str,
@@ -398,8 +545,122 @@ fn leading_equality_partition_path(
     legacy_partition_name: bool,
     only_value_in_path: bool,
 ) -> Option<String> {
+    let values = leading_equality_values_from_predicate(
+        predicate,
+        partition_fields,
+        default_partition_name,
+        legacy_partition_name,
+    );
+    if values.is_empty() {
+        return None;
+    }
+    let segments = partition_keys
+        .iter()
+        .zip(&values)
+        .map(|(key, value)| {
+            if only_value_in_path {
+                escape_path_name(value)
+            } else {
+                format!("{}={}", escape_path_name(key), 
escape_path_name(value))
+            }
+        })
+        .collect::<Vec<_>>();
+    Some(join_path(table_path, &segments.join("/")))
+}
+
+/// The part of a partition predicate that can be sent to the catalog as a 
filter.
+///
+/// An AND keeps the children that have a wire form, since leaving out a 
conjunct only widens
+/// what the catalog may return; anything else is sent whole or not at all. 
Nothing is sent for a
+/// predicate that selects everything.
+///
+/// Mirrors Java `FormatTableScan.extractPartitionPredicate`.
+fn partition_filter_json(predicate: &Predicate) -> Option<serde_json::Value> {
+    match predicate {
+        Predicate::AlwaysTrue => None,
+        Predicate::And(children) => {
+            let mut pushed = children
+                .iter()
+                .filter(|child| child.to_rest_json().is_some())
+                .cloned()
+                .collect::<Vec<_>>();
+            match pushed.len() {
+                0 => None,
+                1 => pushed.pop().and_then(|child| child.to_rest_json()),
+                _ => Predicate::And(pushed).to_rest_json(),
+            }
+        }
+        other => other.to_rest_json(),
+    }
+}
+
+/// The leading run of partition values this filter pins to a single value, in
+/// partition-key order, formatted the way the catalog and the partition path 
spell them.
+///
+/// Only a leading run is useful: a partition path prefix and the 
partition-name pattern a
+/// catalog prunes on can express nothing else. A null value has no such 
spelling, so the
+/// run stops there.
+fn leading_equality_partition_values(
+    filter: &PartitionFilter,
+    partition_fields: &[DataField],
+    default_partition_name: &str,
+    legacy_partition_name: bool,
+) -> crate::Result<Vec<String>> {
+    match filter {
+        PartitionFilter::Predicate(predicate) => 
Ok(leading_equality_values_from_predicate(
+            predicate,
+            partition_fields,
+            default_partition_name,
+            legacy_partition_name,
+        )),
+        // An enumerated partition set still pins a prefix whenever its rows 
agree on one,
+        // which is what `dt = 'a' AND hh IN ('10', '11')` collapses to.
+        PartitionFilter::PartitionSet { partitions, .. } => {
+            let mut common: Option<Vec<String>> = None;
+            for serialized in partitions {
+                let row = BinaryRow::from_serialized_bytes(serialized)?;
+                let mut values = Vec::with_capacity(partition_fields.len());
+                for (index, field) in partition_fields.iter().enumerate() {
+                    let Some(datum) = extract_datum(&row, index, 
field.data_type())? else {
+                        break;
+                    };
+                    let Some(value) = format_partition_value(
+                        &datum,
+                        field.data_type(),
+                        default_partition_name,
+                        legacy_partition_name,
+                    ) else {
+                        break;
+                    };
+                    values.push(value);
+                }
+                common = Some(match common {
+                    None => values,
+                    Some(common) => common
+                        .into_iter()
+                        .zip(values)
+                        .take_while(|(left, right)| left == right)
+                        .map(|(left, _)| left)
+                        .collect(),
+                });
+                if common.as_ref().is_some_and(Vec::is_empty) {
+                    break;
+                }
+            }
+            Ok(common.unwrap_or_default())
+        }
+    }
+}
+
+/// Mirrors Java 
`FormatTableScan.extractLeadingEqualityPartitionSpecWhenOnlyAnd`.
+fn leading_equality_values_from_predicate(
+    predicate: &Predicate,
+    partition_fields: &[DataField],
+    default_partition_name: &str,
+    legacy_partition_name: bool,
+) -> Vec<String> {
+    let mut pinned: Vec<Option<&Datum>> = vec![None; partition_fields.len()];
     let predicates = predicate.clone().split_and();
-    let mut values: Vec<Option<&Datum>> = vec![None; partition_keys.len()];
     for predicate in &predicates {
         let Predicate::Leaf {
             index,
@@ -410,38 +671,27 @@ fn leading_equality_partition_path(
         else {
             continue;
         };
-        if *index < values.len() {
-            values[*index] = literals.first();
+        if *index < pinned.len() {
+            pinned[*index] = literals.first();
         }
     }
 
-    let mut segments = Vec::new();
-    for (idx, key) in partition_keys.iter().enumerate() {
-        let Some(datum) = values[idx] else {
+    let mut values = Vec::new();
+    for (field, datum) in partition_fields.iter().zip(pinned) {
+        let Some(datum) = datum else {
             break;
         };
-        let value = partition_value_from_datum(
+        let Some(value) = format_partition_value(
             datum,
-            partition_fields[idx].data_type(),
+            field.data_type(),
             default_partition_name,
             legacy_partition_name,
-        )?;
-        if only_value_in_path {
-            segments.push(escape_path_name(&value));
-        } else {
-            segments.push(format!(
-                "{}={}",
-                escape_path_name(key),
-                escape_path_name(&value)
-            ));
-        }
-    }
-
-    if segments.is_empty() {
-        None
-    } else {
-        Some(join_path(table_path, &segments.join("/")))
+        ) else {
+            break;
+        };
+        values.push(value);
     }
+    values
 }
 
 fn partition_path_from_row(
@@ -455,7 +705,7 @@ fn partition_path_from_row(
     for (idx, field) in partition_fields.iter().enumerate() {
         let value = match extract_datum(row, idx, field.data_type())? {
             None => default_partition_name.to_string(),
-            Some(datum) => partition_value_from_datum(
+            Some(datum) => format_partition_value(
                 &datum,
                 field.data_type(),
                 default_partition_name,
@@ -481,78 +731,6 @@ fn partition_path_from_row(
     Ok(segments.join("/"))
 }
 
-fn partition_value_from_datum(
-    datum: &Datum,
-    data_type: &DataType,
-    default_partition_name: &str,
-    legacy_partition_name: bool,
-) -> Option<String> {
-    match (datum, data_type) {
-        (Datum::Bool(value), DataType::Boolean(_)) => Some(value.to_string()),
-        (Datum::TinyInt(value), DataType::TinyInt(_)) => 
Some(value.to_string()),
-        (Datum::SmallInt(value), DataType::SmallInt(_)) => 
Some(value.to_string()),
-        (Datum::Int(value), DataType::Int(_)) => Some(value.to_string()),
-        (Datum::Long(value), DataType::BigInt(_)) => Some(value.to_string()),
-        (Datum::String(value), DataType::Char(_) | DataType::VarChar(_)) => {
-            if value.trim().is_empty() {
-                Some(default_partition_name.to_string())
-            } else {
-                Some(value.clone())
-            }
-        }
-        (Datum::Date(value), DataType::Date(_)) => {
-            if legacy_partition_name {
-                Some(value.to_string())
-            } else {
-                Some(format_partition_date(*value))
-            }
-        }
-        (Datum::Time(value), DataType::Time(_)) => Some(value.to_string()),
-        _ => None,
-    }
-}
-
-fn format_partition_date(epoch_days: i32) -> String {
-    let date = NaiveDate::from_num_days_from_ce_opt(epoch_days + 719_163)
-        .unwrap_or(NaiveDate::from_ymd_opt(1970, 1, 1).unwrap());
-    date.format("%Y-%m-%d").to_string()
-}
-
-fn escape_path_name(path: &str) -> String {
-    let mut result = String::with_capacity(path.len());
-    for byte in path.bytes() {
-        if should_escape(byte) {
-            result.push('%');
-            result.push_str(&format!("{byte:02X}"));
-        } else {
-            result.push(byte as char);
-        }
-    }
-    result
-}
-
-fn should_escape(byte: u8) -> bool {
-    byte <= 0x1F
-        || byte >= 0x7F
-        || matches!(
-            byte,
-            b'"' | b'#'
-                | b'%'
-                | b'\''
-                | b'*'
-                | b'/'
-                | b':'
-                | b'='
-                | b'?'
-                | b'\\'
-                | b'\x7F'
-                | b'{'
-                | b'['
-                | b']'
-                | b'^'
-        )
-}
-
 fn partition_row_from_path(
     table_path: &str,
     file_parent: &str,
@@ -605,7 +783,8 @@ fn partition_row_from_path(
             builder.set_null_at(idx);
             continue;
         }
-        let Some(datum) = parse_partition_datum(value, 
partition_fields[idx].data_type()) else {
+        let Some(datum) = parse_format_partition_value(value, 
partition_fields[idx].data_type())
+        else {
             return Ok(None);
         };
         builder.write_datum(idx, &datum, partition_fields[idx].data_type());
@@ -613,6 +792,36 @@ fn partition_row_from_path(
     Ok(Some(builder.build()))
 }
 
+fn partition_row_from_catalog_spec(
+    spec: &HashMap<String, String>,
+    partition_fields: &[DataField],
+    partition_keys: &[String],
+    default_partition_name: &str,
+) -> crate::Result<BinaryRow> {
+    let mut builder = BinaryRowBuilder::new(partition_fields.len() as i32);
+    for (index, (key, field)) in 
partition_keys.iter().zip(partition_fields).enumerate() {
+        let value = spec.get(key).ok_or_else(|| crate::Error::DataInvalid {
+            message: format!("Catalog partition is missing column '{key}'"),
+            source: None,
+        })?;
+        if value == default_partition_name {
+            builder.set_null_at(index);
+            continue;
+        }
+        let datum = parse_format_partition_value(value, 
field.data_type()).ok_or_else(|| {
+            crate::Error::DataInvalid {
+                message: format!(
+                    "Invalid catalog partition value {value:?} for column 
'{key}' with type {:?}",
+                    field.data_type()
+                ),
+                source: None,
+            }
+        })?;
+        builder.write_datum(index, &datum, field.data_type());
+    }
+    Ok(builder.build())
+}
+
 fn partition_segment_value(segment: &str, key: &str) -> Option<String> {
     let (segment_key, segment_value) = segment.split_once('=')?;
     if unescape_path_name(segment_key)? == key {
@@ -652,31 +861,6 @@ fn hex_value(byte: u8) -> Option<u8> {
     }
 }
 
-fn parse_partition_datum(value: &str, data_type: &DataType) -> Option<Datum> {
-    match data_type {
-        DataType::Boolean(_) => value.parse::<bool>().ok().map(Datum::Bool),
-        DataType::TinyInt(_) => value.parse::<i8>().ok().map(Datum::TinyInt),
-        DataType::SmallInt(_) => 
value.parse::<i16>().ok().map(Datum::SmallInt),
-        DataType::Int(_) => value.parse::<i32>().ok().map(Datum::Int),
-        DataType::BigInt(_) => value.parse::<i64>().ok().map(Datum::Long),
-        DataType::Char(_) | DataType::VarChar(_) => 
Some(Datum::String(value.to_string())),
-        DataType::Date(_) => parse_partition_date(value).map(Datum::Date),
-        DataType::Time(_) => value.parse::<i32>().ok().map(Datum::Time),
-        _ => None,
-    }
-}
-
-fn parse_partition_date(value: &str) -> Option<i32> {
-    if let Ok(epoch_days) = value.parse::<i32>() {
-        return Some(epoch_days);
-    }
-    let date = NaiveDate::parse_from_str(value, "%Y-%m-%d").ok()?;
-    date.signed_duration_since(NaiveDate::from_ymd_opt(1970, 1, 1).unwrap())
-        .num_days()
-        .try_into()
-        .ok()
-}
-
 fn supported_format_table_formats() -> Vec<&'static str> {
     vec![
         "parquet",
@@ -1011,4 +1195,79 @@ mod tests {
             );
         }
     }
+
+    #[test]
+    fn test_partition_filter_json_sends_what_can_only_widen_the_result() {
+        use crate::spec::{DateType, PredicateBuilder};
+
+        let fields = vec![
+            DataField::new(
+                0,
+                "dt".to_string(),
+                DataType::VarChar(VarCharType::default()),
+            ),
+            DataField::new(1, "hh".to_string(), DataType::Int(IntType::new())),
+            DataField::new(2, "day".to_string(), 
DataType::Date(DateType::new())),
+        ];
+        let builder = PredicateBuilder::new(&fields);
+        let dt = builder.equal("dt", Datum::String("a".to_string())).unwrap();
+        let hh = builder.greater_than("hh", Datum::Int(10)).unwrap();
+        let day = builder.equal("day", Datum::Date(20_656)).unwrap();
+        let sent = |predicate: &Predicate| {
+            partition_filter_json(predicate).map(|json| {
+                Predicate::from_rest_json(&json.to_string(), &fields)
+                    .unwrap()
+                    .to_string()
+            })
+        };
+
+        // A DATE literal has no wire form, so the AND goes without it.
+        assert_eq!(
+            sent(&Predicate::and(vec![dt.clone(), hh.clone(), day.clone()])),
+            Some(Predicate::and(vec![dt.clone(), hh]).to_string())
+        );
+        assert_eq!(
+            sent(&Predicate::and(vec![dt.clone(), day.clone()])),
+            Some(dt.to_string())
+        );
+        // Leaving out a child of an OR would narrow it, so it is sent whole 
or not at all.
+        assert_eq!(sent(&Predicate::or(vec![dt.clone(), day])), None);
+        assert_eq!(sent(&Predicate::Not(Box::new(dt))), None);
+        assert_eq!(sent(&Predicate::AlwaysTrue), None);
+    }
+
+    #[test]
+    fn test_leading_equality_path_escapes_values_as_partition_paths_do() {
+        use crate::spec::PredicateBuilder;
+
+        let fields = vec![DataField::new(
+            0,
+            "dt".to_string(),
+            DataType::VarChar(VarCharType::default()),
+        )];
+        let keys = vec!["dt".to_string()];
+        let path = |value: &str, only_value_in_path: bool| {
+            let predicate = PredicateBuilder::new(&fields)
+                .equal("dt", Datum::String(value.to_string()))
+                .unwrap();
+            leading_equality_partition_path(
+                "memory:/t",
+                &keys,
+                &fields,
+                &predicate,
+                "__DEFAULT_PARTITION__",
+                false,
+                only_value_in_path,
+            )
+        };
+
+        // Only the characters Java escapes are escaped; a non-ASCII value is 
written as it is,
+        // as the directory a writer creates for it.
+        assert_eq!(
+            path("2026/07", false).as_deref(),
+            Some("memory:/t/dt=2026%2F07")
+        );
+        assert_eq!(path("中文", false).as_deref(), Some("memory:/t/dt=中文"));
+        assert_eq!(path("中文", true).as_deref(), Some("memory:/t/中文"));
+    }
 }
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index 32484924..708aa6b3 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -40,6 +40,7 @@ pub mod data_evolution_writer;
 mod data_file_reader;
 mod data_file_writer;
 mod dedicated_format_file_writer;
+mod format_partition;
 mod format_read_builder;
 mod format_table_read;
 mod format_table_scan;
@@ -342,6 +343,15 @@ impl Table {
         CoreOptions::new(self.schema.options()).is_format_table()
     }
 
+    /// Whether this table uses catalog-managed Format Table partitions: a 
Format Table loaded
+    /// from a REST catalog with `metastore.partitioned-table=true`.
+    pub fn has_catalog_managed_partitions(&self) -> bool {
+        let options = CoreOptions::new(self.schema.options());
+        self.rest_env.is_some()
+            && options.is_format_table()
+            && options.partitioned_table_in_metastore()
+    }
+
     /// Create a read builder for scan/read.
     ///
     /// Reference: [pypaimon 
FileStoreTable.new_read_builder](https://github.com/apache/paimon/blob/release-1.3/paimon-python/pypaimon/table/file_store_table.py).
@@ -513,6 +523,60 @@ impl Table {
         self.copy_with_time_travel_mode(extra, true).await
     }
 
+    /// Refuse dynamic options that would change where a Format Table loaded 
from a REST catalog
+    /// takes its partitions from, or, when the catalog manages them, how they 
are read.
+    ///
+    /// Mirrors Java `FormatTable.copy`. Java also fixes a Format Table's 
type, location and
+    /// format when the table is loaded; this table reads them from its 
options, so changing
+    /// them is refused here too.
+    fn ensure_format_table_partition_options_unchanged(
+        &self,
+        extra: &HashMap<String, String>,
+    ) -> Result<()> {
+        let current = CoreOptions::new(self.schema.options());
+        if self.rest_env.is_none() || !current.is_format_table() {
+            return Ok(());
+        }
+        let mut merged_options = self.schema.options().clone();
+        merged_options.extend(
+            extra
+                .iter()
+                .map(|(key, value)| (key.clone(), value.clone())),
+        );
+        let merged = CoreOptions::new(&merged_options);
+        let managed = current.partitioned_table_in_metastore();
+        let changed = if merged.partitioned_table_in_metastore() != managed {
+            Some("metastore.partitioned-table")
+        } else if !managed {
+            None
+        } else if !merged.is_format_table() {
+            Some("type")
+        } else if merged.format_table_partition_only_value_in_path()
+            != current.format_table_partition_only_value_in_path()
+        {
+            Some("format-table.partition-path-only-value")
+        } else if merged.format_table_implementation_is_engine() {
+            Some("format-table.implementation")
+        } else if merged.path() != current.path() {
+            Some("path")
+        } else if merged.file_format() != current.file_format() {
+            Some("file.format")
+        } else {
+            None
+        };
+        match changed {
+            Some(key) => Err(crate::Error::DataInvalid {
+                message: format!(
+                    "Dynamic option '{key}' cannot change where Format Table 
{} takes its \
+                     partitions from, or how it reads them",
+                    self.identifier.full_name()
+                ),
+                source: None,
+            }),
+            None => Ok(()),
+        }
+    }
+
     async fn copy_with_time_travel_mode(
         &self,
         extra: HashMap<String, String>,
@@ -521,6 +585,7 @@ impl Table {
         // Resolution reads Paimon snapshot paths, so refuse before any IO.
         CoreOptions::new(self.schema.options())
             .ensure_type_paimon_served(&self.identifier.full_name())?;
+        self.ensure_format_table_partition_options_unchanged(&extra)?;
         let mut table = self.copy_with_options(extra);
         // Reject unimplemented scan options on the merged view before any IO, 
so
         // both table-level and per-read options are covered.
diff --git a/crates/paimon/src/table/rest_env.rs 
b/crates/paimon/src/table/rest_env.rs
index 133acf69..4e8a1f7c 100644
--- a/crates/paimon/src/table/rest_env.rs
+++ b/crates/paimon/src/table/rest_env.rs
@@ -183,6 +183,7 @@ impl RESTEnv {
             ),
             source: None,
         })?;
+        validate_catalog_managed_format_table(identifier, &table_schema, 
is_external)?;
 
         let uuid = response.id.ok_or_else(|| Error::DataInvalid {
             message: format!(
@@ -314,6 +315,42 @@ impl RESTEnv {
     }
 }
 
+/// Refuse a Format Table that asks for catalog-managed partitions it cannot 
have: an engine
+/// implementation reads the table directory itself, and only an internal 
table's partitions
+/// belong to the catalog.
+///
+/// Mirrors Java `CatalogUtils.validateCatalogManagedFormatTablePartitions`.
+fn validate_catalog_managed_format_table(
+    identifier: &Identifier,
+    table_schema: &TableSchema,
+    is_external: bool,
+) -> Result<()> {
+    let options = CoreOptions::new(table_schema.options());
+    if !options.is_format_table() || !options.partitioned_table_in_metastore() 
{
+        return Ok(());
+    }
+    if options.format_table_implementation_is_engine() {
+        return Err(Error::DataInvalid {
+            message: format!(
+                "Format Table {} cannot set metastore.partitioned-table=true 
when \
+                 format-table.implementation=engine",
+                identifier.full_name()
+            ),
+            source: None,
+        });
+    }
+    if is_external {
+        return Err(Error::DataInvalid {
+            message: format!(
+                "Catalog-managed partitions require an internal Format Table, 
but {} is external",
+                identifier.full_name()
+            ),
+            source: None,
+        });
+    }
+    Ok(())
+}
+
 fn map_rest_error_for_table(err: Error, identifier: &Identifier) -> Error {
     match err {
         Error::RestApi {
diff --git a/crates/paimon/tests/mock_server.rs 
b/crates/paimon/tests/mock_server.rs
index 934c55de..bd73993b 100644
--- a/crates/paimon/tests/mock_server.rs
+++ b/crates/paimon/tests/mock_server.rs
@@ -69,6 +69,7 @@ struct MockState {
     create_partitions_calls: Vec<(String, String, CreatePartitionsRequest)>,
     drop_partitions_calls: Vec<(String, String, DropPartitionsRequest)>,
     create_partitions_error_status: Option<StatusCode>,
+    list_partitions_error_status: Option<StatusCode>,
     /// ECS metadata role name (for token loader testing)
     ecs_role_name: Option<String>,
     /// ECS metadata token (for token loader testing)
@@ -958,6 +959,15 @@ impl RESTServer {
             );
             return (StatusCode::NOT_FOUND, Json(error)).into_response();
         }
+        if let Some(status) = inner.list_partitions_error_status {
+            let error = ErrorResponse::new(
+                Some("partition".to_string()),
+                Some(table),
+                Some("Partition listing is not implemented".to_string()),
+                Some(status.as_u16() as i32),
+            );
+            return (status, Json(error)).into_response();
+        }
         if inner.partition_page_responses.contains_key(&key) {
             let request_index = {
                 let calls = inner
@@ -1309,6 +1319,11 @@ impl RESTServer {
         self.inner.lock().unwrap().create_partitions_error_status = status;
     }
 
+    /// Make the list-partitions endpoint return the given status.
+    pub fn set_list_partitions_error_status(&self, status: Option<StatusCode>) 
{
+        self.inner.lock().unwrap().list_partitions_error_status = status;
+    }
+
     /// Make the list-partitions-by-names endpoint return the given status.
     pub fn set_list_partitions_by_names_error_status(&self, status: 
Option<StatusCode>) {
         self.inner
@@ -1420,6 +1435,38 @@ impl RESTServer {
         inner.partition_list_call_counts.remove(&key);
     }
 
+    /// Set whether a stored table is external.
+    pub fn set_table_external(&self, database: &str, table: &str, is_external: 
bool) {
+        let key = format!("{database}.{table}");
+        let mut state = self.inner.lock().unwrap();
+        state
+            .tables
+            .get_mut(&key)
+            .unwrap_or_else(|| panic!("table {key} does not exist"))
+            .is_external = Some(is_external);
+    }
+
+    /// Attach catalog options, such as a custom `path`, to a registered 
partition.
+    pub fn set_table_partition_options(
+        &self,
+        database: &str,
+        table: &str,
+        spec: &HashMap<String, String>,
+        options: HashMap<String, String>,
+    ) {
+        let mut inner = self.inner.lock().unwrap();
+        let partition = inner
+            .partitions
+            .get_mut(&format!("{database}.{table}"))
+            .and_then(|partitions| {
+                partitions
+                    .iter_mut()
+                    .find(|partition| &partition.spec == spec)
+            })
+            .unwrap_or_else(|| panic!("partition {spec:?} is not registered"));
+        partition.options = Some(options);
+    }
+
     /// Set explicit list-partitions pages and response tokens in request 
order.
     pub fn set_table_partition_page_responses(
         &self,
diff --git a/crates/paimon/tests/rest_catalog_test.rs 
b/crates/paimon/tests/rest_catalog_test.rs
index 972fab05..b3f6cf53 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -84,6 +84,33 @@ fn test_schema() -> Schema {
         .expect("Failed to build schema")
 }
 
+/// A Parquet Format Table partitioned by `dt` whose partitions the catalog 
manages, unless
+/// `options` says otherwise.
+fn format_table_schema(options: &[(&str, &str)]) -> Schema {
+    let mut builder = Schema::builder()
+        .column("dt", DataType::VarChar(VarCharType::new(255).unwrap()))
+        .column("id", DataType::BigInt(BigIntType::new()))
+        .partition_keys(["dt"])
+        .option("type", "format-table")
+        .option("file.format", "parquet")
+        .option("metastore.partitioned-table", "true");
+    for (key, value) in options {
+        builder = builder.option(*key, *value);
+    }
+    builder.build().unwrap()
+}
+
+/// Catalog-managed partitions require an internal table, and the mock serves 
external ones.
+fn add_internal_table_with_schema(
+    server: &RESTServer,
+    table: &str,
+    schema: Schema,
+    location: &str,
+) {
+    server.add_table_with_schema("default", table, schema, location);
+    server.set_table_external("default", table, false);
+}
+
 fn blob_schema(options: &[(&str, &str)]) -> Schema {
     let mut builder = Schema::builder()
         .column("id", DataType::Int(IntType::new()))
@@ -484,6 +511,68 @@ async fn 
test_rest_catalog_looks_up_partitions_by_names_through_the_listing_when
     );
 }
 
+#[cfg(not(windows))]
+#[tokio::test]
+async fn 
test_rest_catalog_keeps_managed_partitions_off_the_filesystem_when_listing_is_unsupported()
+{
+    let tmp = tempfile::tempdir().unwrap();
+    let partition_dir = tmp.path().join("dt=2026-07-22");
+    std::fs::create_dir_all(&partition_dir).unwrap();
+    std::fs::write(partition_dir.join("part-0.parquet"), b"listed, never 
read").unwrap();
+    let location = format!("file://{}", tmp.path().display());
+    let ctx = setup_catalog(vec!["default"]).await;
+    add_internal_table_with_schema(
+        &ctx.server,
+        "managed_table",
+        format_table_schema(&[]),
+        &location,
+    );
+    add_internal_table_with_schema(
+        &ctx.server,
+        "unmanaged_table",
+        format_table_schema(&[("metastore.partitioned-table", "false")]),
+        &location,
+    );
+    ctx.server
+        .set_list_partitions_error_status(Some(StatusCode::NOT_IMPLEMENTED));
+    ctx.server
+        
.set_list_partitions_by_names_error_status(Some(StatusCode::NOT_IMPLEMENTED));
+    let managed = Identifier::new("default", "managed_table");
+    let partition = HashMap::from([("dt".to_string(), 
"2026-07-22".to_string())]);
+    let is_not_implemented = |error: paimon::Error| {
+        matches!(
+            error,
+            paimon::Error::RestApi {
+                source: paimon::api::RestError::NotImplemented { .. }
+            }
+        )
+    };
+
+    // The directory is there, but it is not a registration of a 
catalog-managed table.
+    assert!(is_not_implemented(
+        ctx.catalog.list_partitions(&managed).await.unwrap_err()
+    ));
+    assert!(is_not_implemented(
+        ctx.catalog
+            .list_partitions_paged(&managed, None, None)
+            .await
+            .unwrap_err()
+    ));
+    assert!(is_not_implemented(
+        ctx.catalog
+            .list_partitions_by_names(&managed, vec![partition.clone()])
+            .await
+            .unwrap_err()
+    ));
+
+    // A table whose partitions the catalog does not manage still falls back 
to the file system
+    // listing rather than failing.
+    ctx.catalog
+        .list_partitions(&Identifier::new("default", "unmanaged_table"))
+        .await
+        .unwrap();
+}
+
 // ==================== Database Tests ====================
 
 #[tokio::test]
@@ -896,6 +985,284 @@ async fn test_rest_catalog_reads_format_table() {
     );
 }
 
+#[tokio::test]
+async fn test_rest_catalog_validates_dynamic_managed_partition_options() {
+    let ctx = setup_catalog(vec!["default"]).await;
+    let schema = format_table_schema(&[]);
+    let identifier = Identifier::new("default", "managed_format");
+    add_internal_table_with_schema(
+        &ctx.server,
+        "managed_format",
+        schema,
+        "memory:/managed_format",
+    );
+
+    let table = ctx.catalog.get_table(&identifier).await.unwrap();
+
+    assert!(table.has_catalog_managed_partitions());
+    for (key, value) in [
+        ("metastore.partitioned-table", "false"),
+        ("format-table.partition-path-only-value", "true"),
+        ("format-table.implementation", "engine"),
+        ("type", "table"),
+        ("path", "memory:/elsewhere"),
+        ("file.format", "orc"),
+    ] {
+        let error = table
+            .copy_with_time_travel(HashMap::from([(key.to_string(), 
value.to_string())]))
+            .await
+            .unwrap_err();
+        assert!(
+            matches!(error, paimon::Error::DataInvalid { .. }) && 
error.to_string().contains(key),
+            "expected {key} validation error, got: {error}"
+        );
+    }
+
+    let copied = table
+        .copy_with_time_travel(HashMap::from([
+            (
+                "metastore.partitioned-table".to_string(),
+                "TRUE".to_string(),
+            ),
+            (
+                "format-table.partition-path-only-value".to_string(),
+                "false".to_string(),
+            ),
+            (
+                "format-table.implementation".to_string(),
+                "PAIMON".to_string(),
+            ),
+            (
+                "format-table.scan.list-parallelism".to_string(),
+                "8".to_string(),
+            ),
+        ]))
+        .await
+        .unwrap();
+    assert!(copied.has_catalog_managed_partitions());
+    assert!(copied
+        .new_read_builder()
+        .new_scan()
+        .plan()
+        .await
+        .unwrap()
+        .splits()
+        .is_empty());
+}
+
+#[tokio::test]
+async fn 
test_rest_catalog_rejects_enabling_managed_partitions_in_dynamic_options() {
+    let ctx = setup_catalog(vec!["default"]).await;
+    let schema = format_table_schema(&[("metastore.partitioned-table", 
"false")]);
+    let identifier = Identifier::new("default", "unmanaged_format");
+    add_internal_table_with_schema(
+        &ctx.server,
+        "unmanaged_format",
+        schema,
+        "memory:/unmanaged_format",
+    );
+
+    let table = ctx.catalog.get_table(&identifier).await.unwrap();
+    assert!(!table.has_catalog_managed_partitions());
+
+    let error = table
+        .copy_with_time_travel(HashMap::from([(
+            "metastore.partitioned-table".to_string(),
+            "true".to_string(),
+        )]))
+        .await
+        .unwrap_err();
+    assert!(
+        matches!(error, paimon::Error::DataInvalid { .. })
+            && error.to_string().contains("metastore.partitioned-table"),
+        "expected partition source validation error, got: {error}"
+    );
+}
+
+#[tokio::test]
+async fn test_rest_catalog_rejects_external_catalog_managed_format_table() {
+    let ctx = setup_catalog(vec!["default"]).await;
+    let schema = format_table_schema(&[]);
+    let identifier = Identifier::new("default", "external_managed_format");
+    ctx.server.add_table_with_schema(
+        "default",
+        "external_managed_format",
+        schema,
+        "memory:/external_managed_format",
+    );
+
+    let error = ctx.catalog.get_table(&identifier).await.unwrap_err();
+
+    assert!(matches!(
+        error,
+        paimon::Error::DataInvalid { message, .. }
+            if message.contains("default.external_managed_format")
+                && message.contains("internal")
+    ));
+}
+
+#[tokio::test]
+async fn test_rest_catalog_rejects_engine_managed_format_table() {
+    let ctx = setup_catalog(vec!["default"]).await;
+    let schema = format_table_schema(&[("format-table.implementation", 
"engine")]);
+    let identifier = Identifier::new("default", "engine_managed_format");
+    add_internal_table_with_schema(
+        &ctx.server,
+        "engine_managed_format",
+        schema,
+        "memory:/engine_managed_format",
+    );
+
+    let error = ctx.catalog.get_table(&identifier).await.unwrap_err();
+
+    assert!(matches!(
+        error,
+        paimon::Error::DataInvalid { message, .. }
+            if message.contains("metastore.partitioned-table")
+                && message.contains("format-table.implementation=engine")
+    ));
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_managed_format_scan_uses_registered_partition_paths_once() {
+    let tmp = tempfile::tempdir().unwrap();
+    for dt in ["2026-07-21", "2026-07-22"] {
+        let partition_dir = tmp.path().join(format!("dt={dt}"));
+        std::fs::create_dir_all(&partition_dir).unwrap();
+        std::fs::write(partition_dir.join("part-0.parquet"), b"data").unwrap();
+    }
+    let table_path = format!("file://{}", tmp.path().display());
+
+    let ctx = setup_catalog(vec!["default"]).await;
+    let schema = format_table_schema(&[]);
+    let identifier = Identifier::new("default", "managed_visibility");
+    add_internal_table_with_schema(&ctx.server, "managed_visibility", schema, 
&table_path);
+    ctx.server.set_table_partitions(
+        "default",
+        "managed_visibility",
+        vec![
+            HashMap::from([("dt".to_string(), "2026-07-22".to_string())]),
+            HashMap::from([("dt".to_string(), "2026-07-22".to_string())]),
+        ],
+    );
+
+    let table = ctx.catalog.get_table(&identifier).await.unwrap();
+    let plan = table.new_read_builder().new_scan().plan().await.unwrap();
+
+    // `dt=2026-07-21` holds a file but is not registered; the repeated 
registration is read once.
+    assert_eq!(plan.splits().len(), 1);
+    assert!(plan.splits()[0].bucket_path().ends_with("/dt=2026-07-22"));
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn 
test_managed_format_scan_treats_registered_missing_directory_as_empty() {
+    let tmp = tempfile::tempdir().unwrap();
+    let table_path = format!("file://{}", tmp.path().display());
+
+    let ctx = setup_catalog(vec!["default"]).await;
+    let schema = format_table_schema(&[]);
+    let identifier = Identifier::new("default", "managed_missing_directory");
+    add_internal_table_with_schema(
+        &ctx.server,
+        "managed_missing_directory",
+        schema,
+        &table_path,
+    );
+    ctx.server.set_table_partitions(
+        "default",
+        "managed_missing_directory",
+        vec![HashMap::from([(
+            "dt".to_string(),
+            "2026-07-22".to_string(),
+        )])],
+    );
+
+    let table = ctx.catalog.get_table(&identifier).await.unwrap();
+    let plan = table.new_read_builder().new_scan().plan().await.unwrap();
+
+    assert!(plan.splits().is_empty());
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_managed_format_scan_propagates_partition_listing_error() {
+    let tmp = tempfile::tempdir().unwrap();
+    std::fs::create_dir_all(tmp.path().join("dt=2026-07-22")).unwrap();
+    std::fs::write(
+        tmp.path().join("dt=2026-07-22").join("part-0.parquet"),
+        b"data",
+    )
+    .unwrap();
+    let table_path = format!("file://{}", tmp.path().display());
+
+    let ctx = setup_catalog(vec!["default"]).await;
+    let schema = format_table_schema(&[]);
+    let identifier = Identifier::new("default", "managed_scan_error");
+    add_internal_table_with_schema(&ctx.server, "managed_scan_error", schema, 
&table_path);
+    ctx.server
+        .set_list_partitions_error_status(Some(StatusCode::NOT_IMPLEMENTED));
+
+    let table = ctx.catalog.get_table(&identifier).await.unwrap();
+    let error = table
+        .new_read_builder()
+        .new_scan()
+        .plan()
+        .await
+        .unwrap_err();
+
+    // The directory is not read in place of the registrations the catalog 
could not list.
+    assert!(matches!(
+        error,
+        paimon::Error::RestApi {
+            source: paimon::api::RestError::NotImplemented { .. }
+        }
+    ));
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn 
test_managed_format_scan_reports_table_for_malformed_partition_metadata() {
+    let tmp = tempfile::tempdir().unwrap();
+    let table_path = format!("file://{}", tmp.path().display());
+
+    let ctx = setup_catalog(vec!["default"]).await;
+    let schema = format_table_schema(&[]);
+    let identifier = Identifier::new("default", "managed_corrupt_metadata");
+    add_internal_table_with_schema(&ctx.server, "managed_corrupt_metadata", 
schema, &table_path);
+    ctx.server.set_table_partitions(
+        "default",
+        "managed_corrupt_metadata",
+        vec![HashMap::from([
+            ("dt".to_string(), "2026-07-22".to_string()),
+            ("unexpected".to_string(), "value".to_string()),
+        ])],
+    );
+
+    let table = ctx.catalog.get_table(&identifier).await.unwrap();
+    let error = table
+        .new_read_builder()
+        .new_scan()
+        .plan()
+        .await
+        .unwrap_err();
+
+    match error {
+        paimon::Error::DataInvalid {
+            message,
+            source: Some(source),
+        } => {
+            assert!(message.contains("invalid partition metadata"));
+            assert!(message.contains("default.managed_corrupt_metadata"));
+            let cause = source.to_string();
+            assert!(cause.contains("unexpected"), "unexpected cause: {cause}");
+            assert!(cause.contains("dt"), "unexpected cause: {cause}");
+        }
+        other => panic!("expected invalid partition metadata, got: {other}"),
+    }
+}
+
 #[cfg(not(windows))]
 #[tokio::test]
 async fn test_rest_catalog_prunes_format_table_partition_filter() {

Reply via email to