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 b8fbc1c3 [core] Support configured data directories throughout table 
IO (#974)
b8fbc1c3 is described below

commit b8fbc1c301295361a96019e7cdcadf367e8be65e
Author: Jingsong Lee <[email protected]>
AuthorDate: Mon Sep 28 13:14:28 2026 +0800

    [core] Support configured data directories throughout table IO (#974)
---
 .../src/system_tables/physical_files_size.rs       |  12 +-
 .../src/system_tables/referenced_files_size.rs     |   7 +-
 crates/paimon/src/catalog/filesystem.rs            |  60 ++-
 crates/paimon/src/spec/core_options.rs             |  17 +
 crates/paimon/src/spec/mod.rs                      |   3 +-
 crates/paimon/src/spec/partition_utils.rs          | 250 +++++++++++
 crates/paimon/src/spec/schema.rs                   |  25 +-
 crates/paimon/src/table/bucket_assigner_dynamic.rs |  15 +-
 crates/paimon/src/table/data_evolution_writer.rs   |  17 +-
 crates/paimon/src/table/data_file_writer.rs        |   6 +-
 .../src/table/dedicated_format_file_writer.rs      |   2 +-
 crates/paimon/src/table/external_path.rs           |  43 +-
 .../src/table/global_index_build_common/vector.rs  |   4 +-
 crates/paimon/src/table/index_file_path.rs         |   2 +-
 crates/paimon/src/table/kv_file_writer.rs          |  10 +-
 crates/paimon/src/table/managed_blob_writer.rs     |   5 +-
 crates/paimon/src/table/mod.rs                     |   8 +
 crates/paimon/src/table/read_builder.rs            |   1 +
 crates/paimon/src/table/referenced_files.rs        | 170 +++++---
 .../sorted_global_index_build_builder/planning.rs  |   4 +-
 crates/paimon/src/table/table_commit.rs            |   8 +-
 crates/paimon/src/table/table_scan.rs              |  20 +-
 crates/paimon/src/table/table_write.rs             |  34 +-
 crates/paimon/tests/data_file_directory_test.rs    | 470 +++++++++++++++++++++
 24 files changed, 1067 insertions(+), 126 deletions(-)

diff --git 
a/crates/integrations/datafusion/src/system_tables/physical_files_size.rs 
b/crates/integrations/datafusion/src/system_tables/physical_files_size.rs
index 01ef43c1..0c46ab4d 100644
--- a/crates/integrations/datafusion/src/system_tables/physical_files_size.rs
+++ b/crates/integrations/datafusion/src/system_tables/physical_files_size.rs
@@ -28,7 +28,9 @@ use datafusion::datasource::{TableProvider, TableType};
 use datafusion::error::Result as DFResult;
 use datafusion::logical_expr::Expr;
 use datafusion::physical_plan::ExecutionPlan;
-use paimon::table::referenced_files::{collect_physical_files_summary, 
PhysicalFilesSummary};
+use paimon::table::referenced_files::{
+    collect_physical_files_summary_with_options, PhysicalFilesSummary,
+};
 use paimon::table::Table;
 
 use crate::error::to_datafusion_error;
@@ -78,7 +80,13 @@ impl TableProvider for PhysicalFilesSizeTable {
         let table = self.table.clone();
         let summary = crate::runtime::await_with_runtime(async move {
             let partition_depth = table.schema().partition_keys().len();
-            collect_physical_files_summary(table.file_io(), table.location(), 
partition_depth).await
+            collect_physical_files_summary_with_options(
+                table.file_io(),
+                table.location(),
+                partition_depth,
+                &table.schema().core_options(),
+            )
+            .await
         })
         .await
         .map_err(to_datafusion_error)?;
diff --git 
a/crates/integrations/datafusion/src/system_tables/referenced_files_size.rs 
b/crates/integrations/datafusion/src/system_tables/referenced_files_size.rs
index 568663ca..db09d0e7 100644
--- a/crates/integrations/datafusion/src/system_tables/referenced_files_size.rs
+++ b/crates/integrations/datafusion/src/system_tables/referenced_files_size.rs
@@ -28,7 +28,9 @@ use datafusion::datasource::{TableProvider, TableType};
 use datafusion::error::Result as DFResult;
 use datafusion::logical_expr::Expr;
 use datafusion::physical_plan::ExecutionPlan;
-use paimon::table::referenced_files::{collect_referenced_files_summary, 
ReferencedFilesSummary};
+use paimon::table::referenced_files::{
+    collect_referenced_files_summary_with_options, ReferencedFilesSummary,
+};
 use paimon::table::Table;
 
 use crate::error::to_datafusion_error;
@@ -81,11 +83,12 @@ impl TableProvider for ReferencedFilesSizeTable {
             let schema = table.schema();
             let partition_keys = schema.partition_keys();
             let partition_fields = schema.partition_fields();
-            collect_referenced_files_summary(
+            collect_referenced_files_summary_with_options(
                 table.file_io(),
                 table.location(),
                 partition_keys,
                 &partition_fields,
+                &schema.core_options(),
             )
             .await
         })
diff --git a/crates/paimon/src/catalog/filesystem.rs 
b/crates/paimon/src/catalog/filesystem.rs
index d660697f..23195c78 100644
--- a/crates/paimon/src/catalog/filesystem.rs
+++ b/crates/paimon/src/catalog/filesystem.rs
@@ -704,8 +704,8 @@ fn local_datetime_to_millis<Tz: TimeZone>(
 ///   snapshot is not required: a format table holds data without ever writing
 ///   one, so a snapshot check would not catch it. Java special-cases `type` 
the
 ///   same way (`SchemaManager.generateTableSchema`).
-/// * `index-file-in-data-file-dir` picks the directory every bucket-local 
index
-///   file is written to and read from, so flipping it hides every index file 
the
+/// * `index-file-in-data-file-dir` and `data-file.path-directory` pick the 
directories
+///   files are written to and read from, so flipping them hides files the
 ///   table already has. A change is rejected once the table exists, and a
 ///   `SetOption` repeating the stored value or a `RemoveOption` for an option 
that
 ///   is not set is let through, since neither moves anything.
@@ -745,25 +745,27 @@ fn reject_immutable_option_changes(
                 });
             }
             crate::spec::SchemaChange::SetOption { key, value }
-                if key == INDEX_FILE_IN_DATA_FILE_DIR_OPTION
+                if (key == INDEX_FILE_IN_DATA_FILE_DIR_OPTION
+                    || key == "data-file.path-directory")
                     && current_options.get(key.as_str()) != Some(value) =>
             {
                 return Err(Error::Unsupported {
                     message: format!(
-                        "changing '{INDEX_FILE_IN_DATA_FILE_DIR_OPTION}' after 
the table exists \
-                         is not supported: it selects the directory index 
files are written to, \
+                        "changing '{key}' after the table exists \
+                         is not supported: it selects the directory files are 
written to, \
                          so the files already written would no longer be found"
                     ),
                 });
             }
             crate::spec::SchemaChange::RemoveOption { key }
-                if key == INDEX_FILE_IN_DATA_FILE_DIR_OPTION
+                if (key == INDEX_FILE_IN_DATA_FILE_DIR_OPTION
+                    || key == "data-file.path-directory")
                     && current_options.contains_key(key.as_str()) =>
             {
                 return Err(Error::Unsupported {
                     message: format!(
-                        "removing '{INDEX_FILE_IN_DATA_FILE_DIR_OPTION}' is 
not supported: \
-                         it selects the directory index files are written to, 
so the files \
+                        "removing '{key}' is not supported: \
+                         it selects the directory files are written to, so the 
files \
                          already written would no longer be found"
                     ),
                 });
@@ -1277,6 +1279,48 @@ mod tests {
         assert!(matches!(err, Error::Unsupported { .. }), "{err:?}");
     }
 
+    #[tokio::test]
+    async fn test_alter_table_cannot_change_data_directory() {
+        use crate::spec::SchemaChange;
+        for stored in [None, Some("data/nested")] {
+            let (_temp_dir, catalog) = create_test_catalog();
+            let options = stored
+                .map(|value| {
+                    HashMap::from([("data-file.path-directory".to_string(), 
value.to_string())])
+                })
+                .unwrap_or_default();
+            let identifier = create_table_for_alter(&catalog, options).await;
+            give_the_table_a_snapshot(&catalog, &identifier).await;
+            let key = "data-file.path-directory".to_string();
+            let original = catalog.get_table(&identifier).await.unwrap();
+            let mut changes = vec![SchemaChange::set_option(key.clone(), 
"other".to_string())];
+            if stored.is_some() {
+                changes.push(SchemaChange::remove_option(key.clone()));
+            }
+            for change in changes {
+                let error = catalog
+                    .alter_table(&identifier, vec![change], false)
+                    .await
+                    .unwrap_err();
+                assert!(matches!(error, Error::Unsupported { .. }), 
"{error:?}");
+            }
+            let no_op = match stored {
+                Some(value) => SchemaChange::set_option(key, 
value.to_string()),
+                None => SchemaChange::remove_option(key),
+            };
+            catalog
+                .alter_table(&identifier, vec![no_op], false)
+                .await
+                .unwrap();
+            let reloaded = catalog.get_table(&identifier).await.unwrap();
+            assert_eq!(
+                reloaded.schema().core_options().data_file_path_directory(),
+                stored
+            );
+            assert_eq!(reloaded.schema().options(), 
original.schema().options());
+        }
+    }
+
     #[tokio::test]
     async fn test_alter_table_index_layout_no_ops_are_allowed() {
         use crate::spec::SchemaChange;
diff --git a/crates/paimon/src/spec/core_options.rs 
b/crates/paimon/src/spec/core_options.rs
index 984e1380..d474479b 100644
--- a/crates/paimon/src/spec/core_options.rs
+++ b/crates/paimon/src/spec/core_options.rs
@@ -1404,6 +1404,23 @@ impl<'a> CoreOptions<'a> {
             .unwrap_or(1)
     }
 
+    pub(crate) fn validate_data_file_path_directory(&self) -> 
crate::Result<()> {
+        if self.data_file_path_directory() == Some("") {
+            return Err(crate::Error::ConfigInvalid {
+                message: "data-file.path-directory must not be empty".into(),
+            });
+        }
+        Ok(())
+    }
+
+    /// Directory containing bucket data, resolved against the table location.
+    /// Supports relative paths, absolute paths, and URIs, like Java `Path`.
+    pub fn data_file_path_directory(&self) -> Option<&str> {
+        self.options
+            .get("data-file.path-directory")
+            .map(String::as_str)
+    }
+
     /// File name prefix for data files. Default is `"data-"`.
     pub fn data_file_prefix(&self) -> &str {
         self.options
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index f1da81a4..5fe8faa1 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -104,7 +104,8 @@ mod partition;
 pub use partition::Partition;
 mod partition_utils;
 pub(crate) use partition_utils::{
-    bucket_path, bucket_path_under, escape_path_name, unescape_path_name, 
PartitionComputer,
+    bucket_path, bucket_path_under, data_file_path, escape_path_name, 
relative_bucket_path,
+    unescape_path_name, PartitionComputer,
 };
 mod predicate;
 pub(crate) use predicate::datum_cmp;
diff --git a/crates/paimon/src/spec/partition_utils.rs 
b/crates/paimon/src/spec/partition_utils.rs
index 99391ef1..7c3dda8c 100644
--- a/crates/paimon/src/spec/partition_utils.rs
+++ b/crates/paimon/src/spec/partition_utils.rs
@@ -31,6 +31,147 @@ use chrono::{Local, NaiveDate, NaiveDateTime, TimeZone, 
Timelike};
 
 const MILLIS_PER_DAY: i64 = 86_400_000;
 
+/// Java `FileStorePathFactory.dataFilePath`: resolve the configured directory
+/// against the table root. This applies only to data, never table metadata.
+/// Like Java Path, preserve literal percent signs, spaces, `?` and `#` in 
names.
+pub(crate) fn data_file_path(table_path: &str, directory: Option<&str>) -> 
String {
+    let Some(directory) = directory.filter(|directory| !directory.is_empty()) 
else {
+        return if table_path == "/" {
+            "/"
+        } else {
+            table_path.trim_end_matches('/')
+        }
+        .to_string();
+    };
+    resolve_data_path(table_path, directory, cfg!(windows))
+}
+
+fn resolve_data_path(parent: &str, child: &str, windows: bool) -> String {
+    fn has_drive(path: &str) -> bool {
+        let path = path.strip_prefix('/').unwrap_or(path).as_bytes();
+        path.len() >= 2 && path[0].is_ascii_alphabetic() && path[1] == b':'
+    }
+    fn normalize(path: &str, windows: bool) -> String {
+        let mut components = Vec::new();
+        for component in path.split('/') {
+            match component {
+                "" | "." => {}
+                ".." if components.last().is_some_and(|last| *last != "..") => 
{
+                    components.pop();
+                }
+                component => components.push(component),
+            }
+        }
+        let leading = if path.starts_with('/') { "/" } else { "" };
+        let normalized = format!("{leading}{}", components.join("/"));
+        if windows
+            && normalized.starts_with('/')
+            && has_drive(&normalized)
+            && normalized.len() == 3
+            && path != normalized
+        {
+            format!("{normalized}/")
+        } else if !normalized.starts_with('/')
+            && normalized
+                .split('/')
+                .next()
+                .is_some_and(|part| part.contains(':'))
+        {
+            format!("./{normalized}")
+        } else {
+            normalized
+        }
+    }
+    // Keep scheme and authority separate: a port or user-info can contain ':'.
+    fn parts(input: &str, windows: bool) -> (String, String, String) {
+        let input = if windows && has_drive(input) && !input.starts_with('/') {
+            format!("/{input}")
+        } else {
+            input.to_string()
+        };
+        let scheme_end = input
+            .find(':')
+            .filter(|&i| !input[..i].contains('/'))
+            .map_or(0, |i| i + 1);
+        let scheme = &input[..scheme_end];
+        let (authority, path) =
+            if input[scheme_end..].starts_with("//") && input.len() - 
scheme_end > 2 {
+                let start = scheme_end + 2;
+                let end = input[start..].find('/').map_or(input.len(), |i| 
start + i);
+                let authority = if end == start {
+                    ""
+                } else {
+                    &input[scheme_end..end]
+                };
+                (authority, &input[end..])
+            } else {
+                ("", &input[scheme_end..])
+            };
+        // Java collapses forward slashes before converting Windows separators.
+        let mut previous_slash = false;
+        let path: String = path
+            .chars()
+            .filter(|&c| {
+                let duplicate = c == '/' && previous_slash;
+                previous_slash = c == '/';
+                !duplicate
+            })
+            .collect();
+        let path = if windows && (has_drive(&path) || scheme.is_empty() || 
scheme == "file:") {
+            path.replace('\\', "/")
+        } else {
+            path.to_string()
+        };
+        // Java URI recognizes a UNC authority after Windows separator 
conversion.
+        let (authority, path) = if authority.is_empty() && 
path.starts_with("//") && path.len() > 2
+        {
+            let end = path[2..].find('/').map_or(path.len(), |i| i + 2);
+            (&path[..end], &path[end..])
+        } else {
+            (authority, path.as_str())
+        };
+        (
+            scheme.to_string(),
+            authority.to_string(),
+            normalize(path, windows),
+        )
+    }
+    let (parent_scheme, parent_authority, parent_path) = parts(parent, 
windows);
+    let (child_scheme, child_authority, child_path) = parts(child, windows);
+    let (scheme, authority, path) = if !child_scheme.is_empty() {
+        (child_scheme, child_authority, child_path)
+    } else if !child_authority.is_empty() {
+        (parent_scheme, child_authority, child_path)
+    } else if child_path.starts_with('/') {
+        (parent_scheme, parent_authority, child_path)
+    } else {
+        let path = if parent_path.is_empty()
+            && (child_path.is_empty() || (parent_scheme.is_empty() && 
parent_authority.is_empty()))
+        {
+            child_path
+        } else {
+            format!("{}/{child_path}", parent_path.trim_end_matches('/'))
+        };
+        (parent_scheme, parent_authority, path)
+    };
+    let path = normalize(&path, windows);
+    let path = if windows && scheme.is_empty() && authority.is_empty() && 
has_drive(&path) {
+        path.strip_prefix('/').unwrap_or(&path).to_string()
+    } else if scheme.is_empty()
+        && authority.is_empty()
+        && !path.starts_with('/')
+        && path
+            .split('/')
+            .next()
+            .is_some_and(|part| part.contains(':'))
+    {
+        format!("./{path}")
+    } else {
+        path
+    };
+    format!("{scheme}{authority}{path}")
+}
+
 /// The directory holding one bucket's data files, 
`<table>/[<partition>/]bucket-N`.
 ///
 /// Mirrors Java `FileStorePathFactory.bucketPath`. Every consumer that needs a
@@ -53,6 +194,20 @@ pub(crate) fn bucket_path(
     Ok(bucket_path_under(table_path, &partition_path, bucket))
 }
 
+/// Java `relativeBucketPath`, which may itself be absolute when the configured
+/// data directory is absolute. External path providers must resolve, not 
append it.
+pub(crate) fn relative_bucket_path(
+    partition_path: &str,
+    bucket: i32,
+    directory: Option<&str>,
+) -> String {
+    let relative = format!("{partition_path}{}", 
crate::spec::bucket_dir_name(bucket));
+    match directory.filter(|directory| !directory.is_empty()) {
+        Some(directory) => data_file_path(directory, Some(&relative)),
+        None => relative,
+    }
+}
+
 /// [`bucket_path`] for a partition directory that is already computed.
 ///
 /// `partition_path` is empty for an unpartitioned table and otherwise ends 
with `/`,
@@ -652,6 +807,101 @@ fn needs_escaping(c: char) -> bool {
 
 #[cfg(test)]
 mod tests {
+    #[test]
+    fn data_directories_match_java_path_resolution() {
+        // Expected strings produced by the Java org.apache.paimon.fs.Path.
+        for (parent, child, expected) in [
+            ("/warehouse/t", "data/nested", "/warehouse/t/data/nested"),
+            (
+                "/warehouse/t/",
+                "data//discard/../nested/.",
+                "/warehouse/t/data/nested",
+            ),
+            (
+                "s3://bucket/warehouse/t",
+                "/shared/data",
+                "s3://bucket/shared/data",
+            ),
+            ("s3://bucket/t", "s3://other/data", "s3://other/data"),
+            (
+                "file:/warehouse/t",
+                "file:///other/data",
+                "file:/other/data",
+            ),
+            ("memory:/t", "../other", "memory:/other"),
+            ("warehouse/t", "../data", "warehouse/data"),
+            ("warehouse/t", "data", "warehouse/t/data"),
+            ("s3://bucket/t", "//other/data", "s3://other/data"),
+            (
+                "s3://bucket/t",
+                "data 100%?#/你好",
+                "s3://bucket/t/data 100%?#/你好",
+            ),
+            ("file:/t", "./data", "file:/t/data"),
+            ("/", "data", "/data"),
+            ("/t", ".", "/t"),
+            ("/t", "..", "/"),
+            ("/t", "../../data", "/../data"),
+            ("s3://bucket/", "data", "s3://bucket/data"),
+            ("s3://bucket", ".", "s3://bucket"),
+            ("s3://bucket", "x/..", "s3://bucket"),
+            ("warehouse", "a/../b:c", "warehouse/b:c"),
+            ("memory:/t", "a/../../b", "memory:/b"),
+            ("/t", "///data", "/data"),
+            ("/t", "////data", "/data"),
+            ("file:/t", "file:////data", "file:/data"),
+            ("/t", "//", "/"),
+            ("file:/t", "//", "file:/"),
+            ("s3://bucket/t", "//other:9000/data", "s3://other:9000/data"),
+            ("//host:8020/table", "//other/data", "//other/data"),
+            ("relative", "../b:c", "./b:c"),
+            ("relative", "../a:bb", "./a:bb"),
+            ("relative", "../b:c/x", "./b:c/x"),
+            ("file:/t", "file:/data", "file:/data"),
+        ] {
+            assert_eq!(
+                data_file_path(parent, Some(child)),
+                expected,
+                "{parent} + {child}"
+            );
+        }
+    }
+
+    #[test]
+    fn windows_data_directories_match_java() {
+        for (parent, child, expected) in [
+            ("relative", "../b:c", "./b:c"),
+            ("relative", "../a:bb", "./a:bb"),
+            ("relative", "../b:c/x", "./b:c/x"),
+            ("warehouse/t", "data/../../../b:c", "./b:c"),
+            (".", "a/../b:c", "./b:c"),
+            ("s3://bucket", ".", "s3://bucket"),
+            (r"C:\warehouse\table", "../data", "C:/warehouse/data"),
+            (r"C:\warehouse\table", r"..\data", "C:/warehouse/data"),
+            ("C:/warehouse/table", "/data", "/data"),
+            ("C:/warehouse/table", "../..", "C:/"),
+            ("file:/C:/warehouse/table", "../..", "file:/C:/"),
+            (r"\\server\share\table", "data", "//server/share/table/data"),
+            ("s3://bucket/table", r"\\other\share", "s3://other/share"),
+            (
+                "file:/C:/warehouse/table",
+                r"..\data",
+                "file:/C:/warehouse/data",
+            ),
+            (
+                "s3://bucket/t",
+                r"data\literal",
+                "s3://bucket/t/data/literal",
+            ),
+        ] {
+            assert_eq!(
+                resolve_data_path(parent, child, true),
+                expected,
+                "{parent} + {child}"
+            );
+        }
+    }
+
     use super::*;
     use crate::spec::types::*;
     use crate::spec::DataField;
diff --git a/crates/paimon/src/spec/schema.rs b/crates/paimon/src/spec/schema.rs
index c7d15736..7386a625 100644
--- a/crates/paimon/src/spec/schema.rs
+++ b/crates/paimon/src/spec/schema.rs
@@ -149,10 +149,10 @@ impl TableSchema {
     /// override, and the declared `type` can't be changed by one: an override
     /// could re-route foreign data through the Paimon reader.
     ///
-    /// `index-file-in-data-file-dir` can't be changed by one either. It 
selects
-    /// the directory every bucket-local index file is written to and read 
from,
-    /// while an index manifest records only the file name, so a copy carrying 
an
-    /// overridden value would write hash and deletion-vector index files 
where a
+    /// `index-file-in-data-file-dir` and `data-file.path-directory` also 
retain
+    /// their stored values. They select where data and bucket-local indexes 
are
+    /// written and read, while manifests record file names, so a copy 
carrying an
+    /// overridden value would write files where a
     /// normally loaded table cannot find them. Java rejects such an override
     /// outright in `AbstractFileStoreTable.checkImmutability`; this copy 
cannot
     /// fail, so the stored value wins instead, and an absent one leaves the
@@ -165,13 +165,15 @@ impl TableSchema {
             Some(declared) => extra.insert(TABLE_TYPE_OPTION.to_string(), 
declared.clone()),
             None => extra.remove(TABLE_TYPE_OPTION),
         };
-        match self.options.get(INDEX_FILE_IN_DATA_FILE_DIR_OPTION) {
-            Some(stored) => extra.insert(
-                INDEX_FILE_IN_DATA_FILE_DIR_OPTION.to_string(),
-                stored.clone(),
-            ),
-            None => extra.remove(INDEX_FILE_IN_DATA_FILE_DIR_OPTION),
-        };
+        for key in [
+            INDEX_FILE_IN_DATA_FILE_DIR_OPTION,
+            "data-file.path-directory",
+        ] {
+            match self.options.get(key) {
+                Some(stored) => extra.insert(key.to_string(), stored.clone()),
+                None => extra.remove(key),
+            };
+        }
         let mut new_schema = self.clone();
         new_schema.options.extend(extra);
         new_schema
@@ -1213,6 +1215,7 @@ impl Schema {
         Self::validate_bucket_keys(options, fields, partition_keys, 
primary_keys)?;
         Self::validate_sequence_field(options, fields, partition_keys, 
primary_keys)?;
         Self::validate_read_batch_size(options)?;
+        CoreOptions::new(options).validate_data_file_path_directory()?;
         let core_options = CoreOptions::new(options);
         if !core_options.is_format_table() {
             core_options.target_file_row_num()?;
diff --git a/crates/paimon/src/table/bucket_assigner_dynamic.rs 
b/crates/paimon/src/table/bucket_assigner_dynamic.rs
index 57fbc48e..2919d599 100644
--- a/crates/paimon/src/table/bucket_assigner_dynamic.rs
+++ b/crates/paimon/src/table/bucket_assigner_dynamic.rs
@@ -167,6 +167,7 @@ struct HashIndexLayout<'a> {
     table_path: &'a str,
     /// Partition directory, already terminated by `/`, or empty when 
unpartitioned.
     partition_path: &'a str,
+    data_file_path_directory: Option<&'a str>,
     index_file_in_data_file_dir: bool,
 }
 
@@ -182,7 +183,8 @@ impl HashIndexLayout<'_> {
     }
 
     fn bucket_path(&self, bucket: i32) -> String {
-        bucket_path_under(self.table_path, self.partition_path, bucket)
+        let data_root = crate::spec::data_file_path(self.table_path, 
self.data_file_path_directory);
+        bucket_path_under(&data_root, self.partition_path, bucket)
     }
 
     /// The directory a new hash index file for `bucket` is written into.
@@ -373,6 +375,7 @@ pub(crate) struct DynamicBucketAssigner {
     target_bucket_row_number: i64,
     file_io: FileIO,
     table_location: String,
+    data_file_path_directory: Option<String>,
     /// Cached index manifest entries from the latest snapshot (loaded once).
     cached_index_entries: Option<Vec<IndexManifestEntry>>,
     /// Overwrite mode: skip loading existing index entries.
@@ -406,6 +409,7 @@ impl DynamicBucketAssigner {
             target_bucket_row_number,
             file_io,
             table_location,
+            data_file_path_directory: None,
             cached_index_entries: None,
             is_overwrite,
             partition_computer,
@@ -413,6 +417,11 @@ impl DynamicBucketAssigner {
         }
     }
 
+    pub(super) fn with_data_file_path_directory(mut self, directory: 
Option<&str>) -> Self {
+        self.data_file_path_directory = directory.map(str::to_string);
+        self
+    }
+
     pub fn set_overwrite(&mut self, is_overwrite: bool) {
         self.is_overwrite = is_overwrite;
     }
@@ -466,6 +475,7 @@ impl DynamicBucketAssigner {
         if !partition_entries.is_empty() {
             let partition_path = self.partition_path(partition_bytes)?;
             let layout = HashIndexLayout {
+                data_file_path_directory: 
self.data_file_path_directory.as_deref(),
                 table_path: self.table_location.trim_end_matches('/'),
                 partition_path: &partition_path,
                 index_file_in_data_file_dir: self.index_file_in_data_file_dir,
@@ -542,6 +552,7 @@ impl BucketAssigner for DynamicBucketAssigner {
         }
         for (partition_bytes, partition_path) in 
partition_keys.into_iter().zip(partition_paths) {
             let layout = HashIndexLayout {
+                data_file_path_directory: 
self.data_file_path_directory.as_deref(),
                 table_path: &table_path,
                 partition_path: &partition_path,
                 index_file_in_data_file_dir,
@@ -649,6 +660,7 @@ mod tests {
             let table_path = format!("file://{}", tmp.path().display());
             let file_io = 
FileIO::from_url(&table_path).unwrap().build().unwrap();
             let layout = super::HashIndexLayout {
+                data_file_path_directory: None,
                 table_path: &table_path,
                 partition_path: "pt=1/",
                 index_file_in_data_file_dir,
@@ -697,6 +709,7 @@ mod tests {
 
         for index_file_in_data_file_dir in [false, true] {
             let layout = super::HashIndexLayout {
+                data_file_path_directory: None,
                 table_path: &table_path,
                 partition_path: "pt=1/",
                 index_file_in_data_file_dir,
diff --git a/crates/paimon/src/table/data_evolution_writer.rs 
b/crates/paimon/src/table/data_evolution_writer.rs
index f070fe4e..85e21762 100644
--- a/crates/paimon/src/table/data_evolution_writer.rs
+++ b/crates/paimon/src/table/data_evolution_writer.rs
@@ -457,6 +457,7 @@ impl DataEvolutionWriter {
 /// write that follows it cannot disagree about where the file goes.
 struct DeletionVectorLayout {
     table_path: String,
+    relative_bucket_path: String,
     bucket_path: String,
     index_file_in_data_file_dir: bool,
 }
@@ -722,10 +723,19 @@ impl DataEvolutionDeleteWriter {
         } else {
             EMPTY_BINARY_ROW
         };
+        let partition_path = match &computer {
+            Some(computer) => 
computer.generate_partition_path(&partition_row)?,
+            None => String::new(),
+        };
         Ok(DeletionVectorLayout {
+            relative_bucket_path: crate::spec::relative_bucket_path(
+                &partition_path,
+                bucket,
+                core_options.data_file_path_directory(),
+            ),
             table_path: 
self.table.location().trim_end_matches('/').to_string(),
             bucket_path: bucket_path(
-                self.table.location(),
+                &self.table.data_file_location(),
                 computer.as_ref(),
                 &partition_row,
                 bucket,
@@ -806,10 +816,7 @@ impl DataEvolutionDeleteWriter {
         let external_path = super::external_path::new_index_external_path(
             self.table.schema().options(),
             layout.index_file_in_data_file_dir,
-            layout
-                .bucket_path
-                .strip_prefix(&format!("{}/", layout.table_path))
-                .expect("bucket path is under the table"),
+            &layout.relative_bucket_path,
             &file_name,
         )?;
         let path = layout
diff --git a/crates/paimon/src/table/data_file_writer.rs 
b/crates/paimon/src/table/data_file_writer.rs
index 97037d6d..d2c01381 100644
--- a/crates/paimon/src/table/data_file_writer.rs
+++ b/crates/paimon/src/table/data_file_writer.rs
@@ -378,7 +378,11 @@ impl DataFileWriter {
     }
 
     fn bucket_dir(&self) -> String {
-        bucket_path_under(&self.table_location, &self.partition_path, 
self.bucket)
+        let data_root = crate::spec::data_file_path(
+            &self.table_location,
+            CoreOptions::new(&self.format_options).data_file_path_directory(),
+        );
+        bucket_path_under(&data_root, &self.partition_path, self.bucket)
     }
 
     #[allow(clippy::too_many_arguments)]
diff --git a/crates/paimon/src/table/dedicated_format_file_writer.rs 
b/crates/paimon/src/table/dedicated_format_file_writer.rs
index 4e6102d2..fb833dfc 100644
--- a/crates/paimon/src/table/dedicated_format_file_writer.rs
+++ b/crates/paimon/src/table/dedicated_format_file_writer.rs
@@ -117,7 +117,7 @@ impl AppendDedicatedFormatFileWriter {
                         write_buffer_size,
                         "blob".to_string(),
                         vec![field.clone()],
-                        HashMap::new(),
+                        format_options.clone(),
                         Some(0),
                         None,
                         Some(vec![field.name().to_string()]),
diff --git a/crates/paimon/src/table/external_path.rs 
b/crates/paimon/src/table/external_path.rs
index 2b694d15..9d7b730f 100644
--- a/crates/paimon/src/table/external_path.rs
+++ b/crates/paimon/src/table/external_path.rs
@@ -134,7 +134,7 @@ impl ExternalPathProvider {
         };
         Ok(Some(Self {
             paths: roots,
-            bucket: bucket.trim_matches('/').into(),
+            bucket: bucket.to_string(),
             position,
             entropy,
             cumulative_weights,
@@ -150,11 +150,10 @@ impl ExternalPathProvider {
             self.position = (self.position + 1) % self.paths.len();
             self.position
         };
-        let mut path = self.paths[index].clone();
-        if !self.bucket.is_empty() {
-            path.push('/');
-            path.push_str(&self.bucket);
-        }
+        let mut path = crate::spec::data_file_path(
+            &self.paths[index],
+            (!self.bucket.is_empty()).then_some(self.bucket.as_str()),
+        );
         if self.entropy {
             let hash =
                 
crate::spec::murmur_hash::hash_bytes_guava(file_name.as_bytes()) as u32 & 
0xfffff;
@@ -195,8 +194,8 @@ mod tests {
             .unwrap()
             .unwrap();
         let paths = [provider.next_path("index-1"), 
provider.next_path("index-1")];
-        assert!(paths.contains(&"file:///a/p=x/bucket-0/index-1".into()));
-        assert!(paths.contains(&"file:///b/p=x/bucket-0/index-1".into()));
+        assert!(paths.contains(&"file:/a/p=x/bucket-0/index-1".into()));
+        assert!(paths.contains(&"file:/b/p=x/bucket-0/index-1".into()));
         assert_eq!(
             new_index_external_path(&settings, false, "p=x/bucket-0", 
"index-1")
                 .unwrap()
@@ -210,6 +209,26 @@ mod tests {
         );
     }
 
+    #[test]
+    fn configured_directories_resolve_against_external_roots() {
+        for strategy in ["round-robin", "weight-robin", "entropy-inject"] {
+            for (bucket, expected) in [
+                ("/shared/bucket-0", "file:/shared/bucket-0/"),
+                (
+                    "s3://other:9000/shared/bucket-0",
+                    "s3://other:9000/shared/bucket-0/",
+                ),
+            ] {
+                let mut provider = 
ExternalPathProvider::new(&options(strategy), bucket)
+                    .unwrap()
+                    .unwrap();
+                let path = provider.next_path("index-1");
+                assert!(path.starts_with(expected), "{strategy}: {path}");
+                assert!(path.ends_with("/index-1"));
+            }
+        }
+    }
+
     #[test]
     fn specific_fs_and_invalid_options() {
         let mut options = options("specific-fs");
@@ -224,7 +243,7 @@ mod tests {
                 .unwrap()
                 .unwrap()
                 .next_path("index-1"),
-            "file:///a/bucket-0/index-1"
+            "file:/a/bucket-0/index-1"
         );
         options.insert("data-file.external-paths.specific-fs".into(), 
"oss".into());
         assert!(ExternalPathProvider::new(&options, "").is_err());
@@ -253,7 +272,7 @@ mod tests {
             .unwrap();
         assert_eq!(provider.cumulative_weights, vec![1, 3]);
         for _ in 0..10 {
-            assert!(["file:///a/bucket-0/index", "file:///b/bucket-0/index"]
+            assert!(["file:/a/bucket-0/index", "file:/b/bucket-0/index"]
                 .contains(&provider.next_path("index").as_str()));
         }
     }
@@ -284,11 +303,11 @@ mod tests {
         // Guava murmur3_32(0), UTF-8 "hello": 0x248bfa47.
         assert_eq!(
             provider.next_path("hello"),
-            "file:///b/p=x/bucket-0/1011/1111/1010/01000111/hello"
+            "file:/b/p=x/bucket-0/1011/1111/1010/01000111/hello"
         );
         assert_eq!(
             provider.next_path("hello"),
-            "file:///a/p=x/bucket-0/1011/1111/1010/01000111/hello"
+            "file:/a/p=x/bucket-0/1011/1111/1010/01000111/hello"
         );
     }
 }
diff --git a/crates/paimon/src/table/global_index_build_common/vector.rs 
b/crates/paimon/src/table/global_index_build_common/vector.rs
index 72a37d68..a226597d 100644
--- a/crates/paimon/src/table/global_index_build_common/vector.rs
+++ b/crates/paimon/src/table/global_index_build_common/vector.rs
@@ -240,7 +240,9 @@ fn bucket_path(
     partition: &BinaryRow,
     bucket: i32,
 ) -> Result<String> {
-    let base = table_location.trim_end_matches('/');
+    let data_root =
+        crate::spec::data_file_path(table_location, 
core_options.data_file_path_directory());
+    let base = data_root.trim_end_matches('/');
     if partition_keys.is_empty() {
         return Ok(format!("{base}/{}", bucket_dir_name(bucket)));
     }
diff --git a/crates/paimon/src/table/index_file_path.rs 
b/crates/paimon/src/table/index_file_path.rs
index a4a3cbd4..8523e03e 100644
--- a/crates/paimon/src/table/index_file_path.rs
+++ b/crates/paimon/src/table/index_file_path.rs
@@ -150,7 +150,7 @@ pub(crate) async fn resolve_legacy_deletion_vector_entries(
         }
         let partition = BinaryRow::from_serialized_bytes(&entry.partition)?;
         let bucket_path = bucket_path(
-            table_path,
+            &table.data_file_location(),
             partition_computer.as_ref(),
             &partition,
             entry.bucket,
diff --git a/crates/paimon/src/table/kv_file_writer.rs 
b/crates/paimon/src/table/kv_file_writer.rs
index 000a6057..ebef74ab 100644
--- a/crates/paimon/src/table/kv_file_writer.rs
+++ b/crates/paimon/src/table/kv_file_writer.rs
@@ -491,7 +491,10 @@ impl KeyValueFileWriter {
             write.file_format,
         );
         let bucket_dir = bucket_path_under(
-            &self.config.table_location,
+            &crate::spec::data_file_path(
+                &self.config.table_location,
+                
CoreOptions::new(&self.config.table_options).data_file_path_directory(),
+            ),
             &self.config.partition_path,
             self.config.bucket,
         );
@@ -1173,7 +1176,10 @@ impl KeyValueFileWriter {
             let _ = reservation.try_resize(0);
         }
         let bucket_path = bucket_path_under(
-            &self.config.table_location,
+            &crate::spec::data_file_path(
+                &self.config.table_location,
+                
CoreOptions::new(&self.config.table_options).data_file_path_directory(),
+            ),
             &self.config.partition_path,
             self.config.bucket,
         );
diff --git a/crates/paimon/src/table/managed_blob_writer.rs 
b/crates/paimon/src/table/managed_blob_writer.rs
index 886a58af..d53be232 100644
--- a/crates/paimon/src/table/managed_blob_writer.rs
+++ b/crates/paimon/src/table/managed_blob_writer.rs
@@ -102,7 +102,10 @@ impl ManagedBlobWriteState {
         let options = CoreOptions::new(&config.table_options);
         let writer = ManagedBlobWriter::new(
             file_io.clone(),
-            &config.table_location,
+            &crate::spec::data_file_path(
+                &config.table_location,
+                options.data_file_path_directory(),
+            ),
             &config.partition_path,
             config.bucket,
             &config.data_file_prefix,
diff --git a/crates/paimon/src/table/mod.rs b/crates/paimon/src/table/mod.rs
index c195deda..5cdf174a 100644
--- a/crates/paimon/src/table/mod.rs
+++ b/crates/paimon/src/table/mod.rs
@@ -318,6 +318,14 @@ impl Table {
         &self.location
     }
 
+    /// Root for bucket data. Metadata continues to use `location()`.
+    pub(crate) fn data_file_location(&self) -> String {
+        crate::spec::data_file_path(
+            self.location(),
+            self.schema().core_options().data_file_path_directory(),
+        )
+    }
+
     /// Get the table's schema.
     pub fn schema(&self) -> &TableSchema {
         &self.schema
diff --git a/crates/paimon/src/table/read_builder.rs 
b/crates/paimon/src/table/read_builder.rs
index 75183365..d3a308b4 100644
--- a/crates/paimon/src/table/read_builder.rs
+++ b/crates/paimon/src/table/read_builder.rs
@@ -553,6 +553,7 @@ impl<'a> PaimonReadBuilder<'a> {
         // `to_arrow` (e.g. an empty-splits fast path) can't bypass the guard.
         let core_options = self.table.schema.core_options();
         core_options.ensure_read_authorized()?;
+        core_options.validate_data_file_path_directory()?;
         let read_type = match self.resolve_read_type()? {
             None => self.table.schema.fields().to_vec(),
             Some(fields) => fields,
diff --git a/crates/paimon/src/table/referenced_files.rs 
b/crates/paimon/src/table/referenced_files.rs
index 812cf566..9b5470c4 100644
--- a/crates/paimon/src/table/referenced_files.rs
+++ b/crates/paimon/src/table/referenced_files.rs
@@ -29,8 +29,8 @@ use std::sync::{
 
 use crate::io::FileIO;
 use crate::spec::{
-    bucket_dir_name, BinaryRow, DataField, DataFileMeta, IndexManifest, 
Manifest, ManifestEntry,
-    ManifestFileMeta, PartitionComputer,
+    bucket_dir_name, BinaryRow, CoreOptions, DataField, DataFileMeta, 
IndexManifest, Manifest,
+    ManifestEntry, ManifestFileMeta, PartitionComputer,
 };
 use crate::table::{BranchManager, SnapshotManager, TagManager};
 use futures::future::try_join_all;
@@ -187,10 +187,40 @@ pub async fn collect_referenced_files_summary(
     table_location: &str,
     partition_keys: &[String],
     schema_fields: &[DataField],
+) -> crate::Result<Vec<ReferencedFilesSummary>> {
+    collect_referenced_files_summary_with_options(
+        file_io,
+        table_location,
+        partition_keys,
+        schema_fields,
+        &CoreOptions::new(&HashMap::new()),
+    )
+    .await
+}
+
+/// Collect referenced sizes using the table's data-directory and partition 
options.
+pub async fn collect_referenced_files_summary_with_options(
+    file_io: &FileIO,
+    table_location: &str,
+    partition_keys: &[String],
+    schema_fields: &[DataField],
+    options: &CoreOptions<'_>,
 ) -> crate::Result<Vec<ReferencedFilesSummary>> {
     let manifest_cache: ManifestCache = Mutex::new(HashMap::new());
     let manifest_cache_ref = &manifest_cache;
-    let extra_resolver = ExtraFileResolver::new(table_location, 
partition_keys, schema_fields);
+    let mut extra_resolver = ExtraFileResolver::new(
+        &crate::spec::data_file_path(table_location, 
options.data_file_path_directory()),
+        partition_keys,
+        schema_fields,
+    );
+    if !partition_keys.is_empty() {
+        extra_resolver.partition_computer = Some(PartitionComputer::new(
+            partition_keys,
+            schema_fields,
+            options.partition_default_name(),
+            options.legacy_partition_name(),
+        )?);
+    }
     let extra_resolver_ref = &extra_resolver;
 
     let sm = SnapshotManager::new(file_io.clone(), table_location.to_string());
@@ -728,62 +758,31 @@ fn is_index_file_in_bucket(segments: &[&str], 
partition_depth: usize) -> bool {
         && is_bucket_index_file_name(segments[partition_depth + 1])
 }
 
-fn is_data_file_in_data_dir(
-    relative_path: &str,
-    data_dir_relative_path: &str,
-    partition_depth: usize,
-) -> bool {
-    let data_dir_relative_path = data_dir_relative_path.trim_matches('/');
-    let data_relative_path = if data_dir_relative_path.is_empty() {
-        relative_path
-    } else {
-        let Some(rest) = relative_path
-            .strip_prefix(data_dir_relative_path)
-            .and_then(|rest| rest.strip_prefix('/'))
-        else {
-            return false;
-        };
-        rest
-    };
-    let segments = data_relative_path.split('/').collect::<Vec<_>>();
-    is_data_file_in_bucket(&segments, partition_depth)
-}
-
 fn classify_physical_path(
     table_location: &str,
     path: &str,
     partition_depth: usize,
     data_file_path_directory: Option<&str>,
 ) -> PhysicalFileKind {
-    let Some(relative_path) = table_relative_path(table_location, path) else {
+    let data_root = crate::spec::data_file_path(table_location, 
data_file_path_directory);
+    if let Some(relative) = table_relative_path(&data_root, path) {
+        let segments: Vec<_> = relative.split('/').collect();
+        if is_index_file_in_bucket(&segments, partition_depth) {
+            return PhysicalFileKind::Index;
+        }
+        if is_data_file_in_bucket(&segments, partition_depth) {
+            return PhysicalFileKind::Data;
+        }
+    }
+    let Some(relative) = table_relative_path(table_location, path) else {
         return PhysicalFileKind::Other;
     };
-    let relative_path = relative_path.trim_matches('/');
-    if relative_path.is_empty() {
-        return PhysicalFileKind::Other;
-    }
-
-    let segments = relative_path.split('/').collect::<Vec<_>>();
-
+    let segments: Vec<_> = relative.split('/').collect();
     match segments.as_slice() {
         ["manifest", name] if is_manifest_file_name(name) => 
PhysicalFileKind::Manifest,
         ["statistics", _] => PhysicalFileKind::Statistics,
         ["index", _] => PhysicalFileKind::Index,
-        _ if is_index_file_in_bucket(&segments, partition_depth) => 
PhysicalFileKind::Index,
-        _ => {
-            if let Some(data_dir) = data_file_path_directory {
-                let data_dir = table_relative_path(table_location, 
data_dir).unwrap_or(data_dir);
-                if is_data_file_in_data_dir(relative_path, data_dir, 
partition_depth) {
-                    PhysicalFileKind::Data
-                } else {
-                    PhysicalFileKind::Other
-                }
-            } else if is_data_file_in_bucket(&segments, partition_depth) {
-                PhysicalFileKind::Data
-            } else {
-                PhysicalFileKind::Other
-            }
-        }
+        _ => PhysicalFileKind::Other,
     }
 }
 
@@ -803,13 +802,31 @@ pub async fn collect_physical_files_summary(
     table_location: &str,
     partition_depth: usize,
 ) -> crate::Result<PhysicalFilesSummary> {
+    collect_physical_files_summary_with_options(
+        file_io,
+        table_location,
+        partition_depth,
+        &CoreOptions::new(&HashMap::new()),
+    )
+    .await
+}
+
+/// Include the configured data directory, including directories outside the 
table root.
+pub async fn collect_physical_files_summary_with_options(
+    file_io: &FileIO,
+    table_location: &str,
+    partition_depth: usize,
+    options: &CoreOptions<'_>,
+) -> crate::Result<PhysicalFilesSummary> {
+    let data_directory = options.data_file_path_directory();
+    let data_root = crate::spec::data_file_path(table_location, 
data_directory);
     // List top-level entries to discover subdirectories and top-level files
     let top_entries = match file_io.list_status(table_location).await {
         Ok(s) => s,
         Err(crate::Error::IoUnexpected { ref source, .. })
             if source.kind() == opendal::ErrorKind::NotFound =>
         {
-            return Ok(PhysicalFilesSummary::default());
+            Vec::new()
         }
         Err(e) => return Err(e),
     };
@@ -818,16 +835,21 @@ pub async fn collect_physical_files_summary(
 
     // Classify top-level files directly
     let mut sub_dirs = Vec::new();
+    if table_relative_path(table_location, &data_root).is_none() {
+        sub_dirs.push(data_root);
+    }
+    let mut seen = HashSet::new();
     for entry in &top_entries {
         if entry.is_dir {
             sub_dirs.push(entry.path.clone());
-        } else {
+        } else if seen.insert(entry.path.clone()) {
             accumulate_file(
                 &mut summary,
                 table_location,
                 &entry.path,
                 partition_depth,
                 entry.size,
+                data_directory,
             );
         }
     }
@@ -852,12 +874,16 @@ pub async fn collect_physical_files_summary(
     for result in dir_results {
         let statuses = result?;
         for status in &statuses {
+            if !seen.insert(status.path.clone()) {
+                continue;
+            }
             accumulate_file(
                 &mut summary,
                 table_location,
                 &status.path,
                 partition_depth,
                 status.size,
+                data_directory,
             );
         }
     }
@@ -871,8 +897,9 @@ fn accumulate_file(
     path: &str,
     partition_depth: usize,
     size: u64,
+    data_directory: Option<&str>,
 ) {
-    match classify_physical_path(table_location, path, partition_depth, None) {
+    match classify_physical_path(table_location, path, partition_depth, 
data_directory) {
         PhysicalFileKind::Manifest | PhysicalFileKind::Statistics => {
             summary.manifest_file_count += 1;
             summary.manifest_file_size += size as i64;
@@ -891,6 +918,49 @@ fn accumulate_file(
 
 #[cfg(test)]
 mod tests {
+    #[tokio::test]
+    async fn configured_directory_counts_data_and_bucket_indexes_once() {
+        for directory in ["data/nested", "memory:/outside-data", ".."] {
+            let io = test_file_io();
+            let table_path = "memory:/sizes/table";
+            let options =
+                HashMap::from([("data-file.path-directory".into(), 
directory.to_string())]);
+            let root = crate::spec::data_file_path(table_path, 
Some(directory));
+            write_test_file(&io, &format!("{table_path}/manifest/manifest-1"), 
"meta").await;
+            write_test_file(
+                &io,
+                &format!("{root}/pt=a/bucket-0/data.parquet"),
+                "payload",
+            )
+            .await;
+            write_test_file(&io, &format!("{root}/pt=a/bucket-0/index-dv"), 
"dv").await;
+            write_test_file(&io, &format!("{table_path}/bucket-0/unrelated"), 
"ignore").await;
+            let sizes = collect_physical_files_summary_with_options(
+                &io,
+                table_path,
+                1,
+                &CoreOptions::new(&options),
+            )
+            .await
+            .unwrap();
+            assert_eq!(
+                (sizes.manifest_file_count, sizes.manifest_file_size),
+                (1, 4),
+                "{directory}"
+            );
+            assert_eq!(
+                (sizes.data_file_count, sizes.data_file_size),
+                (1, 7),
+                "{directory}"
+            );
+            assert_eq!(
+                (sizes.index_file_count, sizes.index_file_size),
+                (1, 2),
+                "{directory}"
+            );
+        }
+    }
+
     use super::*;
     use crate::io::FileIOBuilder;
     use crate::spec::{CommitKind, Snapshot};
diff --git 
a/crates/paimon/src/table/sorted_global_index_build_builder/planning.rs 
b/crates/paimon/src/table/sorted_global_index_build_builder/planning.rs
index 7c52fd80..7e487b11 100644
--- a/crates/paimon/src/table/sorted_global_index_build_builder/planning.rs
+++ b/crates/paimon/src/table/sorted_global_index_build_builder/planning.rs
@@ -227,7 +227,9 @@ fn bucket_path(
     partition: &BinaryRow,
     bucket: i32,
 ) -> Result<String> {
-    let base = table_location.trim_end_matches('/');
+    let data_root =
+        crate::spec::data_file_path(table_location, 
core_options.data_file_path_directory());
+    let base = data_root.trim_end_matches('/');
     if partition_keys.is_empty() {
         return Ok(format!("{base}/{}", bucket_dir_name(bucket)));
     }
diff --git a/crates/paimon/src/table/table_commit.rs 
b/crates/paimon/src/table/table_commit.rs
index ec3049dc..e56b9477 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -943,7 +943,11 @@ impl TableCommit {
             // An unpartitioned table's buckets sit directly under the table 
path,
             // so the partition blob is never decoded — callers are free to 
pass an
             // empty one.
-            return Ok(bucket_path_under(self.table.location(), "", bucket));
+            return Ok(bucket_path_under(
+                &self.table.data_file_location(),
+                "",
+                bucket,
+            ));
         }
         let core_options = CoreOptions::new(self.table.schema().options());
         let computer = PartitionComputer::new(
@@ -953,7 +957,7 @@ impl TableCommit {
             core_options.legacy_partition_name(),
         )?;
         bucket_path(
-            self.table.location(),
+            &self.table.data_file_location(),
             Some(&computer),
             &BinaryRow::from_serialized_bytes(partition)?,
             bucket,
diff --git a/crates/paimon/src/table/table_scan.rs 
b/crates/paimon/src/table/table_scan.rs
index e975dda7..4165b592 100644
--- a/crates/paimon/src/table/table_scan.rs
+++ b/crates/paimon/src/table/table_scan.rs
@@ -1498,7 +1498,7 @@ impl<'a> PaimonTableScan<'a> {
     /// for `scan.version`; the strict selectors mirror Java's typed
     /// `scan.snapshot-id` / `scan.tag-name` handling.
     pub async fn plan(&self) -> crate::Result<Plan> {
-        self.ensure_query_auth_allowed()?;
+        self.validate_read_options()?;
         self.validate_shard_strategy()?;
         let data_evolution_read_field_ids = self.projected_read_field_ids()?;
         let snapshot = match 
super::time_travel::resolve_snapshot(self.table).await? {
@@ -1511,7 +1511,7 @@ impl<'a> PaimonTableScan<'a> {
 
     /// Plan the full scan and return metadata-pruning trace counters.
     pub async fn plan_with_trace(&self) -> crate::Result<(Plan, ScanTrace)> {
-        self.ensure_query_auth_allowed()?;
+        self.validate_read_options()?;
         self.validate_shard_strategy()?;
         let mut trace = ScanTrace {
             limit: self.limit,
@@ -1537,8 +1537,10 @@ impl<'a> PaimonTableScan<'a> {
     /// Fail closed for a `query-auth.enabled` table: scan planning — including
     /// `with_scan_all_files`, which read-facing system tables like `files` 
use —
     /// exposes file paths, row counts, and stats the client can't authorize.
-    fn ensure_query_auth_allowed(&self) -> crate::Result<()> {
-        
CoreOptions::new(self.table.schema().options()).ensure_read_authorized()
+    fn validate_read_options(&self) -> crate::Result<()> {
+        let options = CoreOptions::new(self.table.schema().options());
+        options.ensure_read_authorized()?;
+        options.validate_data_file_path_directory()
     }
 
     fn validate_shard_strategy(&self) -> crate::Result<()> {
@@ -1886,7 +1888,7 @@ impl<'a> PaimonTableScan<'a> {
     /// Reuses the same split-building path as a full snapshot plan, but only
     /// reads the delta manifest list and keeps ADD entries.
     pub(crate) async fn plan_snapshot_delta(&self, snapshot: &Snapshot) -> 
crate::Result<Plan> {
-        self.ensure_query_auth_allowed()?;
+        self.validate_read_options()?;
         let data_evolution_read_field_ids = self.projected_read_field_ids()?;
         let mut scan = self.clone();
         scan.incremental_split_mode = Some(IncrementalSplitMode::Streaming);
@@ -1905,7 +1907,7 @@ impl<'a> PaimonTableScan<'a> {
         snapshots: &[Snapshot],
         end_snapshot: &Snapshot,
     ) -> crate::Result<Plan> {
-        self.ensure_query_auth_allowed()?;
+        self.validate_read_options()?;
         self.validate_shard_strategy()?;
         let data_evolution_read_field_ids = self.projected_read_field_ids()?;
         let mut scan = self.clone();
@@ -1925,7 +1927,7 @@ impl<'a> PaimonTableScan<'a> {
     /// reads the changelog manifest list and keeps ADD entries. Snapshots
     /// without a changelog list yield an empty plan.
     pub(crate) async fn plan_snapshot_changelog(&self, snapshot: &Snapshot) -> 
crate::Result<Plan> {
-        self.ensure_query_auth_allowed()?;
+        self.validate_read_options()?;
         let Some(list_name) = snapshot.changelog_manifest_list() else {
             return Ok(Plan::new(Vec::new()).with_snapshot_id(snapshot.id()));
         };
@@ -2008,7 +2010,7 @@ impl<'a> PaimonTableScan<'a> {
         before: &Snapshot,
         after: &Snapshot,
     ) -> crate::Result<(Plan, Plan)> {
-        self.ensure_query_auth_allowed()?;
+        self.validate_read_options()?;
         let core_options = CoreOptions::new(self.table.schema().options());
         // Both forms: without `first_row_id` a filter stays an ordinary data
         // predicate instead of becoming a row range.
@@ -2464,7 +2466,7 @@ impl<'a> PaimonTableScan<'a> {
         'groups: for ((partition, bucket), (total_buckets, data_files)) in 
groups {
             let partition_row = BinaryRow::from_serialized_bytes(&partition)?;
             let bucket_path = bucket_path(
-                base_path,
+                &self.table.data_file_location(),
                 partition_computer.as_ref(),
                 &partition_row,
                 bucket,
diff --git a/crates/paimon/src/table/table_write.rs 
b/crates/paimon/src/table/table_write.rs
index 1f28cc6d..4e78c42e 100644
--- a/crates/paimon/src/table/table_write.rs
+++ b/crates/paimon/src/table/table_write.rs
@@ -245,6 +245,7 @@ impl TableWrite {
         // A dynamic-bucket write reads the persisted PK hash index; the rest 
are
         // refused too, since their commit is blocked anyway.
         CoreOptions::new(table.schema().options()).ensure_read_authorized()?;
+        
CoreOptions::new(table.schema().options()).validate_data_file_path_directory()?;
         let is_overwrite = false;
         let schema = table.schema();
         let write_schema = build_target_arrow_schema(schema.fields())?;
@@ -405,20 +406,23 @@ impl TableWrite {
                 merge_engine,
             )))
         } else if is_dynamic_bucket {
-            BucketAssignerEnum::Dynamic(Box::new(DynamicBucketAssigner::new(
-                partition_field_indices,
-                primary_key_indices.clone(),
-                schema.fields().to_vec(),
-                target_bucket_row_number,
-                table.file_io().clone(),
-                table.location().to_string(),
-                is_overwrite,
-                // The same computer this writer already built: a hash index 
kept in
-                // the data-file directory must land in the directory the 
writer and
-                // the reader both derive, so both must agree on partition 
naming.
-                partition_computer.clone(),
-                core_options.index_file_in_data_file_dir(),
-            )))
+            BucketAssignerEnum::Dynamic(Box::new(
+                DynamicBucketAssigner::new(
+                    partition_field_indices,
+                    primary_key_indices.clone(),
+                    schema.fields().to_vec(),
+                    target_bucket_row_number,
+                    table.file_io().clone(),
+                    table.location().to_string(),
+                    is_overwrite,
+                    // The same computer this writer already built: a hash 
index kept in
+                    // the data-file directory must land in the directory the 
writer and
+                    // the reader both derive, so both must agree on partition 
naming.
+                    partition_computer.clone(),
+                    core_options.index_file_in_data_file_dir(),
+                )
+                
.with_data_file_path_directory(core_options.data_file_path_directory()),
+            ))
         } else if total_buckets == POSTPONE_BUCKET {
             BucketAssignerEnum::Constant(ConstantBucketAssigner::new(
                 partition_field_indices,
@@ -1202,7 +1206,7 @@ impl TableWrite {
             PostponeFileWriter::new(
                 self.table.file_io().clone(),
                 PostponeWriteConfig {
-                    table_location: self.table.location().to_string(),
+                    table_location: self.table.data_file_location(),
                     partition_path,
                     bucket,
                     schema_id: self.schema_id,
diff --git a/crates/paimon/tests/data_file_directory_test.rs 
b/crates/paimon/tests/data_file_directory_test.rs
new file mode 100644
index 00000000..e5847ab3
--- /dev/null
+++ b/crates/paimon/tests/data_file_directory_test.rs
@@ -0,0 +1,470 @@
+// 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.
+
+mod common;
+
+use arrow_array::{ArrayRef, Int32Array, RecordBatch, StringArray};
+use common::incremental_helpers::{memory_table, persist_table_schema, 
setup_dirs, write_batch};
+use futures::TryStreamExt;
+use paimon::spec::{DataType, IntType, Schema, TableSchema, VarCharType};
+use paimon::table::Table;
+use std::sync::Arc;
+
+async fn table(primary_key: bool, evolution: bool, directory: &str, 
bucket_indexes: bool) -> Table {
+    let path = "memory:/directory_test";
+    let mut schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("pt", DataType::VarChar(VarCharType::string_type()))
+        .column("value", DataType::Int(IntType::new()))
+        .partition_keys(["pt"])
+        .option("data-file.path-directory", directory)
+        .option("target-file-row-num", "2");
+    if primary_key {
+        schema = schema
+            .primary_key(["id", "pt"])
+            .option("bucket", "1")
+            .option("changelog-producer", "input");
+    }
+    if evolution {
+        schema = schema
+            .option("row-tracking.enabled", "true")
+            .option("data-evolution.enabled", "true")
+            .option("deletion-vectors.enabled", "true")
+            .option("index-file-in-data-file-dir", bucket_indexes.to_string());
+    }
+    let (io, table) = memory_table(path, TableSchema::new(0, 
&schema.build().unwrap()));
+    setup_dirs(&io, path).await;
+    persist_table_schema(&io, path, table.schema()).await;
+    table
+}
+
+fn batch(ids: Vec<i32>, parts: Vec<Option<&str>>, values: Vec<i32>) -> 
RecordBatch {
+    RecordBatch::try_from_iter([
+        ("id", Arc::new(Int32Array::from(ids)) as ArrayRef),
+        ("pt", Arc::new(StringArray::from(parts)) as ArrayRef),
+        ("value", Arc::new(Int32Array::from(values)) as ArrayRef),
+    ])
+    .unwrap()
+}
+
+async fn values(table: &Table) -> Vec<i32> {
+    let builder = table.new_read_builder();
+    let plan = builder.new_scan().plan().await.unwrap();
+    for split in plan.splits() {
+        assert!(
+            split.bucket_path().contains("/data/nested/pt="),
+            "{}",
+            split.bucket_path()
+        );
+        for file in split.data_files() {
+            assert!(table
+                .file_io()
+                .exists(&file.data_file_path(split.bucket_path()))
+                .await
+                .unwrap());
+        }
+    }
+    let batches: Vec<RecordBatch> = builder
+        .new_read()
+        .unwrap()
+        .to_arrow(plan.splits())
+        .unwrap()
+        .try_collect()
+        .await
+        .unwrap();
+    let mut result: Vec<i32> = batches
+        .iter()
+        .flat_map(|batch| {
+            batch
+                .column_by_name("value")
+                .unwrap()
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap()
+                .values()
+                .to_vec()
+        })
+        .collect();
+    result.sort();
+    result
+}
+
+#[tokio::test]
+async fn configured_directory_contains_append_and_primary_key_files() {
+    for primary_key in [false, true] {
+        let table = table(primary_key, false, "data/nested", false).await;
+        write_batch(
+            &table,
+            &batch(
+                vec![1, 2, 3],
+                vec![
+                    Some("a/b"),
+                    Some("a/b"),
+                    if primary_key { Some("c") } else { None },
+                ],
+                vec![10, 20, 30],
+            ),
+        )
+        .await;
+        assert_eq!(values(&table).await, vec![10, 20, 30]);
+        assert!(table
+            .file_io()
+            .exists("memory:/directory_test/snapshot/snapshot-1")
+            .await
+            .unwrap());
+        assert!(!table
+            .file_io()
+            .exists("memory:/directory_test/data/nested/snapshot/snapshot-1")
+            .await
+            .unwrap());
+    }
+}
+
+#[tokio::test]
+async fn relocated_updates_upserts_and_repeated_deletes_share_paths() {
+    for bucket_indexes in [false, true] {
+        let table = table(false, true, "data/nested", bucket_indexes).await;
+        write_batch(
+            &table,
+            &batch(vec![1, 2, 3], vec![Some("a"); 3], vec![10, 20, 30]),
+        )
+        .await;
+        let mut update = table.new_write_builder().new_update().unwrap();
+        update.with_update_type(vec!["value".into()]).unwrap();
+        let input = RecordBatch::try_from_iter([
+            (
+                "_ROW_ID",
+                Arc::new(arrow_array::Int64Array::from(vec![0])) as ArrayRef,
+            ),
+            ("value", Arc::new(Int32Array::from(vec![11])) as ArrayRef),
+        ])
+        .unwrap();
+        let messages = update
+            .update_by_arrow_with_row_id(vec![input])
+            .await
+            .unwrap();
+        table
+            .new_write_builder()
+            .new_commit()
+            .commit(messages)
+            .await
+            .unwrap();
+        assert_eq!(values(&table).await, vec![11, 20, 30]);
+        let messages = update
+            .upsert_by_arrow_with_key(
+                vec![batch(vec![2, 4], vec![Some("a"), Some("b")], vec![22, 
40])],
+                vec!["id".into()],
+            )
+            .await
+            .unwrap();
+        table
+            .new_write_builder()
+            .new_commit()
+            .commit(messages)
+            .await
+            .unwrap();
+        assert_eq!(values(&table).await, vec![11, 22, 30, 40]);
+        for (row_id, expected) in [(0, vec![22, 30, 40]), (1, vec![30, 40])] {
+            let messages = 
update.delete_by_row_id(vec![row_id]).await.unwrap();
+            for message in &messages {
+                for index in &message.new_index_files {
+                    let directory = if bucket_indexes {
+                        "data/nested/pt=a/bucket-0"
+                    } else {
+                        "index"
+                    };
+                    assert!(table
+                        .file_io()
+                        .exists(&format!(
+                            "{}/{directory}/{}",
+                            table.location(),
+                            index.file_name
+                        ))
+                        .await
+                        .unwrap());
+                }
+            }
+            table
+                .new_write_builder()
+                .new_commit()
+                .commit(messages)
+                .await
+                .unwrap();
+            assert_eq!(values(&table).await, expected);
+        }
+        // Abort deletes only newly staged files, leaving the committed DV 
live.
+        let messages = update.delete_by_row_id(vec![2]).await.unwrap();
+        let paths: Vec<String> = messages
+            .iter()
+            .flat_map(|message| {
+                message.new_index_files.iter().map(|index| {
+                    let directory = if bucket_indexes {
+                        "data/nested/pt=a/bucket-0"
+                    } else {
+                        "index"
+                    };
+                    format!("{}/{directory}/{}", table.location(), 
index.file_name)
+                })
+            })
+            .collect();
+        assert!(!paths.is_empty());
+        table
+            .new_write_builder()
+            .new_commit()
+            .abort(&messages)
+            .await
+            .unwrap();
+        for path in paths {
+            assert!(!table.file_io().exists(&path).await.unwrap());
+        }
+        assert_eq!(values(&table).await, vec![30, 40]);
+    }
+}
+
+#[tokio::test]
+async fn abort_removes_relocated_data_and_changelog_files() {
+    for primary_key in [false, true] {
+        let table = table(primary_key, false, "data/nested", false).await;
+        let mut writer = table.new_write_builder().new_write().unwrap();
+        writer
+            .write_arrow_batch(&batch(vec![1], vec![Some("a")], vec![10]))
+            .await
+            .unwrap();
+        let messages = writer.prepare_commit().await.unwrap();
+        let mut paths = Vec::new();
+        for message in &messages {
+            if primary_key {
+                assert!(!message.new_changelog_files.is_empty());
+            }
+            for file in 
message.new_files.iter().chain(&message.new_changelog_files) {
+                let path = format!(
+                    "{}/data/nested/pt=a/bucket-{}/{}",
+                    table.location(),
+                    message.bucket,
+                    file.file_name
+                );
+                assert!(table.file_io().exists(&path).await.unwrap());
+                paths.push(path);
+            }
+        }
+        assert!(!paths.is_empty());
+        table
+            .new_write_builder()
+            .new_commit()
+            .abort(&messages)
+            .await
+            .unwrap();
+        for path in paths {
+            assert!(!table.file_io().exists(&path).await.unwrap());
+        }
+        assert!(table
+            .snapshot_manager()
+            .get_latest_snapshot()
+            .await
+            .unwrap()
+            .is_none());
+    }
+}
+
+#[tokio::test]
+async fn managed_blob_packs_and_parquet_share_relocated_bucket() {
+    use arrow_array::{Array, LargeBinaryArray};
+    use paimon::spec::BlobType;
+    for primary_key in [false, true] {
+        let path = "memory:/blob_directory";
+        let mut builder = Schema::builder()
+            .column("id", DataType::Int(IntType::new()))
+            .column("payload", DataType::Blob(BlobType::new()))
+            .option("data-file.path-directory", "data/nested");
+        if primary_key {
+            builder = builder.primary_key(["id"]).option("bucket", "1");
+        } else {
+            builder = builder
+                .option("row-tracking.enabled", "true")
+                .option("data-evolution.enabled", "true");
+        }
+        let (io, table) = memory_table(path, TableSchema::new(0, 
&builder.build().unwrap()));
+        setup_dirs(&io, path).await;
+        persist_table_schema(&io, path, table.schema()).await;
+        let input = RecordBatch::try_from_iter([
+            ("id", Arc::new(Int32Array::from(vec![1, 2, 3])) as ArrayRef),
+            (
+                "payload",
+                Arc::new(LargeBinaryArray::from(vec![
+                    Some(b"hello".as_slice()),
+                    None,
+                    Some(b"".as_slice()),
+                ])) as ArrayRef,
+            ),
+        ])
+        .unwrap();
+        write_batch(&table, &input).await;
+        let read = table.new_read_builder();
+        let plan = read.new_scan().plan().await.unwrap();
+        assert!(plan
+            .splits()
+            .iter()
+            .all(|split| split.bucket_path() == 
format!("{path}/data/nested/bucket-0")));
+        let batches: Vec<RecordBatch> = read
+            .new_read()
+            .unwrap()
+            .to_arrow(plan.splits())
+            .unwrap()
+            .try_collect()
+            .await
+            .unwrap();
+        let mut rows = Vec::new();
+        for batch in batches {
+            let ids = batch
+                .column_by_name("id")
+                .unwrap()
+                .as_any()
+                .downcast_ref::<Int32Array>()
+                .unwrap();
+            let payload = batch
+                .column_by_name("payload")
+                .unwrap()
+                .as_any()
+                .downcast_ref::<LargeBinaryArray>()
+                .unwrap();
+            for row in 0..batch.num_rows() {
+                rows.push((
+                    ids.value(row),
+                    (!payload.is_null(row)).then(|| 
payload.value(row).to_vec()),
+                ));
+            }
+        }
+        rows.sort();
+        assert_eq!(
+            rows,
+            vec![(1, Some(b"hello".to_vec())), (2, None), (3, Some(vec![]))]
+        );
+    }
+}
+
+#[tokio::test]
+async fn dynamic_bucket_hash_indexes_follow_data_directory() {
+    let path = "memory:/dynamic_directory";
+    let schema = Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .column("pt", DataType::VarChar(VarCharType::string_type()))
+        .column("value", DataType::Int(IntType::new()))
+        .partition_keys(["pt"])
+        .primary_key(["pt", "id"])
+        .option("bucket", "-1")
+        .option("dynamic-bucket.target-row-num", "1")
+        .option("index-file-in-data-file-dir", "true")
+        .option("data-file.path-directory", "data/nested")
+        .build()
+        .unwrap();
+    let (io, table) = memory_table(path, TableSchema::new(0, &schema));
+    setup_dirs(&io, path).await;
+    persist_table_schema(&io, path, table.schema()).await;
+    for value in [10, 20] {
+        let mut writer = table.new_write_builder().new_write().unwrap();
+        writer
+            .write_arrow_batch(&batch(vec![1, 2, 3], vec![Some("a"); 3], 
vec![value; 3]))
+            .await
+            .unwrap();
+        let messages = writer.prepare_commit().await.unwrap();
+        for message in &messages {
+            for index in &message.new_index_files {
+                assert_eq!(index.index_type, "HASH");
+                assert!(io
+                    .exists(&format!(
+                        "{path}/data/nested/pt=a/bucket-{}/{}",
+                        message.bucket, index.file_name
+                    ))
+                    .await
+                    .unwrap());
+            }
+        }
+        table
+            .new_write_builder()
+            .new_commit()
+            .commit(messages)
+            .await
+            .unwrap();
+        assert_eq!(values(&table).await, vec![value; 3]);
+    }
+}
+
+#[tokio::test]
+async fn referenced_sidecars_use_the_configured_directory() {
+    use 
paimon::table::referenced_files::collect_referenced_files_summary_with_options;
+    let table = table(false, false, "data/nested", false)
+        .await
+        .copy_with_options(std::collections::HashMap::from([
+            ("file-index.bloom-filter.columns".into(), "value".into()),
+            ("file-index.bloom-filter.value.items".into(), "10".into()),
+            ("file-index.in-manifest-threshold".into(), "0 B".into()),
+        ]));
+    write_batch(&table, &batch(vec![1, 2], vec![Some("a"); 2], vec![10, 
20])).await;
+    let plan = table.new_read_builder().new_scan().plan().await.unwrap();
+    let mut expected_size = 0;
+    let mut expected_count = 0;
+    for split in plan.splits() {
+        for file in split.data_files() {
+            assert!(!file.extra_files.is_empty());
+            for path in file.collect_files(split.bucket_path()) {
+                expected_size += table
+                    .file_io()
+                    .new_input(&path)
+                    .unwrap()
+                    .metadata()
+                    .await
+                    .unwrap()
+                    .size as i64;
+                expected_count += 1;
+            }
+        }
+    }
+    let summaries = collect_referenced_files_summary_with_options(
+        table.file_io(),
+        table.location(),
+        table.schema().partition_keys(),
+        table.schema().fields(),
+        &table.schema().core_options(),
+    )
+    .await
+    .unwrap();
+    let total = summaries.iter().find(|row| row.source == "total").unwrap();
+    assert_eq!(total.data_file_size, expected_size);
+    assert_eq!(total.data_file_count, expected_count);
+}
+
+#[test]
+fn directory_is_immutable_and_cannot_be_empty() {
+    use std::collections::HashMap;
+    for directory in [None, Some("data/nested")] {
+        let mut builder = Schema::builder().column("id", 
DataType::Int(IntType::new()));
+        if let Some(directory) = directory {
+            builder = builder.option("data-file.path-directory", directory);
+        }
+        let schema = TableSchema::new(0, &builder.build().unwrap());
+        let copied = schema.copy_with_options(HashMap::from([(
+            "data-file.path-directory".into(),
+            "other".into(),
+        )]));
+        assert_eq!(copied.core_options().data_file_path_directory(), 
directory);
+    }
+    assert!(Schema::builder()
+        .column("id", DataType::Int(IntType::new()))
+        .option("data-file.path-directory", "")
+        .build()
+        .is_err());
+}

Reply via email to