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 cdab14d4 feat(datafusion): add MSCK REPAIR TABLE for catalog-managed
format tables (#817)
cdab14d4 is described below
commit cdab14d4902b4ee2c785e5c23792144efa0cbcdb
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Sun Sep 13 19:56:34 2026 +0800
feat(datafusion): add MSCK REPAIR TABLE for catalog-managed format tables
(#817)
---
.../datafusion/src/format_partition_repair.rs | 179 +++++++++++++++++++++
crates/integrations/datafusion/src/lib.rs | 1 +
crates/integrations/datafusion/src/sql_context.rs | 2 +
.../datafusion/tests/rest_format_partition_sql.rs | 109 +++++++++++++
crates/paimon/src/spec/mod.rs | 2 +-
crates/paimon/src/spec/partition_utils.rs | 39 +++++
crates/paimon/src/table/format_partition.rs | 121 +++++++++++++-
crates/paimon/src/table/format_table_scan.rs | 36 +----
crates/paimon/tests/format_partition_test.rs | 132 +++++++++++++++
docs/src/sql.md | 19 ++-
10 files changed, 604 insertions(+), 36 deletions(-)
diff --git a/crates/integrations/datafusion/src/format_partition_repair.rs
b/crates/integrations/datafusion/src/format_partition_repair.rs
new file mode 100644
index 00000000..7b097670
--- /dev/null
+++ b/crates/integrations/datafusion/src/format_partition_repair.rs
@@ -0,0 +1,179 @@
+// 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.
+
+//! MSCK REPAIR TABLE for Format Tables with catalog-managed partitions.
+
+use std::collections::{BTreeMap, HashMap, HashSet};
+
+use datafusion::error::{DataFusionError, Result as DFResult};
+use datafusion::prelude::DataFrame;
+use datafusion::sql::sqlparser::ast::{AddDropSync, Msck};
+use paimon::catalog::{Catalog, Identifier};
+use paimon::spec::CoreOptions;
+use paimon::table::{FormatTablePartitionPaths, Table};
+
+use crate::error::to_datafusion_error;
+use crate::format_partition_ddl::ensure_catalog_managed_format_table;
+use crate::sql_context::{ok_result, SQLContext};
+
+pub(crate) async fn execute_msck(ctx: &SQLContext, msck: &Msck) ->
DFResult<DataFrame> {
+ if !msck.repair {
+ return Err(DataFusionError::Plan(
+ "MSCK requires the REPAIR keyword".to_string(),
+ ));
+ }
+ SQLContext::ensure_partition_command_target(&msck.table_name, "MSCK REPAIR
TABLE")?;
+ let (catalog, _catalog_name, identifier) =
ctx.resolve_catalog_and_table(&msck.table_name)?;
+ let table = catalog
+ .get_table(&identifier)
+ .await
+ .map_err(to_datafusion_error)?;
+ ensure_catalog_managed_format_table(&table, "MSCK REPAIR TABLE")?;
+ let mode = match msck.partition_action {
+ None | Some(AddDropSync::ADD) => RepairMode::Add,
+ Some(AddDropSync::DROP) => RepairMode::Drop,
+ Some(AddDropSync::SYNC) => RepairMode::Sync,
+ };
+ repair(catalog.as_ref(), &identifier, &table, mode)
+ .await
+ .map_err(to_datafusion_error)?;
+ ok_result(ctx.ctx())
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum RepairMode {
+ Add,
+ Drop,
+ Sync,
+}
+
+async fn repair(
+ catalog: &dyn Catalog,
+ identifier: &Identifier,
+ table: &Table,
+ mode: RepairMode,
+) -> paimon::Result<()> {
+ let core_options = CoreOptions::new(table.schema().options());
+ let partition_paths = FormatTablePartitionPaths::new(
+ table.schema().partition_keys().iter().cloned(),
+ core_options.format_table_partition_only_value_in_path(),
+ );
+ let table_path = table.location();
+
+ if matches!(mode, RepairMode::Drop | RepairMode::Sync) {
+ // Discovery reads a missing root as empty, which would unregister
every partition.
+ ensure_table_root_is_directory(table).await?;
+ }
+
+ // Load both views before changing catalog metadata so a listing failure
leaves
+ // metadata unchanged. Discovery preserves raw directory values such as
month=01.
+ let discovered_specs = partition_paths
+ .discover(
+ table.file_io(),
+ table_path,
+ core_options.partition_default_name(),
+ )
+ .await?;
+ let registered_partitions = catalog.list_partitions(identifier).await?;
+ // A partition registered at a location of its own is never discovered
under the table
+ // directory, so repair leaves it registered.
+ let custom_located = registered_partitions
+ .iter()
+ .filter(|partition| {
+ partition
+ .options
+ .as_ref()
+ .is_some_and(|options| options.contains_key("path"))
+ })
+ .map(|partition| partition_paths.partition_name(&partition.spec))
+ .collect::<paimon::Result<HashSet<_>>>()?;
+
+ let discovered_by_name = index_specs_by_name(&partition_paths,
discovered_specs)?;
+ let registered_by_name = index_specs_by_name(
+ &partition_paths,
+ registered_partitions
+ .into_iter()
+ .map(|partition| partition.spec)
+ .collect(),
+ )?;
+
+ let to_register = if matches!(mode, RepairMode::Add | RepairMode::Sync) {
+ discovered_by_name
+ .iter()
+ .filter(|(name, _)| !registered_by_name.contains_key(*name))
+ .map(|(_, spec)| spec.clone())
+ .collect::<Vec<_>>()
+ } else {
+ Vec::new()
+ };
+ let to_unregister = if matches!(mode, RepairMode::Drop | RepairMode::Sync)
{
+ registered_by_name
+ .iter()
+ .filter(|(name, _)| {
+ !discovered_by_name.contains_key(*name) &&
!custom_located.contains(*name)
+ })
+ .map(|(_, spec)| spec.clone())
+ .collect::<Vec<_>>()
+ } else {
+ Vec::new()
+ };
+
+ if !to_register.is_empty() {
+ catalog
+ .create_partitions(identifier, to_register, true)
+ .await?;
+ }
+ if !to_unregister.is_empty() {
+ catalog.drop_partitions(identifier, to_unregister).await?;
+ }
+ Ok(())
+}
+
+/// Fail unless the table root is a directory; on object stores, a root
holding objects counts.
+async fn ensure_table_root_is_directory(table: &Table) -> paimon::Result<()> {
+ let file_io = table.file_io();
+ let root = table.location();
+ let problem = match file_io.get_status(root).await {
+ Ok(status) if status.is_dir => return Ok(()),
+ Ok(_) => "is not a directory",
+ // A store without directory entries cannot stat the root but lists
what it holds.
+ Err(_) => {
+ if !file_io.list_status(root).await?.is_empty() {
+ return Ok(());
+ }
+ "does not exist"
+ }
+ };
+ Err(paimon::Error::DataInvalid {
+ message: format!(
+ "MSCK REPAIR TABLE cannot drop partitions of Format Table {}: \
+ its location {root} {problem}",
+ table.identifier().full_name()
+ ),
+ source: None,
+ })
+}
+
+fn index_specs_by_name(
+ partition_paths: &FormatTablePartitionPaths,
+ specs: Vec<HashMap<String, String>>,
+) -> paimon::Result<BTreeMap<String, HashMap<String, String>>> {
+ specs
+ .into_iter()
+ .map(|spec| Ok((partition_paths.partition_name(&spec)?, spec)))
+ .collect()
+}
diff --git a/crates/integrations/datafusion/src/lib.rs
b/crates/integrations/datafusion/src/lib.rs
index 4ec76c45..dca1d409 100644
--- a/crates/integrations/datafusion/src/lib.rs
+++ b/crates/integrations/datafusion/src/lib.rs
@@ -44,6 +44,7 @@ mod delete;
mod error;
mod filter_pushdown;
mod format_partition_ddl;
+mod format_partition_repair;
#[cfg(feature = "fulltext")]
mod full_text_search;
mod hybrid_search;
diff --git a/crates/integrations/datafusion/src/sql_context.rs
b/crates/integrations/datafusion/src/sql_context.rs
index b75534e9..09cf9764 100644
--- a/crates/integrations/datafusion/src/sql_context.rs
+++ b/crates/integrations/datafusion/src/sql_context.rs
@@ -37,6 +37,7 @@
//! - `ALTER TABLE db.t ADD [IF NOT EXISTS] PARTITION (...) [PARTITION (...)]`
//! - `ALTER TABLE db.t DROP [IF EXISTS] PARTITION (...)`
//! - `SHOW PARTITIONS db.t [PARTITION (...)]`
+//! - `MSCK REPAIR TABLE db.t [{ADD|DROP|SYNC} PARTITIONS]`
//! - `CREATE VIEW [IF NOT EXISTS] view [(col, ...)] AS query`
//! - `DROP VIEW [IF EXISTS] view`
//! - `CREATE FUNCTION name(args) RETURNS type [LANGUAGE SQL] RETURN
expression`
@@ -609,6 +610,7 @@ impl SQLContext {
self.handle_truncate_table(truncate,
enable_ident_normalization)
.await
}
+ Statement::Msck(msck) =>
crate::format_partition_repair::execute_msck(self, msck).await,
Statement::CreateView(create_view) => {
if create_view.temporary {
// Temporary views are always handled by us (Paimon
catalog temp storage)
diff --git a/crates/integrations/datafusion/tests/rest_format_partition_sql.rs
b/crates/integrations/datafusion/tests/rest_format_partition_sql.rs
index 27005727..9a3073f7 100644
--- a/crates/integrations/datafusion/tests/rest_format_partition_sql.rs
+++ b/crates/integrations/datafusion/tests/rest_format_partition_sql.rs
@@ -484,6 +484,115 @@ async fn
test_drop_partition_leaves_a_custom_location_in_place() {
assert!(temp_dir.path().join("dt=b").is_dir());
}
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_msck_repair_reconciles_registrations_with_directories() {
+ let temp_dir = tempfile::tempdir().unwrap();
+ std::fs::create_dir_all(temp_dir.path().join("dt=2026-07-21")).unwrap();
+ let (_server, context) =
+ setup_rest_table(&temp_dir, format_table_schema(&[("dt",
varchar())])).await;
+ common::exec(
+ &context,
+ &format!("ALTER TABLE {TABLE_NAME} ADD PARTITION (dt = '2026-07-22')"),
+ )
+ .await;
+
+ // ADD registers a directory the catalog does not know yet.
+ common::exec(
+ &context,
+ &format!("MSCK REPAIR TABLE {TABLE_NAME} ADD PARTITIONS"),
+ )
+ .await;
+ assert_eq!(
+ show_partitions(&context, "").await,
+ ["dt=2026-07-21", "dt=2026-07-22"]
+ );
+
+ // SYNC also unregisters a partition whose directory is gone, without
deleting anything.
+ std::fs::remove_dir_all(temp_dir.path().join("dt=2026-07-22")).unwrap();
+ common::exec(
+ &context,
+ &format!("MSCK REPAIR TABLE {TABLE_NAME} SYNC PARTITIONS"),
+ )
+ .await;
+ assert_eq!(show_partitions(&context, "").await, ["dt=2026-07-21"]);
+ assert!(temp_dir.path().join("dt=2026-07-21").is_dir());
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_msck_repair_refuses_to_drop_without_a_table_directory() {
+ let temp_dir = tempfile::tempdir().unwrap();
+ let (server, context) =
+ setup_rest_table(&temp_dir, format_table_schema(&[("dt",
varchar())])).await;
+ common::exec(
+ &context,
+ &format!("ALTER TABLE {TABLE_NAME} ADD PARTITION (dt = 'a')"),
+ )
+ .await;
+
+ // A moved root must not read as every partition directory gone.
+ let moved_dir = tempfile::tempdir().unwrap();
+ std::fs::rename(temp_dir.path(), moved_dir.path().join("events")).unwrap();
+ for action in ["DROP", "SYNC"] {
+ common::assert_sql_error(
+ &context,
+ &format!("MSCK REPAIR TABLE {TABLE_NAME} {action} PARTITIONS"),
+ "does not exist",
+ )
+ .await;
+ }
+
+ std::fs::write(temp_dir.path(), b"").unwrap();
+ for action in ["DROP", "SYNC"] {
+ common::assert_sql_error(
+ &context,
+ &format!("MSCK REPAIR TABLE {TABLE_NAME} {action} PARTITIONS"),
+ "is not a directory",
+ )
+ .await;
+ }
+ assert_eq!(
+ server.table_partition_specs(DATABASE, TABLE),
+ vec![spec(&[("dt", "a")])]
+ );
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn test_msck_repair_keeps_a_partition_at_a_custom_location() {
+ let temp_dir = tempfile::tempdir().unwrap();
+ let external_dir = tempfile::tempdir().unwrap();
+ let (server, context) =
+ setup_rest_table(&temp_dir, format_table_schema(&[("dt",
varchar())])).await;
+ common::exec(
+ &context,
+ &format!("ALTER TABLE {TABLE_NAME} ADD PARTITION (dt = 'a') PARTITION
(dt = 'b')"),
+ )
+ .await;
+ server.set_table_partition_options(
+ DATABASE,
+ TABLE,
+ &spec(&[("dt", "b")]),
+ HashMap::from([(
+ "path".to_string(),
+ format!("file://{}", external_dir.path().display()),
+ )]),
+ );
+
+ // Its directory is not under the table, so repair does not read it as
missing.
+ std::fs::remove_dir_all(temp_dir.path().join("dt=b")).unwrap();
+ common::exec(
+ &context,
+ &format!("MSCK REPAIR TABLE {TABLE_NAME} SYNC PARTITIONS"),
+ )
+ .await;
+ assert_eq!(
+ server.table_partition_specs(DATABASE, TABLE),
+ vec![spec(&[("dt", "a")]), spec(&[("dt", "b")])]
+ );
+}
+
/// `SQLContext::sql` futures have to stay `Send` for callers that box or
spawn them; this stops
/// compiling when a stream over borrowed items anywhere below a statement
takes that away.
#[allow(dead_code)]
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index efc0634e..3a35fd20 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -99,7 +99,7 @@ mod partition;
pub use partition::Partition;
mod partition_utils;
pub(crate) use partition_utils::{
- bucket_path, bucket_path_under, escape_path_name, PartitionComputer,
+ bucket_path, bucket_path_under, escape_path_name, 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 463ba653..69e1941d 100644
--- a/crates/paimon/src/spec/partition_utils.rs
+++ b/crates/paimon/src/spec/partition_utils.rs
@@ -522,6 +522,37 @@ pub(crate) fn escape_path_name(path: &str) -> String {
sb
}
+/// Unescape a path component following Java
`PartitionPathUtils.unescapePathName`.
+pub(crate) fn unescape_path_name(value: &str) -> Option<String> {
+ let bytes = value.as_bytes();
+ let mut out = Vec::with_capacity(bytes.len());
+ let mut i = 0;
+ while i < bytes.len() {
+ if bytes[i] == b'%' {
+ if i + 2 >= bytes.len() {
+ return None;
+ }
+ let hi = hex_value(bytes[i + 1])?;
+ let lo = hex_value(bytes[i + 2])?;
+ out.push((hi << 4) | lo);
+ i += 3;
+ } else {
+ out.push(bytes[i]);
+ i += 1;
+ }
+ }
+ String::from_utf8(out).ok()
+}
+
+fn hex_value(byte: u8) -> Option<u8> {
+ match byte {
+ b'0'..=b'9' => Some(byte - b'0'),
+ b'a'..=b'f' => Some(byte - b'a' + 10),
+ b'A'..=b'F' => Some(byte - b'A' + 10),
+ _ => None,
+ }
+}
+
/// Check if a character needs escaping in partition path names.
///
/// Matches Java `PartitionPathUtils.CHAR_TO_ESCAPE`:
@@ -691,6 +722,14 @@ mod tests {
assert_eq!(escape_path_name("a\x7Fb"), "a%7Fb");
}
+ #[test]
+ fn test_unescape_path_name() {
+ assert_eq!(unescape_path_name("a%2Fb"), Some("a/b".to_string()));
+ assert_eq!(unescape_path_name("%E4%B8%AD"), Some("中".to_string()));
+ assert_eq!(unescape_path_name("a%ZZb"), None);
+ assert_eq!(unescape_path_name("a%b"), None);
+ }
+
// ======================== PartitionComputer tests
========================
#[test]
diff --git a/crates/paimon/src/table/format_partition.rs
b/crates/paimon/src/table/format_partition.rs
index deba4111..0ee14316 100644
--- a/crates/paimon/src/table/format_partition.rs
+++ b/crates/paimon/src/table/format_partition.rs
@@ -22,7 +22,8 @@ use std::collections::HashMap;
use chrono::NaiveDate;
-use crate::spec::{escape_path_name, DataType, Datum};
+use crate::io::FileIO;
+use crate::spec::{escape_path_name, unescape_path_name, DataType, Datum};
const UNIX_EPOCH_DAYS_FROM_CE: i32 = 719_163;
@@ -110,6 +111,60 @@ impl FormatTablePartitionPaths {
.join("/"))
}
+ /// Discover complete raw partition specs from the table directory, sorted
and deduplicated.
+ /// Skips hidden or non-matching entries; a malformed or non-canonical
segment is an error.
+ pub async fn discover(
+ &self,
+ file_io: &FileIO,
+ table_path: &str,
+ default_partition_name: &str,
+ ) -> crate::Result<Vec<HashMap<String, String>>> {
+ let default_partition_path_name = self
+ .only_value_in_path
+ .then(|| escape_path_name(default_partition_name));
+ let mut frontier = vec![(table_path.trim_end_matches('/').to_string(),
HashMap::new())];
+ for key in &self.partition_keys {
+ let mut next = Vec::new();
+ for (path, spec) in frontier {
+ let statuses = match file_io.list_status(&path).await {
+ Ok(statuses) => statuses,
+ Err(error) if is_storage_not_found(&error) => continue,
+ Err(error) => return Err(error),
+ };
+ for status in statuses {
+ if !status.is_dir {
+ continue;
+ }
+ let Some(segment) = last_path_segment(&status.path) else {
+ continue;
+ };
+ let is_value_only_default =
+ default_partition_path_name.as_deref() ==
Some(segment);
+ if (segment.starts_with('.') || segment.starts_with('_'))
+ && !is_value_only_default
+ {
+ continue;
+ }
+ let Some(value) = self.partition_value_from_segment(key,
segment)? else {
+ continue;
+ };
+ let mut child_spec = spec.clone();
+ child_spec.insert(key.clone(), value);
+ next.push((status.path.trim_end_matches('/').to_string(),
child_spec));
+ }
+ }
+ frontier = next;
+ }
+
+ let mut partitions = frontier
+ .into_iter()
+ .map(|(_, spec)| Ok((self.partition_name(&spec)?, spec)))
+ .collect::<crate::Result<Vec<_>>>()?;
+ partitions.sort_by(|left, right| left.0.cmp(&right.0));
+ partitions.dedup_by(|left, right| left.0 == right.0);
+ Ok(partitions.into_iter().map(|(_, spec)| spec).collect())
+ }
+
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 {
@@ -147,6 +202,44 @@ impl FormatTablePartitionPaths {
self.partition_keys
)
}
+
+ fn partition_value_from_segment(
+ &self,
+ key: &str,
+ segment: &str,
+ ) -> crate::Result<Option<String>> {
+ let value = if self.only_value_in_path {
+ decode_canonical_path_name(segment)?
+ } else {
+ let Some((segment_key, value)) = segment.split_once('=') else {
+ return Ok(None);
+ };
+ let decoded_key = decode_canonical_path_name(segment_key)?;
+ if decoded_key != key {
+ return Ok(None);
+ }
+ decode_canonical_path_name(value)?
+ };
+ Ok(Some(value))
+ }
+}
+
+fn decode_canonical_path_name(value: &str) -> crate::Result<String> {
+ let decoded = unescape_path_name(value).ok_or_else(||
crate::Error::DataInvalid {
+ message: format!("Invalid escaped partition path segment {value:?}"),
+ source: None,
+ })?;
+ let canonical = escape_path_name(&decoded);
+ if canonical != value {
+ return Err(crate::Error::DataInvalid {
+ message: format!(
+ "Partition path segment {value:?} cannot round-trip through
catalog metadata; \
+ its canonical escaped form is {canonical:?}"
+ ),
+ source: None,
+ });
+ }
+ Ok(decoded)
}
/// Parse a raw Format Table partition value from a path or catalog
registration.
@@ -232,6 +325,18 @@ fn format_partition_date(epoch_days: i32) ->
Option<String> {
.map(|date| date.format("%Y-%m-%d").to_string())
}
+fn is_storage_not_found(error: &crate::Error) -> bool {
+ matches!(
+ error,
+ crate::Error::IoUnexpected { source, .. }
+ if source.kind() == opendal::ErrorKind::NotFound
+ )
+}
+
+fn last_path_segment(path: &str) -> Option<&str> {
+ path.trim_end_matches('/').rsplit('/').next()
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -349,4 +454,18 @@ mod tests {
// rather than silently widened.
assert_eq!(paths.name_prefix_pattern(&["2026/07".to_string()]), None);
}
+
+ #[test]
+ fn test_storage_not_found_matches_only_not_found() {
+ for (kind, expected) in [
+ (opendal::ErrorKind::NotFound, true),
+ (opendal::ErrorKind::PermissionDenied, false),
+ ] {
+ let error = crate::Error::IoUnexpected {
+ message: "list partition directory".to_string(),
+ source: Box::new(opendal::Error::new(kind, "test")),
+ };
+ assert_eq!(is_storage_not_found(&error), expected);
+ }
+ }
}
diff --git a/crates/paimon/src/table/format_table_scan.rs
b/crates/paimon/src/table/format_table_scan.rs
index aabadcec..de2c02e0 100644
--- a/crates/paimon/src/table/format_table_scan.rs
+++ b/crates/paimon/src/table/format_table_scan.rs
@@ -26,9 +26,9 @@ use super::{Plan, RESTEnv, ScanTrace, Table};
use crate::api::RestError;
use crate::spec::stats::BinaryTableStats;
use crate::spec::{
- escape_path_name, extract_datum, BinaryRow, BinaryRowBuilder, CoreOptions,
DataField,
- DataFileMeta, DataType, Datum, Partition, PartitionComputer, Predicate,
PredicateOperator,
- PATH_OPTION,
+ escape_path_name, extract_datum, unescape_path_name, 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};
@@ -831,36 +831,6 @@ fn partition_segment_value(segment: &str, key: &str) ->
Option<String> {
}
}
-fn unescape_path_name(value: &str) -> Option<String> {
- let bytes = value.as_bytes();
- let mut out = Vec::with_capacity(bytes.len());
- let mut i = 0;
- while i < bytes.len() {
- if bytes[i] == b'%' {
- if i + 2 >= bytes.len() {
- return None;
- }
- let hi = hex_value(bytes[i + 1])?;
- let lo = hex_value(bytes[i + 2])?;
- out.push((hi << 4) | lo);
- i += 3;
- } else {
- out.push(bytes[i]);
- i += 1;
- }
- }
- String::from_utf8(out).ok()
-}
-
-fn hex_value(byte: u8) -> Option<u8> {
- match byte {
- b'0'..=b'9' => Some(byte - b'0'),
- b'a'..=b'f' => Some(byte - b'a' + 10),
- b'A'..=b'F' => Some(byte - b'A' + 10),
- _ => None,
- }
-}
-
fn supported_format_table_formats() -> Vec<&'static str> {
vec![
"parquet",
diff --git a/crates/paimon/tests/format_partition_test.rs
b/crates/paimon/tests/format_partition_test.rs
new file mode 100644
index 00000000..d9ad49ca
--- /dev/null
+++ b/crates/paimon/tests/format_partition_test.rs
@@ -0,0 +1,132 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements. See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership. The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use std::collections::HashMap;
+#[cfg(not(windows))]
+use std::path::Path;
+
+use paimon::io::FileIO;
+use paimon::table::FormatTablePartitionPaths;
+
+#[cfg(not(windows))]
+async fn discover_partitions(
+ root: &Path,
+ partition_keys: &[&str],
+ only_value_in_path: bool,
+ default_partition_name: &str,
+) -> paimon::Result<Vec<HashMap<String, String>>> {
+ let table_path = format!("file://{}", root.display());
+ let file_io = FileIO::from_path(&table_path)?.build()?;
+ FormatTablePartitionPaths::new(partition_keys.iter().copied(),
only_value_in_path)
+ .discover(&file_io, &table_path, default_partition_name)
+ .await
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn discover_format_partitions_returns_complete_sorted_specs() {
+ let tmp = tempfile::tempdir().unwrap();
+ for relative in [
+ "dt=2026-07-22/hour=10",
+ "dt=2026-07-21/hour=09",
+ "dt=2026-07-20",
+ ".staging/dt=2026-07-19/hour=08",
+ ] {
+ std::fs::create_dir_all(tmp.path().join(relative)).unwrap();
+ }
+
+ let partitions =
+ discover_partitions(tmp.path(), &["dt", "hour"], false,
"__DEFAULT_PARTITION__")
+ .await
+ .unwrap();
+
+ assert_eq!(
+ partitions,
+ vec![
+ HashMap::from([
+ ("dt".to_string(), "2026-07-21".to_string()),
+ ("hour".to_string(), "09".to_string()),
+ ]),
+ HashMap::from([
+ ("dt".to_string(), "2026-07-22".to_string()),
+ ("hour".to_string(), "10".to_string()),
+ ]),
+ ]
+ );
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn discover_format_partitions_treats_missing_root_as_empty() {
+ let tmp = tempfile::tempdir().unwrap();
+
+ let partitions = discover_partitions(
+ &tmp.path().join("missing"),
+ &["dt"],
+ false,
+ "__DEFAULT_PARTITION__",
+ )
+ .await
+ .unwrap();
+
+ assert!(partitions.is_empty());
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn discover_value_only_partitions_rejects_parent_traversal_value() {
+ let tmp = tempfile::tempdir().unwrap();
+ std::fs::create_dir_all(tmp.path().join("%2E%2E")).unwrap();
+
+ let error = discover_partitions(tmp.path(), &["dt"], true,
"__DEFAULT_PARTITION__")
+ .await
+ .unwrap_err();
+
+ assert!(
+ error.to_string().contains(".."),
+ "expected traversal value in error, got: {error}"
+ );
+}
+
+#[cfg(not(windows))]
+#[tokio::test]
+async fn discover_value_only_partitions_keeps_hidden_default_directory() {
+ for (default_name, directory, ignored_directory) in [
+ ("__DEFAULT_PARTITION__", "__DEFAULT_PARTITION__", None),
+ (".NULL", ".NULL", Some(".staging")),
+ ("_NULL/%NA", "_NULL%2F%25NA", Some("_temporary")),
+ ] {
+ let tmp = tempfile::tempdir().unwrap();
+ std::fs::create_dir_all(tmp.path().join(directory)).unwrap();
+ if let Some(ignored_directory) = ignored_directory {
+
std::fs::create_dir_all(tmp.path().join(ignored_directory)).unwrap();
+ }
+
+ let partitions = discover_partitions(tmp.path(), &["dt"], true,
default_name)
+ .await
+ .unwrap();
+
+ assert_eq!(
+ partitions,
+ vec![HashMap::from([(
+ "dt".to_string(),
+ default_name.to_string()
+ )])],
+ "{default_name}"
+ );
+ }
+}
diff --git a/docs/src/sql.md b/docs/src/sql.md
index 88e23f68..75ac8f69 100644
--- a/docs/src/sql.md
+++ b/docs/src/sql.md
@@ -40,7 +40,7 @@ Mosaic support is always available and currently read-only.
SQL queries can read
SQL support has two layers:
- DataFusion provides the parser, query planner, optimizer, execution engine,
expressions, scalar functions, aggregate functions, and window functions. SQL
statements that `SQLContext` does not intercept are delegated to DataFusion.
This includes the DataFusion SQL surface for `SELECT` queries, CTEs (including
recursive CTEs), subqueries, joins including `LATERAL` joins, SQL lambda
functions, grouping, `HAVING`, window clauses, `QUALIFY`, set operations,
`ORDER BY`, `LIMIT`/`OFFSET`, `EX [...]
-- Paimon-specific table management and row-level writes are implemented by
`SQLContext`. This includes Paimon `CREATE TABLE`, `ALTER TABLE`, `DROP TABLE`,
`CREATE TEMPORARY TABLE`, `CREATE TEMPORARY VIEW`, REST Catalog persistent
`CREATE VIEW`, `DROP VIEW`, and `CREATE FUNCTION`, `DROP TEMPORARY TABLE` /
`VIEW`, `INSERT OVERWRITE ... PARTITION`, `UPDATE`, `DELETE`, `MERGE INTO`,
`TRUNCATE TABLE`, `ALTER TABLE ... ADD PARTITION`, `ALTER TABLE ... DROP
PARTITION`, `SHOW PARTITIONS`, `CALL [...]
+- Paimon-specific table management and row-level writes are implemented by
`SQLContext`. This includes Paimon `CREATE TABLE`, `ALTER TABLE`, `DROP TABLE`,
`CREATE TEMPORARY TABLE`, `CREATE TEMPORARY VIEW`, REST Catalog persistent
`CREATE VIEW`, `DROP VIEW`, and `CREATE FUNCTION`, `DROP TEMPORARY TABLE` /
`VIEW`, `INSERT OVERWRITE ... PARTITION`, `UPDATE`, `DELETE`, `MERGE INTO`,
`TRUNCATE TABLE`, `ALTER TABLE ... ADD PARTITION`, `ALTER TABLE ... DROP
PARTITION`, `SHOW PARTITIONS`, `MSCK [...]
Not every DataFusion DDL/DML statement maps to a Paimon table operation. For
Paimon catalogs, `CREATE EXTERNAL TABLE`, `LOCATION`, `CREATE MATERIALIZED
VIEW`, and persistent `CREATE TABLE AS SELECT` are rejected or not implemented.
Persistent `CREATE FUNCTION` is supported only for the REST Catalog SQL scalar
form documented below. DataFusion `COPY` can export query results to files; it
does not create or commit Paimon table files.
@@ -968,6 +968,23 @@ and the directory may survive; repair the file system and
remove it there rather
re-registering it. A partition at a custom location is only unregistered; its
directory
is left where it is.
+### MSCK REPAIR TABLE
+
+Reconcile the catalog registrations with the directories that actually exist:
+
+```sql
+MSCK REPAIR TABLE paimon.my_db.events; -- same as ADD
PARTITIONS
+MSCK REPAIR TABLE paimon.my_db.events ADD PARTITIONS; -- register discovered
directories
+MSCK REPAIR TABLE paimon.my_db.events DROP PARTITIONS; -- unregister vanished
directories
+MSCK REPAIR TABLE paimon.my_db.events SYNC PARTITIONS; -- both
+```
+
+Repair is metadata-only: it never deletes data. Directory discovery and the
catalog
+listing both complete before any change is made, so a listing failure cannot
turn a
+truncated view of the table into a `DROP` diff. There is no dry-run and no
scope
+argument — repair always covers the whole table. A partition at a custom
location is
+never unregistered by repair.
+
## Procedures
Use `CALL` to invoke built-in procedures. All procedures are under the `sys`
namespace.