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 2bb2abb6 feat(rest): add partition registration, lookup and statistics
APIs (#812)
2bb2abb6 is described below
commit 2bb2abb69f2eeaca9ad3bdab95dadcb2cd8a6e79
Author: Dapeng Sun(孙大鹏) <[email protected]>
AuthorDate: Fri Sep 11 14:18:31 2026 +0800
feat(rest): add partition registration, lookup and statistics APIs (#812)
---
crates/paimon/src/api/api_request.rs | 263 +++++++++++-
crates/paimon/src/api/mod.rs | 4 +-
crates/paimon/src/api/resource_paths.rs | 30 ++
crates/paimon/src/api/rest_api.rs | 220 +++++++++-
crates/paimon/src/catalog/mod.rs | 67 ++++
crates/paimon/src/catalog/rest/rest_catalog.rs | 167 +++++++-
crates/paimon/src/spec/partition.rs | 34 ++
crates/paimon/src/spec/partition_statistics.rs | 8 +-
crates/paimon/src/spec/predicate.rs | 206 ++++++++++
crates/paimon/src/table/table_commit.rs | 4 +-
crates/paimon/tests/mock_server.rs | 534 ++++++++++++++++++++++++-
crates/paimon/tests/rest_api_test.rs | 276 ++++++++++++-
crates/paimon/tests/rest_catalog_test.rs | 321 ++++++++++++++-
13 files changed, 2109 insertions(+), 25 deletions(-)
diff --git a/crates/paimon/src/api/api_request.rs
b/crates/paimon/src/api/api_request.rs
index 84ee7c5c..48646757 100644
--- a/crates/paimon/src/api/api_request.rs
+++ b/crates/paimon/src/api/api_request.rs
@@ -19,12 +19,12 @@
//!
//! This module contains all request structures used in REST API calls.
-use serde::{Deserialize, Serialize};
+use serde::{Deserialize, Deserializer, Serialize};
use std::collections::HashMap;
use crate::{
catalog::{Function, FunctionDefinition, Identifier, ViewSchema},
- spec::{DataField, Schema, SchemaChange},
+ spec::{DataField, PartitionStatistics, Schema, SchemaChange},
};
/// Request to create a new database.
@@ -167,6 +167,135 @@ impl AlterTableRequest {
}
}
+/// Request to create table partitions.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CreatePartitionsRequest {
+ /// Partition specs to register.
+ pub partition_specs: Vec<HashMap<String, String>>,
+ /// Whether already registered partitions should be ignored.
+ #[serde(
+ default = "default_true",
+ deserialize_with = "deserialize_null_to_true"
+ )]
+ pub ignore_if_exists: bool,
+ /// Statistics reported for the partitions, matched to them by spec;
absent unless reported.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub partition_statistics: Option<Vec<PartitionStatistics>>,
+ /// Whether the reported statistics replace what the catalog holds rather
than add to it;
+ /// present only together with `partition_statistics`.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub replace_statistics: Option<bool>,
+}
+
+impl CreatePartitionsRequest {
+ /// Create a request to register partitions.
+ pub fn new(partition_specs: Vec<HashMap<String, String>>,
ignore_if_exists: bool) -> Self {
+ Self {
+ partition_specs,
+ ignore_if_exists,
+ partition_statistics: None,
+ replace_statistics: None,
+ }
+ }
+
+ /// Report statistics for the partitions in the same request.
+ pub fn with_statistics(mut self, statistics: Vec<PartitionStatistics>,
replace: bool) -> Self {
+ self.partition_statistics = Some(statistics);
+ self.replace_statistics = Some(replace);
+ self
+ }
+}
+
+/// Request to drop (unregister) table partitions.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct DropPartitionsRequest {
+ /// Partition specs to unregister.
+ pub partition_specs: Vec<HashMap<String, String>>,
+ /// Whether missing partitions should be ignored.
+ #[serde(
+ default = "default_true",
+ deserialize_with = "deserialize_null_to_true"
+ )]
+ pub ignore_if_not_exists: bool,
+}
+
+impl DropPartitionsRequest {
+ /// Create a request to unregister partitions.
+ pub fn new(partition_specs: Vec<HashMap<String, String>>,
ignore_if_not_exists: bool) -> Self {
+ Self {
+ partition_specs,
+ ignore_if_not_exists,
+ }
+ }
+}
+
+/// Request to look up registered partitions by their complete specs.
+///
+/// Wire-compatible with Java `ListPartitionsByNamesRequest`, whose field is
`specs` rather than
+/// the `partitionSpecs` the create and drop requests use.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct ListPartitionsByNamesRequest {
+ /// Complete partition specs to look up.
+ pub specs: Vec<HashMap<String, String>>,
+}
+
+impl ListPartitionsByNamesRequest {
+ /// Create a request to look up partitions by their complete specs.
+ pub fn new(specs: Vec<HashMap<String, String>>) -> Self {
+ Self { specs }
+ }
+}
+
+/// Request to list partitions matching a partition predicate.
+///
+/// Wire-compatible with Java `ListPartitionsByFilterRequest`: the predicate
travels as its JSON
+/// text inside the request body, and absent fields are left out.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ListPartitionsByFilterRequest {
+ /// Partition predicate in the REST catalog predicate JSON format.
+ pub filter: String,
+ /// Partition-name pattern combined with the filter as a conjunction.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub partition_name_pattern: Option<String>,
+ /// Maximum number of partitions in one page.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub max_results: Option<u32>,
+ /// Token of the page to return.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub page_token: Option<String>,
+}
+
+impl ListPartitionsByFilterRequest {
+ /// Create a request for one page of partitions matching `filter`.
+ pub fn new(
+ filter: String,
+ partition_name_pattern: Option<String>,
+ max_results: Option<u32>,
+ page_token: Option<String>,
+ ) -> Self {
+ Self {
+ filter,
+ partition_name_pattern,
+ max_results,
+ page_token,
+ }
+ }
+}
+
+fn default_true() -> bool {
+ true
+}
+
+fn deserialize_null_to_true<'de, D>(deserializer: D) -> Result<bool, D::Error>
+where
+ D: Deserializer<'de>,
+{
+ Ok(Option::<bool>::deserialize(deserializer)?.unwrap_or(true))
+}
+
/// Request for auth table query: the projected columns of the query.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthTableQueryRequest {
@@ -220,6 +349,136 @@ mod tests {
assert_eq!(serde_json::to_string(&req).unwrap(), "{}");
}
+ #[test]
+ fn test_create_partitions_request_serialization() {
+ let req = CreatePartitionsRequest::new(
+ vec![HashMap::from([
+ ("dt".to_string(), "2026-07-22".to_string()),
+ ("hour".to_string(), "10".to_string()),
+ ])],
+ false,
+ );
+
+ assert_eq!(
+ serde_json::to_value(req).unwrap(),
+ serde_json::json!({
+ "partitionSpecs": [{
+ "dt": "2026-07-22",
+ "hour": "10"
+ }],
+ "ignoreIfExists": false
+ })
+ );
+ }
+
+ #[test]
+ fn test_create_partitions_request_sends_statistics_only_when_reported() {
+ let spec = HashMap::from([("dt".to_string(),
"2026-07-22".to_string())]);
+ let request = CreatePartitionsRequest::new(vec![spec.clone()],
true).with_statistics(
+ vec![PartitionStatistics {
+ spec: spec.clone(),
+ record_count: -1,
+ file_size_in_bytes: 1024,
+ file_count: 2,
+ last_file_creation_time: 1_700_000_000_000,
+ total_buckets: -1,
+ }],
+ true,
+ );
+
+ assert_eq!(
+ serde_json::to_value(request).unwrap(),
+ serde_json::json!({
+ "partitionSpecs": [{"dt": "2026-07-22"}],
+ "ignoreIfExists": true,
+ "partitionStatistics": [{
+ "spec": {"dt": "2026-07-22"},
+ "recordCount": -1,
+ "fileSizeInBytes": 1024,
+ "fileCount": 2,
+ "lastFileCreationTime": 1_700_000_000_000_i64,
+ "totalBuckets": -1
+ }],
+ "replaceStatistics": true
+ })
+ );
+ }
+
+ #[test]
+ fn test_drop_partitions_request_serialization() {
+ let req = DropPartitionsRequest::new(
+ vec![HashMap::from([(
+ "dt".to_string(),
+ "2026-07-22".to_string(),
+ )])],
+ false,
+ );
+
+ assert_eq!(
+ serde_json::to_value(req).unwrap(),
+ serde_json::json!({
+ "partitionSpecs": [{"dt": "2026-07-22"}],
+ "ignoreIfNotExists": false
+ })
+ );
+ }
+
+ #[test]
+ fn test_partition_request_flags_default_to_true() {
+ for json in [
+ serde_json::json!({"partitionSpecs": []}),
+ serde_json::json!({"partitionSpecs": [], "ignoreIfExists": null}),
+ ] {
+ let request: CreatePartitionsRequest =
serde_json::from_value(json).unwrap();
+ assert!(request.ignore_if_exists);
+ }
+ for json in [
+ serde_json::json!({"partitionSpecs": []}),
+ serde_json::json!({"partitionSpecs": [], "ignoreIfNotExists":
null}),
+ ] {
+ let request: DropPartitionsRequest =
serde_json::from_value(json).unwrap();
+ assert!(request.ignore_if_not_exists);
+ }
+ }
+
+ #[test]
+ fn test_list_partitions_by_names_request_uses_the_specs_field() {
+ let request = ListPartitionsByNamesRequest::new(vec![HashMap::from([(
+ "dt".to_string(),
+ "2026-07-22".to_string(),
+ )])]);
+
+ assert_eq!(
+ serde_json::to_value(request).unwrap(),
+ serde_json::json!({"specs": [{"dt": "2026-07-22"}]})
+ );
+ }
+
+ #[test]
+ fn test_list_partitions_by_filter_request_leaves_out_absent_fields() {
+ let request = ListPartitionsByFilterRequest::new("{}".to_string(),
None, None, None);
+ assert_eq!(
+ serde_json::to_value(request).unwrap(),
+ serde_json::json!({"filter": "{}"})
+ );
+
+ let request = ListPartitionsByFilterRequest::new(
+ "{}".to_string(),
+ Some("dt=a/%".to_string()),
+ Some(1000),
+ Some("next".to_string()),
+ );
+ assert_eq!(
+ serde_json::to_value(request).unwrap(),
+ serde_json::json!({
+ "filter": "{}",
+ "partitionNamePattern": "dt=a/%",
+ "maxResults": 1000,
+ "pageToken": "next"
+ })
+ );
+ }
+
#[test]
fn test_rename_table_request_serialization() {
let source = Identifier::new("db1".to_string(), "table1".to_string());
diff --git a/crates/paimon/src/api/mod.rs b/crates/paimon/src/api/mod.rs
index 809b9021..181d0fa2 100644
--- a/crates/paimon/src/api/mod.rs
+++ b/crates/paimon/src/api/mod.rs
@@ -32,7 +32,9 @@ mod api_response;
// Re-export request types
pub use api_request::{
AlterDatabaseRequest, AlterTableRequest, AuthTableQueryRequest,
CreateDatabaseRequest,
- CreateFunctionRequest, CreateTableRequest, CreateViewRequest,
RenameTableRequest,
+ CreateFunctionRequest, CreatePartitionsRequest, CreateTableRequest,
CreateViewRequest,
+ DropPartitionsRequest, ListPartitionsByFilterRequest,
ListPartitionsByNamesRequest,
+ RenameTableRequest,
};
// Re-export response types
diff --git a/crates/paimon/src/api/resource_paths.rs
b/crates/paimon/src/api/resource_paths.rs
index 38c39c9a..9acea094 100644
--- a/crates/paimon/src/api/resource_paths.rs
+++ b/crates/paimon/src/api/resource_paths.rs
@@ -216,6 +216,27 @@ impl ResourcePaths {
Self::PARTITIONS
)
}
+
+ /// Get the endpoint path for dropping table partitions.
+ pub fn drop_partitions(&self, database_name: &str, table_name: &str) ->
String {
+ format!("{}/drop", self.partitions(database_name, table_name))
+ }
+
+ /// Get the endpoint path for looking up table partitions by their specs.
+ pub fn list_partitions_by_names(&self, database_name: &str, table_name:
&str) -> String {
+ format!(
+ "{}/list-by-names",
+ self.partitions(database_name, table_name)
+ )
+ }
+
+ /// Get the endpoint path for listing table partitions matching a
predicate.
+ pub fn list_partitions_by_filter(&self, database_name: &str, table_name:
&str) -> String {
+ format!(
+ "{}/list-by-filter",
+ self.partitions(database_name, table_name)
+ )
+ }
}
#[cfg(test)]
@@ -296,4 +317,13 @@ mod tests {
"/v1/catalog/databases/analytics/functions/rectangle+area"
);
}
+
+ #[test]
+ fn test_drop_partitions_path_encodes_names() {
+ let paths = ResourcePaths::new("catalog");
+ assert_eq!(
+ paths.drop_partitions("analytics db", "user events"),
+
"/v1/catalog/databases/analytics+db/tables/user+events/partitions/drop"
+ );
+ }
}
diff --git a/crates/paimon/src/api/rest_api.rs
b/crates/paimon/src/api/rest_api.rs
index cdde2b23..50605895 100644
--- a/crates/paimon/src/api/rest_api.rs
+++ b/crates/paimon/src/api/rest_api.rs
@@ -20,7 +20,7 @@
//! This module provides a REST API client for interacting with
//! Paimon rest catalog services, supporting database operations.
-use std::collections::HashMap;
+use std::collections::{HashMap, HashSet};
use crate::api::rest_client::HttpClient;
use crate::catalog::{Function, Identifier, ViewSchema};
@@ -30,7 +30,9 @@ use crate::Result;
use super::api_request::{
AlterDatabaseRequest, AlterTableRequest, AuthTableQueryRequest,
CreateDatabaseRequest,
- CreateFunctionRequest, CreateTableRequest, CreateViewRequest,
RenameTableRequest,
+ CreateFunctionRequest, CreatePartitionsRequest, CreateTableRequest,
CreateViewRequest,
+ DropPartitionsRequest, ListPartitionsByFilterRequest,
ListPartitionsByNamesRequest,
+ RenameTableRequest,
};
use super::api_response::{
AuthTableQueryResponse, ConfigResponse, GetDatabaseResponse,
GetFunctionResponse,
@@ -91,6 +93,9 @@ impl RESTApi {
pub const TABLE_NAME_PATTERN: &'static str = "tableNamePattern";
pub const VIEW_NAME_PATTERN: &'static str = "viewNamePattern";
pub const FUNCTION_NAME_PATTERN: &'static str = "functionNamePattern";
+ pub const PARTITION_NAME_PATTERN: &'static str = "partitionNamePattern";
+ /// Bounds one request: catalog services cap the partitions a single call
may carry.
+ const PARTITION_REQUEST_SIZE: u32 = 1000;
pub const TABLE_TYPE: &'static str = "tableType";
/// Create a new RESTApi from options.
@@ -556,36 +561,232 @@ impl RESTApi {
// ==================== Partition Operations ====================
+ /// Create table partitions in a single REST request.
+ pub async fn create_partitions(
+ &self,
+ identifier: &Identifier,
+ partition_specs: Vec<HashMap<String, String>>,
+ ignore_if_exists: bool,
+ ) -> Result<()> {
+ self.create_partitions_with_statistics(
+ identifier,
+ partition_specs,
+ ignore_if_exists,
+ None,
+ false,
+ )
+ .await
+ }
+
+ /// Create table partitions and report statistics for them in a single
REST request.
+ ///
+ /// Statistics are matched to the specs by spec and may cover only some of
them.
+ /// `replace_statistics` says whether they replace what the catalog holds
or add to it, and is
+ /// not sent when no statistics are.
+ pub async fn create_partitions_with_statistics(
+ &self,
+ identifier: &Identifier,
+ partition_specs: Vec<HashMap<String, String>>,
+ ignore_if_exists: bool,
+ statistics: Option<Vec<PartitionStatistics>>,
+ replace_statistics: bool,
+ ) -> Result<()> {
+ let database = identifier.database();
+ let table = identifier.object();
+ validate_non_empty_multi(&[(database, "database name"), (table, "table
name")])?;
+ let path = self.resource_paths.partitions(database, table);
+ let mut request = CreatePartitionsRequest::new(partition_specs,
ignore_if_exists);
+ if let Some(statistics) = statistics {
+ request = request.with_statistics(statistics, replace_statistics);
+ }
+ let _resp: serde_json::Value = self.client.post(&path,
&request).await?;
+ Ok(())
+ }
+
+ /// Unregister table partitions in a single REST request.
+ ///
+ /// The REST service removes metadata only; it does not delete partition
+ /// directories or data files.
+ pub async fn drop_partitions(
+ &self,
+ identifier: &Identifier,
+ partition_specs: Vec<HashMap<String, String>>,
+ ignore_if_not_exists: bool,
+ ) -> Result<()> {
+ let database = identifier.database();
+ let table = identifier.object();
+ validate_non_empty_multi(&[(database, "database name"), (table, "table
name")])?;
+ let path = self.resource_paths.drop_partitions(database, table);
+ let request = DropPartitionsRequest::new(partition_specs,
ignore_if_not_exists);
+ let _resp: serde_json::Value = self.client.post(&path,
&request).await?;
+ Ok(())
+ }
+
/// List all partitions of a table, paging internally.
pub async fn list_partitions(&self, identifier: &Identifier) ->
Result<Vec<Partition>> {
+ self.drain_partitions(identifier, None, None, None).await
+ }
+
+ /// List partitions, asking the catalog to return only those whose
partition name
+ /// matches `partition_name_pattern`.
+ ///
+ /// The pattern is a pushdown hint: a catalog may apply it partially or
not at all, so
+ /// the result is a superset of the matching partitions and never misses
one. Callers
+ /// keep applying their own filter to what comes back.
+ pub async fn list_partitions_by_name_pattern(
+ &self,
+ identifier: &Identifier,
+ partition_name_pattern: Option<&str>,
+ ) -> Result<Vec<Partition>> {
+ self.drain_partitions(
+ identifier,
+ Some(Self::PARTITION_REQUEST_SIZE),
+ partition_name_pattern,
+ None,
+ )
+ .await
+ }
+
+ /// List partitions, asking the catalog to return only those matching
`filter`, a partition
+ /// predicate in the REST catalog predicate JSON format, together with
+ /// `partition_name_pattern` when one is given.
+ ///
+ /// Like the pattern, the filter is a pushdown hint: a catalog may apply
it partially or not
+ /// at all, so callers keep applying their own filter to what comes back.
+ pub async fn list_partitions_by_filter(
+ &self,
+ identifier: &Identifier,
+ filter: &str,
+ partition_name_pattern: Option<&str>,
+ ) -> Result<Vec<Partition>> {
+ self.drain_partitions(
+ identifier,
+ Some(Self::PARTITION_REQUEST_SIZE),
+ partition_name_pattern,
+ Some(filter),
+ )
+ .await
+ }
+
+ /// List one page of partitions matching `filter`. See
[`Self::list_partitions_by_filter`].
+ pub async fn list_partitions_by_filter_paged(
+ &self,
+ identifier: &Identifier,
+ filter: &str,
+ max_results: Option<u32>,
+ page_token: Option<&str>,
+ partition_name_pattern: Option<&str>,
+ ) -> Result<PagedList<Partition>> {
+ let database = identifier.database();
+ let table = identifier.object();
+ validate_non_empty_multi(&[(database, "database name"), (table, "table
name")])?;
+ let path = self
+ .resource_paths
+ .list_partitions_by_filter(database, table);
+ let request = ListPartitionsByFilterRequest::new(
+ filter.to_string(),
+ partition_name_pattern
+ .filter(|pattern| !pattern.is_empty())
+ .map(str::to_string),
+ max_results,
+ page_token.map(str::to_string),
+ );
+ let response: ListPartitionsResponse = self.client.post(&path,
&request).await?;
+ Ok(PagedList::new(
+ response.partitions.unwrap_or_default(),
+ response.next_page_token,
+ ))
+ }
+
+ /// Return those of the given complete partition specs that are registered.
+ ///
+ /// The specs go out in one request, so callers bound how many they send
at once.
+ pub async fn list_partitions_by_names(
+ &self,
+ identifier: &Identifier,
+ partition_specs: Vec<HashMap<String, String>>,
+ ) -> Result<Vec<Partition>> {
+ let database = identifier.database();
+ let table = identifier.object();
+ validate_non_empty_multi(&[(database, "database name"), (table, "table
name")])?;
+ let path = self
+ .resource_paths
+ .list_partitions_by_names(database, table);
+ let request = ListPartitionsByNamesRequest::new(partition_specs);
+ let response: ListPartitionsResponse = self.client.post(&path,
&request).await?;
+ Ok(response.partitions.unwrap_or_default())
+ }
+
+ async fn drain_partitions(
+ &self,
+ identifier: &Identifier,
+ max_results: Option<u32>,
+ partition_name_pattern: Option<&str>,
+ filter: Option<&str>,
+ ) -> Result<Vec<Partition>> {
let database = identifier.database();
let table = identifier.object();
validate_non_empty_multi(&[(database, "database name"), (table, "table
name")])?;
let mut results = Vec::new();
let mut page_token: Option<String> = None;
+ let mut seen_page_tokens = HashSet::new();
loop {
- let paged = self
- .list_partitions_paged(identifier, None, page_token.as_deref())
- .await?;
- let is_empty = paged.elements.is_empty();
+ let paged = match filter {
+ Some(filter) => {
+ self.list_partitions_by_filter_paged(
+ identifier,
+ filter,
+ max_results,
+ page_token.as_deref(),
+ partition_name_pattern,
+ )
+ .await?
+ }
+ None => {
+ self.list_partitions_paged(
+ identifier,
+ max_results,
+ page_token.as_deref(),
+ partition_name_pattern,
+ )
+ .await?
+ }
+ };
results.extend(paged.elements);
- page_token = paged.next_page_token;
- if page_token.is_none() || is_empty {
+
+ let Some(next_page_token) = paged.next_page_token.filter(|token|
!token.is_empty())
+ else {
break;
+ };
+ if !seen_page_tokens.insert(next_page_token.clone()) {
+ return Err(crate::Error::UnexpectedError {
+ message: format!(
+ "REST catalog returned partition page token
'{next_page_token}' more than \
+ once for table {}",
+ identifier.full_name()
+ ),
+ source: None,
+ });
}
+ page_token = Some(next_page_token);
}
Ok(results)
}
/// List partitions with pagination.
+ ///
+ /// `partition_name_pattern` is a SQL LIKE pattern over partition names,
where `%` is
+ /// the only wildcard. Like [`Self::list_partitions_by_name_pattern`], it
is a hint the
+ /// catalog may ignore.
pub async fn list_partitions_paged(
&self,
identifier: &Identifier,
max_results: Option<u32>,
page_token: Option<&str>,
+ partition_name_pattern: Option<&str>,
) -> Result<PagedList<Partition>> {
let database = identifier.database();
let table = identifier.object();
@@ -599,6 +800,9 @@ impl RESTApi {
if let Some(token) = page_token {
params.push((Self::PAGE_TOKEN, token.to_string()));
}
+ if let Some(pattern) = partition_name_pattern.filter(|pattern|
!pattern.is_empty()) {
+ params.push((Self::PARTITION_NAME_PATTERN, pattern.to_string()));
+ }
let response: ListPartitionsResponse = if params.is_empty() {
self.client.get(&path, None::<&[(&str, &str)]>).await?
diff --git a/crates/paimon/src/catalog/mod.rs b/crates/paimon/src/catalog/mod.rs
index f16fd25a..308b78dc 100644
--- a/crates/paimon/src/catalog/mod.rs
+++ b/crates/paimon/src/catalog/mod.rs
@@ -560,6 +560,73 @@ pub trait Catalog: Send + Sync {
})
}
+ // ======================= partition methods
===============================
+
+ /// Register table partition specs in the catalog.
+ ///
+ /// When `ignore_if_exists` is false, implementations must reject the
+ /// entire request if any supplied spec already exists. When true, existing
+ /// specs are ignored so callers can safely retry the request.
+ async fn create_partitions(
+ &self,
+ identifier: &Identifier,
+ partition_specs: Vec<HashMap<String, String>>,
+ ignore_if_exists: bool,
+ ) -> Result<()> {
+ self.create_partitions_with_statistics(
+ identifier,
+ partition_specs,
+ ignore_if_exists,
+ None,
+ false,
+ )
+ .await
+ }
+
+ /// Register partition specs and report statistics for them in the same
call, so a partition
+ /// is never registered by a request whose statistics failed on their own.
+ ///
+ /// Statistics are matched to `partition_specs` by spec and may cover only
some of them.
+ /// `replace_statistics` says whether they replace what the catalog holds
or add to it. A field
+ /// reported as [`Partition::UNKNOWN`] says nothing about itself and
leaves the stored value as
+ /// it was. Reporting never unregisters a partition.
+ ///
+ /// This is the method implementations provide, so that one forwarding only
+ /// [`Self::create_partitions`] cannot drop every report without anyone
noticing.
+ async fn create_partitions_with_statistics(
+ &self,
+ _identifier: &Identifier,
+ _partition_specs: Vec<HashMap<String, String>>,
+ _ignore_if_exists: bool,
+ _statistics: Option<Vec<crate::spec::PartitionStatistics>>,
+ _replace_statistics: bool,
+ ) -> Result<()> {
+ Err(Error::Unsupported {
+ message: "Catalog does not support creating
partitions".to_string(),
+ })
+ }
+
+ /// Return those of the given complete partition specs that are registered.
+ ///
+ /// Specs are compared with the registered values as they are, without
normalizing them.
+ /// The default impl filters [`Self::list_partitions`]; catalogs that can
look partitions up
+ /// by name (e.g. `RESTCatalog`) override it so a few specs never cost a
full listing.
+ async fn list_partitions_by_names(
+ &self,
+ identifier: &Identifier,
+ partition_specs: Vec<HashMap<String, String>>,
+ ) -> Result<Vec<Partition>> {
+ if partition_specs.is_empty() {
+ return Ok(Vec::new());
+ }
+ Ok(self
+ .list_partitions(identifier)
+ .await?
+ .into_iter()
+ .filter(|partition| partition_specs.contains(&partition.spec))
+ .collect())
+ }
+
/// List partitions for a table.
///
/// Default impl scans the table's manifest entries via
diff --git a/crates/paimon/src/catalog/rest/rest_catalog.rs
b/crates/paimon/src/catalog/rest/rest_catalog.rs
index db167c3d..df068b2b 100644
--- a/crates/paimon/src/catalog/rest/rest_catalog.rs
+++ b/crates/paimon/src/catalog/rest/rest_catalog.rs
@@ -34,10 +34,12 @@ use crate::catalog::{
use crate::common::{CatalogOptions, Options};
use crate::error::Error;
use crate::io::cache::{create_local_cache_with_namespace, LocalCache};
-use crate::spec::{Partition, Schema, SchemaChange};
+use crate::spec::{Partition, PartitionStatistics, Schema, SchemaChange};
use crate::table::{RESTEnv, Table};
use crate::Result;
+const PARTITION_BATCH_SIZE: usize = 1000;
+
/// REST catalog implementation.
///
/// This catalog communicates with a Paimon REST catalog server
@@ -411,6 +413,79 @@ impl Catalog for RESTCatalog {
))
}
+ async fn create_partitions_with_statistics(
+ &self,
+ identifier: &Identifier,
+ partition_specs: Vec<HashMap<String, String>>,
+ ignore_if_exists: bool,
+ statistics: Option<Vec<PartitionStatistics>>,
+ replace_statistics: bool,
+ ) -> Result<()> {
+ let statistics = statistics
+ .map(|statistics| index_statistics_by_spec(identifier,
&partition_specs, statistics))
+ .transpose()?;
+ if partition_specs.is_empty() {
+ return Ok(());
+ }
+ // A strict create is rejected whole when any partition already
exists, so it is never
+ // split; an idempotent one is sent in bounded batches, each with its
own statistics.
+ let batch_size = if ignore_if_exists {
+ PARTITION_BATCH_SIZE
+ } else {
+ partition_specs.len()
+ };
+ for batch in partition_specs.chunks(batch_size) {
+ let batch_statistics = statistics.as_ref().map(|by_spec| {
+ batch
+ .iter()
+ .filter_map(|spec| by_spec.get(&spec_key(spec)).cloned())
+ .collect::<Vec<_>>()
+ });
+ self.api
+ .create_partitions_with_statistics(
+ identifier,
+ batch.to_vec(),
+ ignore_if_exists,
+ batch_statistics,
+ replace_statistics,
+ )
+ .await
+ .map_err(|error| map_rest_error_for_create_partitions(error,
identifier))?;
+ }
+ Ok(())
+ }
+
+ async fn list_partitions_by_names(
+ &self,
+ identifier: &Identifier,
+ partition_specs: Vec<HashMap<String, String>>,
+ ) -> Result<Vec<Partition>> {
+ let mut partitions = Vec::new();
+ for batch in partition_specs.chunks(PARTITION_BATCH_SIZE) {
+ match self
+ .api
+ .list_partitions_by_names(identifier, batch.to_vec())
+ .await
+ {
+ Ok(found) => partitions.extend(found),
+ // A catalog without the lookup still answers the plain
listing, which
+ // `list_partitions` falls back from the same way.
+ Err(Error::RestApi {
+ source: RestError::NotImplemented { .. },
+ }) => {
+ return Ok(self
+ .list_partitions(identifier)
+ .await?
+ .into_iter()
+ .filter(|partition|
partition_specs.contains(&partition.spec))
+ .collect());
+ }
+ Err(error) => return Err(map_rest_error_for_table(error,
identifier)),
+ }
+ }
+ Ok(partitions)
+ }
+
async fn list_partitions(&self, identifier: &Identifier) ->
Result<Vec<Partition>> {
match self.api.list_partitions(identifier).await {
Ok(parts) => Ok(parts),
@@ -432,7 +507,7 @@ impl Catalog for RESTCatalog {
) -> Result<PagedList<Partition>> {
match self
.api
- .list_partitions_paged(identifier, max_results, page_token)
+ .list_partitions_paged(identifier, max_results, page_token, None)
.await
{
Ok(page) => Ok(page),
@@ -493,6 +568,94 @@ fn map_rest_error_for_table(err: Error, identifier:
&Identifier) -> Error {
}
}
+/// A partition spec in a form that can key a map.
+fn spec_key(spec: &HashMap<String, String>) -> Vec<(String, String)> {
+ let mut entries = spec
+ .iter()
+ .map(|(key, value)| (key.clone(), value.clone()))
+ .collect::<Vec<_>>();
+ entries.sort_unstable();
+ entries
+}
+
+/// Index reported statistics by the spec they describe, before anything is
sent.
+///
+/// A partition registered twice is rejected: every occurrence would carry the
same report, so a
+/// spec repeated across batches would have an additive report applied once
per batch. A report
+/// for a partition that is not being created and a partition reported twice
are rejected too: the
+/// catalog cannot tell which of two reports is meant.
+///
+/// Mirrors Java
`CatalogFormatTablePartitionManager.validateAndIndexStatistics`.
+fn index_statistics_by_spec(
+ identifier: &Identifier,
+ partition_specs: &[HashMap<String, String>],
+ statistics: Vec<PartitionStatistics>,
+) -> Result<HashMap<Vec<(String, String)>, PartitionStatistics>> {
+ let mut requested =
std::collections::HashSet::with_capacity(partition_specs.len());
+ for spec in partition_specs {
+ if !requested.insert(spec_key(spec)) {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Partition {spec:?} of table {} is registered twice in one
request that \
+ reports statistics; report each partition once",
+ identifier.full_name()
+ ),
+ source: None,
+ });
+ }
+ }
+ let mut by_spec = HashMap::with_capacity(statistics.len());
+ for statistic in statistics {
+ let key = spec_key(&statistic.spec);
+ if !requested.contains(&key) {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Partition statistics were reported for {:?} of table {},
which is not among \
+ the partitions being created",
+ statistic.spec,
+ identifier.full_name()
+ ),
+ source: None,
+ });
+ }
+ let spec = statistic.spec.clone();
+ if by_spec.insert(key, statistic).is_some() {
+ return Err(Error::DataInvalid {
+ message: format!(
+ "Partition statistics were reported twice for {spec:?} of
table {}",
+ identifier.full_name()
+ ),
+ source: None,
+ });
+ }
+ }
+ Ok(by_spec)
+}
+
+fn map_rest_error_for_create_partitions(err: Error, identifier: &Identifier)
-> Error {
+ match err {
+ Error::RestApi {
+ source: RestError::AlreadyExists { message, .. },
+ } => Error::DataInvalid {
+ message: format!(
+ "One or more partitions already exist for table {}: {message}",
+ identifier.full_name()
+ ),
+ source: None,
+ },
+ Error::RestApi {
+ source: RestError::BadRequest { message },
+ } => Error::DataInvalid {
+ message: format!(
+ "Invalid partition request for table {}: {message}",
+ identifier.full_name()
+ ),
+ source: None,
+ },
+ other => map_rest_error_for_table(other, identifier),
+ }
+}
+
/// Map a REST API error from creating a persistent view.
fn map_rest_error_for_create_view(err: Error, identifier: &Identifier) ->
Error {
match err {
diff --git a/crates/paimon/src/spec/partition.rs
b/crates/paimon/src/spec/partition.rs
index a78fe5b2..24372650 100644
--- a/crates/paimon/src/spec/partition.rs
+++ b/crates/paimon/src/spec/partition.rs
@@ -26,9 +26,15 @@ use std::collections::HashMap;
#[serde(rename_all = "camelCase")]
pub struct Partition {
pub spec: HashMap<String, String>,
+ /// Statistics are optional on the wire: one the catalog leaves out was
never reported, so it
+ /// reads as [`Self::UNKNOWN`] rather than as an exact zero.
+ #[serde(default = "unknown_statistic")]
pub record_count: i64,
+ #[serde(default = "unknown_statistic")]
pub file_size_in_bytes: i64,
+ #[serde(default = "unknown_statistic")]
pub file_count: i64,
+ #[serde(default = "unknown_statistic")]
pub last_file_creation_time: i64,
#[serde(default)]
pub total_buckets: i32,
@@ -67,6 +73,10 @@ impl Partition {
}
}
+fn unknown_statistic() -> i64 {
+ Partition::UNKNOWN
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -130,6 +140,30 @@ mod tests {
assert_eq!(decoded.spec.get("dt"), Some(&"2024-01-01".to_string()));
}
+ #[test]
+ fn test_absent_statistics_decode_as_unknown_and_zero_stays_exact() {
+ let absent: Partition =
+ serde_json::from_str(r#"{"spec": {"pt": "1"}, "done":
true}"#).unwrap();
+ for value in [
+ absent.record_count,
+ absent.file_size_in_bytes,
+ absent.file_count,
+ absent.last_file_creation_time,
+ ] {
+ assert_eq!(value, Partition::UNKNOWN);
+ }
+
+ let zero: Partition = serde_json::from_str(
+ r#"{"spec": {"pt": "1"}, "recordCount": 0, "fileSizeInBytes": 0,
+ "fileCount": 0, "lastFileCreationTime": 0}"#,
+ )
+ .unwrap();
+ assert_eq!(
+ (zero.record_count, zero.file_size_in_bytes, zero.file_count),
+ (0, 0, 0)
+ );
+ }
+
#[test]
fn test_unknown_is_negative_and_zero_is_a_measurement() {
assert_eq!(Partition::UNKNOWN, -1);
diff --git a/crates/paimon/src/spec/partition_statistics.rs
b/crates/paimon/src/spec/partition_statistics.rs
index 30f05793..ff82f314 100644
--- a/crates/paimon/src/spec/partition_statistics.rs
+++ b/crates/paimon/src/spec/partition_statistics.rs
@@ -22,13 +22,17 @@ use std::collections::HashMap;
///
/// Reference:
[org.apache.paimon.partition.PartitionStatistics](https://github.com/apache/paimon)
/// and [pypaimon snapshot_commit.py
PartitionStatistics](https://github.com/apache/paimon/blob/master/paimon-python/pypaimon/snapshot/snapshot_commit.py)
-#[derive(Debug, Clone, Serialize, Deserialize)]
+///
+/// The same shape reports what a partition holds when a Format Table's
partitions are measured.
+/// There a negative field is unknown rather than a decrement, which is why
+/// `last_file_creation_time` is signed like the other counts.
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PartitionStatistics {
pub spec: HashMap<String, String>,
pub record_count: i64,
pub file_size_in_bytes: i64,
pub file_count: i64,
- pub last_file_creation_time: u64,
+ pub last_file_creation_time: i64,
pub total_buckets: i32,
}
diff --git a/crates/paimon/src/spec/predicate.rs
b/crates/paimon/src/spec/predicate.rs
index c168e769..82a93eac 100644
--- a/crates/paimon/src/spec/predicate.rs
+++ b/crates/paimon/src/spec/predicate.rs
@@ -672,6 +672,116 @@ impl Predicate {
Predicate::AlwaysTrue | Predicate::AlwaysFalse => {}
}
}
+
+ /// Serialize this predicate in the REST catalog wire format, the inverse
of
+ /// [`Self::from_rest_json`].
+ ///
+ /// Returns `None` when some part of the predicate has no wire form: the
format has no `NOT`,
+ /// and Java cannot read temporal, decimal or binary literals back from
JSON. Field references
+ /// carry each leaf's index as it is, so a predicate meant for a partition
row must already be
+ /// indexed by partition field.
+ pub fn to_rest_json(&self) -> Option<serde_json::Value> {
+ match self {
+ Predicate::AlwaysTrue => Some(rest_constant_leaf("TRUE")),
+ Predicate::AlwaysFalse => Some(rest_constant_leaf("FALSE")),
+ Predicate::And(children) | Predicate::Or(children) => {
+ if children.is_empty() {
+ return None;
+ }
+ let function = if matches!(self, Predicate::And(_)) {
+ "AND"
+ } else {
+ "OR"
+ };
+ let children = children
+ .iter()
+ .map(Predicate::to_rest_json)
+ .collect::<Option<Vec<_>>>()?;
+ Some(serde_json::json!({
+ "kind": "COMPOUND",
+ "function": function,
+ "children": children,
+ }))
+ }
+ Predicate::Not(_) => None,
+ Predicate::Leaf {
+ column,
+ index,
+ data_type,
+ op,
+ literals,
+ } => {
+ if matches!(op, PredicateOperator::Like) && literals.len() !=
1 {
+ return None;
+ }
+ let literals = literals
+ .iter()
+ .map(datum_to_rest_json)
+ .collect::<Option<Vec<_>>>()?;
+ Some(serde_json::json!({
+ "kind": "LEAF",
+ "transform": {
+ "name": "FIELD_REF",
+ "fieldRef": {
+ "index": index,
+ "name": column,
+ "type": serde_json::to_value(data_type).ok()?,
+ },
+ },
+ "function": rest_leaf_function(*op),
+ "literals": literals,
+ }))
+ }
+ }
+ }
+}
+
+fn rest_constant_leaf(function: &str) -> serde_json::Value {
+ serde_json::json!({
+ "kind": "LEAF",
+ "transform": {"name": "NULL"},
+ "function": function,
+ "literals": [],
+ })
+}
+
+fn rest_leaf_function(op: PredicateOperator) -> &'static str {
+ match op {
+ PredicateOperator::IsNull => "IS_NULL",
+ PredicateOperator::IsNotNull => "IS_NOT_NULL",
+ PredicateOperator::Eq => "EQUAL",
+ PredicateOperator::NotEq => "NOT_EQUAL",
+ PredicateOperator::Lt => "LESS_THAN",
+ PredicateOperator::LtEq => "LESS_OR_EQUAL",
+ PredicateOperator::Gt => "GREATER_THAN",
+ PredicateOperator::GtEq => "GREATER_OR_EQUAL",
+ PredicateOperator::In => "IN",
+ PredicateOperator::NotIn => "NOT_IN",
+ PredicateOperator::StartsWith => "STARTS_WITH",
+ PredicateOperator::EndsWith => "ENDS_WITH",
+ PredicateOperator::Contains => "CONTAINS",
+ PredicateOperator::ArrayContains => "ARRAY_CONTAINS",
+ PredicateOperator::ArraysOverlap => "ARRAYS_OVERLAP",
+ PredicateOperator::ArrayContainsAll => "ARRAY_CONTAINS_ALL",
+ PredicateOperator::Like => "LIKE",
+ PredicateOperator::Between => "BETWEEN",
+ PredicateOperator::NotBetween => "NOT_BETWEEN",
+ }
+}
+
+/// A literal the way Java writes it, for the types Java can also read back.
+fn datum_to_rest_json(datum: &Datum) -> Option<serde_json::Value> {
+ match datum {
+ Datum::Bool(value) => Some(serde_json::Value::Bool(*value)),
+ Datum::TinyInt(value) => Some(serde_json::Value::from(*value)),
+ Datum::SmallInt(value) => Some(serde_json::Value::from(*value)),
+ Datum::Int(value) => Some(serde_json::Value::from(*value)),
+ Datum::Long(value) => Some(serde_json::Value::from(*value)),
+ Datum::Float(value) =>
serde_json::Number::from_f64(f64::from(*value)).map(Into::into),
+ Datum::Double(value) =>
serde_json::Number::from_f64(*value).map(Into::into),
+ Datum::String(value) => Some(serde_json::Value::String(value.clone())),
+ _ => None,
+ }
}
fn rest_json_err(detail: impl fmt::Display) -> Error {
@@ -3390,6 +3500,102 @@ mod tests {
}
}
+ #[test]
+ fn test_to_rest_json_is_read_back_by_from_rest_json() {
+ let fields = test_fields();
+ let builder = PredicateBuilder::new(&fields);
+ let id_is_one = builder.equal("id", Datum::Int(1)).unwrap();
+ let predicates = vec![
+ builder.is_null("id").unwrap(),
+ builder.is_not_null("name").unwrap(),
+ id_is_one.clone(),
+ builder
+ .not_equal("name", Datum::String("x".to_string()))
+ .unwrap(),
+ builder.less_than("hr", Datum::Int(3)).unwrap(),
+ builder.less_or_equal("hr", Datum::Int(3)).unwrap(),
+ builder.greater_than("hr", Datum::Int(3)).unwrap(),
+ builder.greater_or_equal("hr", Datum::Int(3)).unwrap(),
+ builder
+ .is_in("id", vec![Datum::Int(1), Datum::Int(2)])
+ .unwrap(),
+ builder
+ .is_not_in("id", vec![Datum::Int(1), Datum::Int(2)])
+ .unwrap(),
+ builder
+ .starts_with("name", Datum::String("a".to_string()))
+ .unwrap(),
+ builder
+ .ends_with("name", Datum::String("a".to_string()))
+ .unwrap(),
+ builder
+ .contains("name", Datum::String("a".to_string()))
+ .unwrap(),
+ builder
+ .like("name", Datum::String("a%".to_string()), None)
+ .unwrap(),
+ builder.between("hr", Datum::Int(1), Datum::Int(5)).unwrap(),
+ builder
+ .not_between("hr", Datum::Int(1), Datum::Int(5))
+ .unwrap(),
+ Predicate::and(vec![
+ id_is_one.clone(),
+ builder
+ .equal("name", Datum::String("x".to_string()))
+ .unwrap(),
+ ]),
+ Predicate::or(vec![id_is_one, builder.equal("id",
Datum::Int(2)).unwrap()]),
+ Predicate::AlwaysTrue,
+ Predicate::AlwaysFalse,
+ ];
+ for predicate in predicates {
+ let json = predicate
+ .to_rest_json()
+ .unwrap_or_else(|| panic!("{predicate} should have a wire
form"));
+ let parsed = Predicate::from_rest_json(&json.to_string(),
&fields).unwrap();
+ assert_eq!(parsed.to_string(), predicate.to_string(), "{json}");
+ }
+ }
+
+ /// Wire strings below are taken verbatim from Java
`PredicateJsonSerdeTest`.
+ #[test]
+ fn test_to_rest_json_matches_java_wire_format() {
+ let fields = vec![DataField::new(
+ 0,
+ "f0".to_string(),
+ DataType::Int(IntType::new()),
+ )];
+ for java in [
+
r#"{"kind":"LEAF","transform":{"name":"FIELD_REF","fieldRef":{"index":0,"name":"f0","type":"INT"}},"function":"EQUAL","literals":[1]}"#,
+
r#"{"kind":"LEAF","transform":{"name":"FIELD_REF","fieldRef":{"index":0,"name":"f0","type":"INT"}},"function":"IS_NULL","literals":[]}"#,
+
r#"{"kind":"LEAF","transform":{"name":"NULL"},"function":"TRUE","literals":[]}"#,
+
r#"{"kind":"LEAF","transform":{"name":"FIELD_REF","fieldRef":{"index":0,"name":"f0","type":"INT"}},"function":"BETWEEN","literals":[3,7]}"#,
+ ] {
+ let predicate = Predicate::from_rest_json(java, &fields).unwrap();
+ assert_eq!(
+ predicate.to_rest_json().unwrap(),
+ serde_json::from_str::<serde_json::Value>(java).unwrap()
+ );
+ }
+ }
+
+ #[test]
+ fn test_to_rest_json_refuses_what_java_cannot_read_back() {
+ let fields = test_fields();
+ let builder = PredicateBuilder::new(&fields);
+ let on_date = builder.equal("dt", Datum::Date(20_656)).unwrap();
+ let on_id = builder.equal("id", Datum::Int(1)).unwrap();
+
+ assert!(on_date.to_rest_json().is_none());
+ assert!(Predicate::Not(Box::new(on_id.clone()))
+ .to_rest_json()
+ .is_none());
+ // One child without a wire form keeps the whole compound off the wire.
+ assert!(Predicate::and(vec![on_id, on_date])
+ .to_rest_json()
+ .is_none());
+ }
+
#[test]
fn test_from_rest_json_rejects_empty_compounds() {
let fields = test_fields();
diff --git a/crates/paimon/src/table/table_commit.rs
b/crates/paimon/src/table/table_commit.rs
index 9f3f707d..676d5358 100644
--- a/crates/paimon/src/table/table_commit.rs
+++ b/crates/paimon/src/table/table_commit.rs
@@ -2792,8 +2792,8 @@ impl TableCommit {
let file = entry.file();
let file_creation_time = file
.creation_time
- .map(|t| t.timestamp_millis() as u64)
- .unwrap_or_else(current_time_millis);
+ .map(|t| t.timestamp_millis())
+ .unwrap_or_else(|| current_time_millis() as i64);
let stats =
stats_map.entry(partition_bytes.clone()).or_insert_with(|| {
// Parse partition spec from BinaryRow
diff --git a/crates/paimon/tests/mock_server.rs
b/crates/paimon/tests/mock_server.rs
index 6c8a0048..934c55de 100644
--- a/crates/paimon/tests/mock_server.rs
+++ b/crates/paimon/tests/mock_server.rs
@@ -35,12 +35,17 @@ use tokio::task::JoinHandle;
use paimon::api::{
AlterDatabaseRequest, AlterTableRequest, AuditRESTResponse, ConfigResponse,
- CreateFunctionRequest, CreateViewRequest, ErrorResponse,
GetDatabaseResponse,
- GetFunctionResponse, GetTableResponse, GetViewResponse,
ListDatabasesResponse,
- ListFunctionsResponse, ListTablesResponse, ListViewsResponse,
RenameTableRequest,
- ResourcePaths,
+ CreateFunctionRequest, CreatePartitionsRequest, CreateViewRequest,
DropPartitionsRequest,
+ ErrorResponse, GetDatabaseResponse, GetFunctionResponse, GetTableResponse,
GetViewResponse,
+ ListDatabasesResponse, ListFunctionsResponse,
ListPartitionsByFilterRequest,
+ ListPartitionsByNamesRequest, ListPartitionsResponse, ListTablesResponse,
ListViewsResponse,
+ RenameTableRequest, ResourcePaths,
};
use paimon::catalog::{Function, Identifier};
+use paimon::spec::Partition;
+
+type PartitionPageResponse = (Vec<Partition>, Option<String>);
+type PartitionSpecPageResponse = (Vec<HashMap<String, String>>,
Option<String>);
#[derive(Clone, Debug, Default)]
struct MockState {
@@ -48,17 +53,63 @@ struct MockState {
tables: HashMap<String, GetTableResponse>,
views: HashMap<String, GetViewResponse>,
functions: HashMap<String, GetFunctionResponse>,
+ partitions: HashMap<String, Vec<Partition>>,
+ partition_page_responses: HashMap<String, Vec<PartitionPageResponse>>,
+ partition_list_call_counts: HashMap<String, usize>,
+ partition_list_name_patterns: HashMap<String, Vec<Option<String>>>,
+ partition_list_by_names_calls: HashMap<String, Vec<Vec<HashMap<String,
String>>>>,
+ partition_list_by_filter_requests: HashMap<String,
Vec<ListPartitionsByFilterRequest>>,
+ list_partitions_by_names_error_status: Option<StatusCode>,
+ list_partitions_by_filter_error_status: Option<StatusCode>,
view_function_endpoints_unsupported: bool,
drop_view_error_status: Option<StatusCode>,
list_page_size: Option<usize>,
no_permission_databases: HashSet<String>,
no_permission_tables: HashSet<String>,
+ create_partitions_calls: Vec<(String, String, CreatePartitionsRequest)>,
+ drop_partitions_calls: Vec<(String, String, DropPartitionsRequest)>,
+ create_partitions_error_status: Option<StatusCode>,
/// ECS metadata role name (for token loader testing)
ecs_role_name: Option<String>,
/// ECS metadata token (for token loader testing)
ecs_token: Option<serde_json::Value>,
}
+/// Match a partition spec against a partition-name pattern the way a catalog
would.
+///
+/// Only the pattern shape clients send is understood: `key=value` segments
joined by `/`,
+/// optionally ending in a `/%` wildcard. Values are compared unescaped, so a
test that
+/// needs escaping should assert the pattern the client sent instead of the
rows returned.
+fn partition_spec_matches_name_pattern(spec: &HashMap<String, String>,
pattern: &str) -> bool {
+ let prefix = pattern.strip_suffix("/%");
+ let segments = prefix.unwrap_or(pattern).split('/').collect::<Vec<_>>();
+ if prefix.is_none() && segments.len() != spec.len() {
+ return false;
+ }
+ segments.iter().all(|segment| {
+ segment
+ .split_once('=')
+ .is_some_and(|(key, value)| spec.get(key).map(String::as_str) ==
Some(value))
+ })
+}
+
+fn partition_from_spec(spec: HashMap<String, String>) -> Partition {
+ Partition {
+ spec,
+ record_count: Partition::UNKNOWN,
+ file_size_in_bytes: Partition::UNKNOWN,
+ file_count: Partition::UNKNOWN,
+ last_file_creation_time: Partition::UNKNOWN,
+ total_buckets: 0,
+ done: false,
+ created_at: None,
+ created_by: None,
+ updated_at: None,
+ updated_by: None,
+ options: None,
+ }
+}
+
fn paginate_names(
names: Vec<String>,
params: &HashMap<String, String>,
@@ -282,6 +333,11 @@ impl RESTServer {
// Also remove all tables in this database
let prefix = format!("{name}.");
s.tables.retain(|key, _| !key.starts_with(&prefix));
+ s.partitions.retain(|key, _| !key.starts_with(&prefix));
+ s.partition_page_responses
+ .retain(|key, _| !key.starts_with(&prefix));
+ s.partition_list_call_counts
+ .retain(|key, _| !key.starts_with(&prefix));
s.no_permission_tables
.retain(|key| !key.starts_with(&prefix));
(StatusCode::OK, Json(serde_json::json!(""))).into_response()
@@ -724,6 +780,9 @@ impl RESTServer {
}
if s.tables.remove(&key).is_some() {
+ s.partitions.remove(&key);
+ s.partition_page_responses.remove(&key);
+ s.partition_list_call_counts.remove(&key);
s.no_permission_tables.remove(&key);
(StatusCode::OK, Json(serde_json::json!(""))).into_response()
} else {
@@ -771,6 +830,322 @@ impl RESTServer {
}
}
+ /// Handle POST /databases/:db/tables/:table/partitions - create
partitions.
+ pub async fn create_partitions(
+ Path((db, table)): Path<(String, String)>,
+ Extension(state): Extension<Arc<RESTServer>>,
+ Json(request): Json<CreatePartitionsRequest>,
+ ) -> impl IntoResponse {
+ let mut inner = state.inner.lock().unwrap();
+ inner
+ .create_partitions_calls
+ .push((db.clone(), table.clone(), request.clone()));
+ if let Some(status) = inner.create_partitions_error_status {
+ let message = if status == StatusCode::CONFLICT {
+ "Some partitions already exist"
+ } else {
+ "Invalid partition request"
+ };
+ let error = ErrorResponse::new(
+ Some("partition".to_string()),
+ Some(table.clone()),
+ Some(message.to_string()),
+ Some(status.as_u16() as i32),
+ );
+ return (status, Json(error)).into_response();
+ }
+
+ let key = format!("{db}.{table}");
+ if !inner.tables.contains_key(&key) {
+ let error = ErrorResponse::new(
+ Some("table".to_string()),
+ Some(table),
+ Some("Not Found".to_string()),
+ Some(404),
+ );
+ return (StatusCode::NOT_FOUND, Json(error)).into_response();
+ }
+
+ let registered_partitions = inner.partitions.entry(key).or_default();
+ let has_conflict = request
+ .partition_specs
+ .iter()
+ .enumerate()
+ .any(|(index, spec)| {
+ registered_partitions
+ .iter()
+ .any(|partition| partition.spec == *spec)
+ || request.partition_specs[..index].contains(spec)
+ });
+ if has_conflict && !request.ignore_if_exists {
+ let error = ErrorResponse::new(
+ Some("partition".to_string()),
+ Some(table),
+ Some("Some partitions already exist".to_string()),
+ Some(StatusCode::CONFLICT.as_u16() as i32),
+ );
+ return (StatusCode::CONFLICT, Json(error)).into_response();
+ }
+
+ for spec in request.partition_specs {
+ if !registered_partitions
+ .iter()
+ .any(|partition| partition.spec == spec)
+ {
+ registered_partitions.push(partition_from_spec(spec));
+ }
+ }
+ // As the catalog does: a negative field was never measured and leaves
the stored value
+ // alone; a replacing report overwrites what is stored, any other is
added to it.
+ let replace = request.replace_statistics.unwrap_or(false);
+ for statistic in request.partition_statistics.unwrap_or_default() {
+ let Some(partition) = registered_partitions
+ .iter_mut()
+ .find(|partition| partition.spec == statistic.spec)
+ else {
+ continue;
+ };
+ for (stored, reported) in [
+ (&mut partition.record_count, statistic.record_count),
+ (
+ &mut partition.file_size_in_bytes,
+ statistic.file_size_in_bytes,
+ ),
+ (&mut partition.file_count, statistic.file_count),
+ ] {
+ if reported >= 0 {
+ *stored = if replace {
+ reported
+ } else {
+ (*stored).max(0) + reported
+ };
+ }
+ }
+ if statistic.last_file_creation_time >= 0 {
+ partition.last_file_creation_time = if replace {
+ statistic.last_file_creation_time
+ } else {
+ partition
+ .last_file_creation_time
+ .max(statistic.last_file_creation_time)
+ };
+ }
+ }
+ let response = json!({"success": true});
+ (StatusCode::OK, Json(response)).into_response()
+ }
+
+ /// Handle GET /databases/:db/tables/:table/partitions - list partitions.
+ pub async fn list_partitions(
+ Path((db, table)): Path<(String, String)>,
+ Query(params): Query<HashMap<String, String>>,
+ Extension(state): Extension<Arc<RESTServer>>,
+ ) -> impl IntoResponse {
+ let mut inner = state.inner.lock().unwrap();
+ let key = format!("{db}.{table}");
+ let name_pattern = params.get("partitionNamePattern").cloned();
+ inner
+ .partition_list_name_patterns
+ .entry(key.clone())
+ .or_default()
+ .push(name_pattern.clone());
+ if !inner.tables.contains_key(&key) {
+ let error = ErrorResponse::new(
+ Some("table".to_string()),
+ Some(table),
+ Some("Not Found".to_string()),
+ Some(404),
+ );
+ return (StatusCode::NOT_FOUND, Json(error)).into_response();
+ }
+ if inner.partition_page_responses.contains_key(&key) {
+ let request_index = {
+ let calls = inner
+ .partition_list_call_counts
+ .entry(key.clone())
+ .or_default();
+ let request_index = *calls;
+ *calls += 1;
+ request_index
+ };
+ let responses = &inner.partition_page_responses[&key];
+ let expected_token = request_index
+ .checked_sub(1)
+ .and_then(|index| responses.get(index))
+ .and_then(|(_, token)| token.clone());
+ let actual_token = params.get("pageToken").cloned();
+ if actual_token != expected_token || request_index >=
responses.len() {
+ let error = ErrorResponse::new(
+ Some("partition".to_string()),
+ Some(table),
+ Some(format!(
+ "Invalid page token: expected {expected_token:?}, got
{actual_token:?}"
+ )),
+ Some(StatusCode::BAD_REQUEST.as_u16() as i32),
+ );
+ return (StatusCode::BAD_REQUEST, Json(error)).into_response();
+ }
+ let (partitions, next_page_token) =
responses[request_index].clone();
+ return (
+ StatusCode::OK,
+ Json(ListPartitionsResponse::new(
+ Some(partitions),
+ next_page_token,
+ )),
+ )
+ .into_response();
+ }
+ let mut partitions =
inner.partitions.get(&key).cloned().unwrap_or_default();
+ if let Some(pattern) = name_pattern {
+ partitions
+ .retain(|partition|
partition_spec_matches_name_pattern(&partition.spec, &pattern));
+ }
+ (
+ StatusCode::OK,
+ Json(ListPartitionsResponse::new(Some(partitions), None)),
+ )
+ .into_response()
+ }
+
+ /// Handle POST /databases/:db/tables/:table/partitions/drop - drop
partitions.
+ pub async fn drop_partitions(
+ Path((db, table)): Path<(String, String)>,
+ Extension(state): Extension<Arc<RESTServer>>,
+ Json(request): Json<DropPartitionsRequest>,
+ ) -> impl IntoResponse {
+ let mut inner = state.inner.lock().unwrap();
+ inner
+ .drop_partitions_calls
+ .push((db.clone(), table.clone(), request.clone()));
+
+ let key = format!("{db}.{table}");
+ if !inner.tables.contains_key(&key) {
+ let error = ErrorResponse::new(
+ Some("table".to_string()),
+ Some(table),
+ Some("Not Found".to_string()),
+ Some(404),
+ );
+ return (StatusCode::NOT_FOUND, Json(error)).into_response();
+ }
+
+ let registered_partitions = inner.partitions.entry(key).or_default();
+ let has_missing = request.partition_specs.iter().any(|spec| {
+ !registered_partitions
+ .iter()
+ .any(|partition| partition.spec == *spec)
+ });
+ if has_missing && !request.ignore_if_not_exists {
+ let error = ErrorResponse::new(
+ Some("partition".to_string()),
+ Some(table),
+ Some("Some partitions do not exist".to_string()),
+ Some(StatusCode::NOT_FOUND.as_u16() as i32),
+ );
+ return (StatusCode::NOT_FOUND, Json(error)).into_response();
+ }
+
+ registered_partitions
+ .retain(|partition|
!request.partition_specs.contains(&partition.spec));
+ let response = json!({"success": true});
+ (StatusCode::OK, Json(response)).into_response()
+ }
+
+ /// Handle POST /databases/:db/tables/:table/partitions/list-by-names -
look up partitions.
+ pub async fn list_partitions_by_names(
+ Path((db, table)): Path<(String, String)>,
+ Extension(state): Extension<Arc<RESTServer>>,
+ Json(request): Json<ListPartitionsByNamesRequest>,
+ ) -> impl IntoResponse {
+ let mut inner = state.inner.lock().unwrap();
+ let key = format!("{db}.{table}");
+ inner
+ .partition_list_by_names_calls
+ .entry(key.clone())
+ .or_default()
+ .push(request.specs.clone());
+ if !inner.tables.contains_key(&key) {
+ let error = ErrorResponse::new(
+ Some("table".to_string()),
+ Some(table),
+ Some("Not Found".to_string()),
+ Some(404),
+ );
+ return (StatusCode::NOT_FOUND, Json(error)).into_response();
+ }
+ if let Some(status) = inner.list_partitions_by_names_error_status {
+ let error = ErrorResponse::new(
+ Some("partition".to_string()),
+ Some(table),
+ Some("Listing partitions by names is not
implemented".to_string()),
+ Some(status.as_u16() as i32),
+ );
+ return (status, Json(error)).into_response();
+ }
+ let partitions = inner
+ .partitions
+ .get(&key)
+ .map(|partitions| {
+ partitions
+ .iter()
+ .filter(|partition|
request.specs.contains(&partition.spec))
+ .cloned()
+ .collect()
+ })
+ .unwrap_or_default();
+ (
+ StatusCode::OK,
+ Json(ListPartitionsResponse::new(Some(partitions), None)),
+ )
+ .into_response()
+ }
+
+ /// Handle POST /databases/:db/tables/:table/partitions/list-by-filter -
list partitions.
+ ///
+ /// Like a catalog that does not evaluate predicates yet, this applies the
name pattern only
+ /// and returns every other registered partition, which the endpoint
contract allows.
+ pub async fn list_partitions_by_filter(
+ Path((db, table)): Path<(String, String)>,
+ Extension(state): Extension<Arc<RESTServer>>,
+ Json(request): Json<ListPartitionsByFilterRequest>,
+ ) -> impl IntoResponse {
+ let mut inner = state.inner.lock().unwrap();
+ let key = format!("{db}.{table}");
+ inner
+ .partition_list_by_filter_requests
+ .entry(key.clone())
+ .or_default()
+ .push(request.clone());
+ if !inner.tables.contains_key(&key) {
+ let error = ErrorResponse::new(
+ Some("table".to_string()),
+ Some(table),
+ Some("Not Found".to_string()),
+ Some(404),
+ );
+ return (StatusCode::NOT_FOUND, Json(error)).into_response();
+ }
+ if let Some(status) = inner.list_partitions_by_filter_error_status {
+ let error = ErrorResponse::new(
+ Some("partition".to_string()),
+ Some(table),
+ Some("Listing partitions by filter is not
implemented".to_string()),
+ Some(status.as_u16() as i32),
+ );
+ return (status, Json(error)).into_response();
+ }
+ let mut partitions =
inner.partitions.get(&key).cloned().unwrap_or_default();
+ if let Some(pattern) = &request.partition_name_pattern {
+ partitions
+ .retain(|partition|
partition_spec_matches_name_pattern(&partition.spec, pattern));
+ }
+ (
+ StatusCode::OK,
+ Json(ListPartitionsResponse::new(Some(partitions), None)),
+ )
+ .into_response()
+ }
+
/// Handle POST /rename-table - rename a table.
pub async fn rename_table(
Extension(state): Extension<Arc<RESTServer>>,
@@ -827,7 +1202,6 @@ impl RESTServer {
if s.no_permission_tables.remove(&source_key) {
s.no_permission_tables.insert(dest_key.clone());
}
-
(StatusCode::OK, Json(serde_json::json!(""))).into_response()
} else {
let err = ErrorResponse::new(
@@ -930,6 +1304,57 @@ impl RESTServer {
self.inner.lock().unwrap().drop_view_error_status = status;
}
+ /// Make the create-partitions endpoint return the given status.
+ pub fn set_create_partitions_error_status(&self, status:
Option<StatusCode>) {
+ self.inner.lock().unwrap().create_partitions_error_status = status;
+ }
+
+ /// Make the list-partitions-by-names endpoint return the given status.
+ pub fn set_list_partitions_by_names_error_status(&self, status:
Option<StatusCode>) {
+ self.inner
+ .lock()
+ .unwrap()
+ .list_partitions_by_names_error_status = status;
+ }
+
+ /// Make the list-partitions-by-filter endpoint return the given status.
+ pub fn set_list_partitions_by_filter_error_status(&self, status:
Option<StatusCode>) {
+ self.inner
+ .lock()
+ .unwrap()
+ .list_partitions_by_filter_error_status = status;
+ }
+
+ /// Return the specs of every list-by-names request the table received, in
order.
+ pub fn table_partition_list_by_names_calls(
+ &self,
+ database: &str,
+ table: &str,
+ ) -> Vec<Vec<HashMap<String, String>>> {
+ self.inner
+ .lock()
+ .unwrap()
+ .partition_list_by_names_calls
+ .get(&format!("{database}.{table}"))
+ .cloned()
+ .unwrap_or_default()
+ }
+
+ /// Return every list-by-filter request the table received, in order.
+ pub fn table_partition_list_by_filter_requests(
+ &self,
+ database: &str,
+ table: &str,
+ ) -> Vec<ListPartitionsByFilterRequest> {
+ self.inner
+ .lock()
+ .unwrap()
+ .partition_list_by_filter_requests
+ .get(&format!("{database}.{table}"))
+ .cloned()
+ .unwrap_or_default()
+ }
+
/// Add a table with schema and path to the server state.
///
/// This is needed for `RESTCatalog::get_table` which requires
@@ -972,6 +1397,89 @@ impl RESTServer {
let mut s = self.inner.lock().unwrap();
s.no_permission_tables.insert(format!("{database}.{table}"));
}
+
+ /// Set the catalog-registered partitions for a table.
+ pub fn set_table_partitions(
+ &self,
+ database: &str,
+ table: &str,
+ partition_specs: Vec<HashMap<String, String>>,
+ ) {
+ let partitions = partition_specs
+ .into_iter()
+ .map(partition_from_spec)
+ .collect();
+ let key = format!("{database}.{table}");
+ let mut inner = self.inner.lock().unwrap();
+ assert!(
+ inner.tables.contains_key(&key),
+ "table {key} does not exist"
+ );
+ inner.partitions.insert(key.clone(), partitions);
+ inner.partition_page_responses.remove(&key);
+ inner.partition_list_call_counts.remove(&key);
+ }
+
+ /// Set explicit list-partitions pages and response tokens in request
order.
+ pub fn set_table_partition_page_responses(
+ &self,
+ database: &str,
+ table: &str,
+ responses: Vec<PartitionSpecPageResponse>,
+ ) {
+ let responses = responses
+ .into_iter()
+ .map(|(specs, token)|
(specs.into_iter().map(partition_from_spec).collect(), token))
+ .collect();
+ let key = format!("{database}.{table}");
+ let mut inner = self.inner.lock().unwrap();
+ assert!(
+ inner.tables.contains_key(&key),
+ "table {key} does not exist"
+ );
+ inner
+ .partition_page_responses
+ .insert(key.clone(), responses);
+ inner.partition_list_call_counts.remove(&key);
+ }
+
+ /// Return the `partitionNamePattern` of every partition-list request the
table
+ /// received, in order, with `None` where the client sent no pattern.
+ pub fn table_partition_list_name_patterns(
+ &self,
+ database: &str,
+ table: &str,
+ ) -> Vec<Option<String>> {
+ self.inner
+ .lock()
+ .unwrap()
+ .partition_list_name_patterns
+ .get(&format!("{database}.{table}"))
+ .cloned()
+ .unwrap_or_default()
+ }
+
+ /// Return how many paged partition-list requests the table received.
+ pub fn table_partition_list_call_count(&self, database: &str, table: &str)
-> usize {
+ self.inner
+ .lock()
+ .unwrap()
+ .partition_list_call_counts
+ .get(&format!("{database}.{table}"))
+ .copied()
+ .unwrap_or_default()
+ }
+
+ /// Return all create-partitions calls received by the server.
+ pub fn create_partitions_calls(&self) -> Vec<(String, String,
CreatePartitionsRequest)> {
+ self.inner.lock().unwrap().create_partitions_calls.clone()
+ }
+
+ /// Return all drop-partitions calls received by the server.
+ pub fn drop_partitions_calls(&self) -> Vec<(String, String,
DropPartitionsRequest)> {
+ self.inner.lock().unwrap().drop_partitions_calls.clone()
+ }
+
/// Get the server URL.
pub fn url(&self) -> Option<String> {
self.addr.map(|a| format!("http://{a}"))
@@ -1089,6 +1597,22 @@ pub async fn start_mock_server(
.post(RESTServer::alter_table)
.delete(RESTServer::drop_table),
)
+ .route(
+ &format!("{prefix}/databases/:db/tables/:table/partitions"),
+
get(RESTServer::list_partitions).post(RESTServer::create_partitions),
+ )
+ .route(
+ &format!("{prefix}/databases/:db/tables/:table/partitions/drop"),
+ post(RESTServer::drop_partitions),
+ )
+ .route(
+
&format!("{prefix}/databases/:db/tables/:table/partitions/list-by-names"),
+ post(RESTServer::list_partitions_by_names),
+ )
+ .route(
+
&format!("{prefix}/databases/:db/tables/:table/partitions/list-by-filter"),
+ post(RESTServer::list_partitions_by_filter),
+ )
.route(
&format!("{prefix}/databases/:db/views"),
get(RESTServer::list_views).post(RESTServer::create_view),
diff --git a/crates/paimon/tests/rest_api_test.rs
b/crates/paimon/tests/rest_api_test.rs
index c7dff44b..086c3301 100644
--- a/crates/paimon/tests/rest_api_test.rs
+++ b/crates/paimon/tests/rest_api_test.rs
@@ -24,7 +24,7 @@ use std::collections::HashMap;
use paimon::api::auth::{DLFECSTokenLoader, DLFToken, DLFTokenLoader};
use paimon::api::rest_api::RESTApi;
-use paimon::api::ConfigResponse;
+use paimon::api::{ConfigResponse, CreatePartitionsRequest,
DropPartitionsRequest};
use paimon::catalog::{Function, FunctionDefinition, Identifier, ViewSchema};
use paimon::common::Options;
use paimon::spec::DataField;
@@ -71,6 +71,13 @@ async fn setup_test_server(initial_dbs: Vec<&str>) ->
TestContext {
TestContext { server, api, url }
}
+async fn setup_partition_api() -> (TestContext, Identifier) {
+ let ctx = setup_test_server(vec!["default"]).await;
+ let identifier = Identifier::new("default", "managed_table");
+ ctx.server.add_table("default", "managed_table");
+ (ctx, identifier)
+}
+
// ==================== Database Tests ====================
#[tokio::test]
async fn test_list_databases() {
@@ -594,6 +601,273 @@ async fn test_drop_table_no_permission() {
assert!(result.is_err(), "dropping no-permission table should fail");
}
+// ==================== Partition Tests ====================
+
+#[tokio::test]
+async fn test_partition_mutations_post_expected_requests() {
+ let (ctx, identifier) = setup_partition_api().await;
+ let partition_specs = vec![HashMap::from([
+ ("dt".to_string(), "2026-07-22".to_string()),
+ ("hour".to_string(), "10".to_string()),
+ ])];
+
+ ctx.api
+ .create_partitions(&identifier, partition_specs.clone(), false)
+ .await
+ .unwrap();
+
+ assert_eq!(
+ ctx.server.create_partitions_calls(),
+ vec![(
+ "default".to_string(),
+ "managed_table".to_string(),
+ CreatePartitionsRequest::new(partition_specs.clone(), false),
+ )]
+ );
+
+ ctx.api
+ .drop_partitions(&identifier, partition_specs.clone(), false)
+ .await
+ .unwrap();
+
+ assert_eq!(
+ ctx.server.drop_partitions_calls(),
+ vec![(
+ "default".to_string(),
+ "managed_table".to_string(),
+ DropPartitionsRequest::new(partition_specs, false),
+ )]
+ );
+}
+
+#[tokio::test]
+async fn test_list_partitions_by_names_returns_the_registered_ones() {
+ let (ctx, identifier) = setup_partition_api().await;
+ let registered = HashMap::from([("dt".to_string(),
"2026-07-22".to_string())]);
+ let missing = HashMap::from([("dt".to_string(),
"2026-07-23".to_string())]);
+ ctx.server
+ .set_table_partitions("default", "managed_table",
vec![registered.clone()]);
+
+ let partitions = ctx
+ .api
+ .list_partitions_by_names(&identifier, vec![registered.clone(),
missing.clone()])
+ .await
+ .unwrap();
+
+ assert_eq!(
+ partitions
+ .into_iter()
+ .map(|partition| partition.spec)
+ .collect::<Vec<_>>(),
+ vec![registered.clone()]
+ );
+ assert_eq!(
+ ctx.server
+ .table_partition_list_by_names_calls("default", "managed_table"),
+ vec![vec![registered, missing]]
+ );
+}
+
+#[tokio::test]
+async fn test_list_partitions_by_filter_sends_filter_pattern_and_page_size() {
+ let (ctx, identifier) = setup_partition_api().await;
+ let wanted = HashMap::from([("dt".to_string(), "2026-07-22".to_string())]);
+ ctx.server.set_table_partitions(
+ "default",
+ "managed_table",
+ vec![
+ wanted.clone(),
+ HashMap::from([("dt".to_string(), "2026-07-23".to_string())]),
+ ],
+ );
+ let filter =
r#"{"kind":"LEAF","transform":{"name":"FIELD_REF","fieldRef":{"index":0,"name":"dt","type":"STRING"}},"function":"EQUAL","literals":["2026-07-22"]}"#;
+
+ let partitions = ctx
+ .api
+ .list_partitions_by_filter(&identifier, filter, Some("dt=2026-07-22"))
+ .await
+ .unwrap();
+
+ assert_eq!(
+ partitions
+ .into_iter()
+ .map(|partition| partition.spec)
+ .collect::<Vec<_>>(),
+ vec![wanted]
+ );
+ let requests = ctx
+ .server
+ .table_partition_list_by_filter_requests("default", "managed_table");
+ assert_eq!(requests.len(), 1);
+ assert_eq!(requests[0].filter, filter);
+ assert_eq!(
+ requests[0].partition_name_pattern.as_deref(),
+ Some("dt=2026-07-22")
+ );
+ assert_eq!(requests[0].max_results, Some(1000));
+ assert_eq!(requests[0].page_token, None);
+}
+
+#[tokio::test]
+async fn test_list_partitions_by_filter_surfaces_not_implemented() {
+ let (ctx, identifier) = setup_partition_api().await;
+ ctx.server
+
.set_list_partitions_by_filter_error_status(Some(axum::http::StatusCode::NOT_IMPLEMENTED));
+
+ let error = ctx
+ .api
+ .list_partitions_by_filter(&identifier, "{}", None)
+ .await
+ .unwrap_err();
+
+ assert!(
+ matches!(
+ error,
+ paimon::Error::RestApi {
+ source: paimon::api::RestError::NotImplemented { .. }
+ }
+ ),
+ "{error}"
+ );
+}
+
+#[tokio::test]
+async fn test_list_partitions_by_name_pattern_sends_the_pattern() {
+ let (ctx, identifier) = setup_partition_api().await;
+ let wanted = HashMap::from([
+ ("dt".to_string(), "2026-07-22".to_string()),
+ ("hour".to_string(), "10".to_string()),
+ ]);
+ ctx.server.set_table_partitions(
+ "default",
+ "managed_table",
+ vec![
+ wanted.clone(),
+ HashMap::from([
+ ("dt".to_string(), "2026-07-23".to_string()),
+ ("hour".to_string(), "10".to_string()),
+ ]),
+ ],
+ );
+
+ let partitions = ctx
+ .api
+ .list_partitions_by_name_pattern(&identifier, Some("dt=2026-07-22/%"))
+ .await
+ .unwrap();
+ ctx.api.list_partitions(&identifier).await.unwrap();
+
+ assert_eq!(
+ partitions
+ .into_iter()
+ .map(|partition| partition.spec)
+ .collect::<Vec<_>>(),
+ vec![wanted]
+ );
+ assert_eq!(
+ ctx.server
+ .table_partition_list_name_patterns("default", "managed_table"),
+ vec![Some("dt=2026-07-22/%".to_string()), None]
+ );
+}
+
+#[tokio::test]
+async fn test_list_partitions_follows_non_empty_token_after_empty_page() {
+ let (ctx, identifier) = setup_partition_api().await;
+ let expected = HashMap::from([("dt".to_string(),
"2026-07-22".to_string())]);
+ ctx.server.set_table_partition_page_responses(
+ "default",
+ "managed_table",
+ vec![
+ (Vec::new(), Some("1".to_string())),
+ (vec![expected.clone()], None),
+ ],
+ );
+
+ let partitions = ctx.api.list_partitions(&identifier).await.unwrap();
+
+ assert_eq!(partitions.len(), 1);
+ assert_eq!(partitions[0].spec, expected);
+}
+
+#[tokio::test]
+async fn test_list_partitions_stops_on_empty_next_page_token() {
+ let (ctx, identifier) = setup_partition_api().await;
+ let expected = HashMap::from([("dt".to_string(),
"2026-07-22".to_string())]);
+ let unexpected = HashMap::from([("dt".to_string(),
"2026-07-23".to_string())]);
+ ctx.server.set_table_partition_page_responses(
+ "default",
+ "managed_table",
+ vec![
+ (vec![expected.clone()], Some(String::new())),
+ (vec![unexpected], None),
+ ],
+ );
+
+ let partitions = ctx.api.list_partitions(&identifier).await.unwrap();
+
+ assert_eq!(
+ partitions
+ .into_iter()
+ .map(|partition| partition.spec)
+ .collect::<Vec<_>>(),
+ vec![expected]
+ );
+ assert_eq!(
+ ctx.server
+ .table_partition_list_call_count("default", "managed_table"),
+ 1
+ );
+}
+
+#[tokio::test]
+async fn test_list_partitions_rejects_repeated_page_token() {
+ let (ctx, identifier) = setup_partition_api().await;
+ ctx.server.set_table_partition_page_responses(
+ "default",
+ "managed_table",
+ vec![
+ (
+ vec![HashMap::from([(
+ "dt".to_string(),
+ "2026-07-20".to_string(),
+ )])],
+ Some("a".to_string()),
+ ),
+ (
+ vec![HashMap::from([(
+ "dt".to_string(),
+ "2026-07-21".to_string(),
+ )])],
+ Some("b".to_string()),
+ ),
+ (
+ vec![HashMap::from([(
+ "dt".to_string(),
+ "2026-07-22".to_string(),
+ )])],
+ Some("a".to_string()),
+ ),
+ ],
+ );
+
+ let error = ctx.api.list_partitions(&identifier).await.unwrap_err();
+
+ let paimon::Error::UnexpectedError { message, .. } = error else {
+ panic!("expected a repeated-page-token error, got: {error}");
+ };
+ assert_eq!(
+ message,
+ "REST catalog returned partition page token 'a' more than once for
table \
+ default.managed_table"
+ );
+ assert_eq!(
+ ctx.server
+ .table_partition_list_call_count("default", "managed_table"),
+ 3
+ );
+}
+
// ==================== Rename Table Tests ====================
#[tokio::test]
diff --git a/crates/paimon/tests/rest_catalog_test.rs
b/crates/paimon/tests/rest_catalog_test.rs
index 41052c45..972fab05 100644
--- a/crates/paimon/tests/rest_catalog_test.rs
+++ b/crates/paimon/tests/rest_catalog_test.rs
@@ -31,8 +31,8 @@ use paimon::api::ConfigResponse;
use paimon::catalog::{Catalog, Function, FunctionDefinition, Identifier,
RESTCatalog, ViewSchema};
use paimon::common::Options;
use paimon::spec::{
- BigIntType, BlobType, BlobViewStruct, DataField, DataType, Datum, IntType,
PredicateBuilder,
- Schema, SchemaChange, VarCharType,
+ BigIntType, BlobType, BlobViewStruct, DataField, DataType, Datum, IntType,
PartitionStatistics,
+ PredicateBuilder, Schema, SchemaChange, VarCharType,
};
use paimon::{CatalogOptions, FileSystemCatalog, Table};
@@ -167,6 +167,323 @@ fn collect_blob_rows(batches: &[RecordBatch]) ->
Vec<(i32, String, Option<Vec<u8
rows
}
+// ==================== Partition Tests ====================
+
+#[tokio::test]
+async fn
test_rest_catalog_skips_empty_and_batches_idempotent_create_partitions() {
+ let ctx = setup_catalog(vec!["default"]).await;
+ let identifier = Identifier::new("default", "managed_table");
+ ctx.server.add_table("default", "managed_table");
+ let partition_specs = (0..1001)
+ .map(|value| HashMap::from([("dt".to_string(), value.to_string())]))
+ .collect::<Vec<_>>();
+
+ ctx.catalog
+ .create_partitions(&identifier, Vec::new(), false)
+ .await
+ .unwrap();
+ ctx.catalog
+ .create_partitions(&identifier, partition_specs.clone(), true)
+ .await
+ .unwrap();
+
+ let calls = ctx.server.create_partitions_calls();
+ assert_eq!(calls.len(), 2);
+ assert_eq!(calls[0].2.partition_specs, partition_specs[..1000]);
+ assert_eq!(calls[1].2.partition_specs, partition_specs[1000..]);
+ assert!(calls.iter().all(|(_, _, request)| request.ignore_if_exists));
+}
+
+#[tokio::test]
+async fn test_rest_catalog_keeps_non_idempotent_create_in_one_request() {
+ let ctx = setup_catalog(vec!["default"]).await;
+ let identifier = Identifier::new("default", "managed_table");
+ ctx.server.add_table("default", "managed_table");
+ let partition_specs = (0..2500)
+ .map(|value| HashMap::from([("dt".to_string(), value.to_string())]))
+ .collect::<Vec<_>>();
+
+ ctx.catalog
+ .create_partitions(&identifier, partition_specs.clone(), false)
+ .await
+ .unwrap();
+
+ let calls = ctx.server.create_partitions_calls();
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0].2.partition_specs, partition_specs);
+ assert!(!calls[0].2.ignore_if_exists);
+}
+
+#[tokio::test]
+async fn test_rest_catalog_sends_partition_statistics_with_their_batch() {
+ let ctx = setup_catalog(vec!["default"]).await;
+ let identifier = Identifier::new("default", "managed_table");
+ ctx.server.add_table("default", "managed_table");
+ let specs = (0..1001)
+ .map(|value| HashMap::from([("dt".to_string(), value.to_string())]))
+ .collect::<Vec<_>>();
+ let statistic = |spec: &HashMap<String, String>| PartitionStatistics {
+ spec: spec.clone(),
+ record_count: 1,
+ file_size_in_bytes: 2,
+ file_count: 3,
+ last_file_creation_time: 4,
+ total_buckets: -1,
+ };
+
+ // Reported for the last partition and the first, in that order.
+ ctx.catalog
+ .create_partitions_with_statistics(
+ &identifier,
+ specs.clone(),
+ true,
+ Some(vec![statistic(&specs[1000]), statistic(&specs[0])]),
+ true,
+ )
+ .await
+ .unwrap();
+
+ let calls = ctx.server.create_partitions_calls();
+ assert_eq!(calls.len(), 2);
+ assert_eq!(
+ calls[0].2.partition_statistics,
+ Some(vec![statistic(&specs[0])])
+ );
+ assert_eq!(
+ calls[1].2.partition_statistics,
+ Some(vec![statistic(&specs[1000])])
+ );
+ assert!(calls
+ .iter()
+ .all(|(_, _, request)| request.replace_statistics == Some(true)));
+
+ // A report the catalog could not place is refused before anything is sent.
+ let unknown = HashMap::from([("dt".to_string(), "x".to_string())]);
+ for statistics in [
+ vec![statistic(&unknown)],
+ vec![statistic(&specs[0]), statistic(&specs[0])],
+ ] {
+ let error = ctx
+ .catalog
+ .create_partitions_with_statistics(
+ &identifier,
+ specs.clone(),
+ true,
+ Some(statistics),
+ true,
+ )
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(error, paimon::Error::DataInvalid { .. }),
+ "{error}"
+ );
+ }
+ assert_eq!(ctx.server.create_partitions_calls().len(), 2);
+}
+
+#[tokio::test]
+async fn
test_rest_catalog_refuses_a_partition_registered_twice_with_statistics() {
+ let ctx = setup_catalog(vec!["default"]).await;
+ let identifier = Identifier::new("default", "managed_table");
+ ctx.server.add_table("default", "managed_table");
+ // The first and the last spec name the same partition, so they land in
different batches.
+ let mut specs = (0..1000)
+ .map(|value| HashMap::from([("dt".to_string(), value.to_string())]))
+ .collect::<Vec<_>>();
+ specs.push(specs[0].clone());
+ let additive = PartitionStatistics {
+ spec: specs[0].clone(),
+ record_count: 7,
+ file_size_in_bytes: 70,
+ file_count: 1,
+ last_file_creation_time: 4,
+ total_buckets: -1,
+ };
+
+ // Both batches would carry the one report, and an additive catalog would
apply it twice.
+ let error = ctx
+ .catalog
+ .create_partitions_with_statistics(
+ &identifier,
+ specs.clone(),
+ true,
+ Some(vec![additive]),
+ false,
+ )
+ .await
+ .unwrap_err();
+ assert!(
+ matches!(&error, paimon::Error::DataInvalid { message, .. } if
message.contains("registered twice")),
+ "{error}"
+ );
+ assert!(ctx.server.create_partitions_calls().is_empty());
+
+ // Without statistics a repeated spec carries nothing that could be
counted twice.
+ ctx.catalog
+ .create_partitions(&identifier, specs, true)
+ .await
+ .unwrap();
+ assert_eq!(ctx.server.create_partitions_calls().len(), 2);
+}
+
+#[tokio::test]
+async fn test_rest_catalog_maps_create_partition_conflict() {
+ let ctx = setup_catalog(vec!["default"]).await;
+ let identifier = Identifier::new("default", "managed_table");
+ ctx.server
+ .set_create_partitions_error_status(Some(StatusCode::CONFLICT));
+
+ let error = ctx
+ .catalog
+ .create_partitions(
+ &identifier,
+ vec![HashMap::from([(
+ "dt".to_string(),
+ "2026-07-22".to_string(),
+ )])],
+ false,
+ )
+ .await
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ paimon::Error::DataInvalid { message, .. }
+ if message.contains("default.managed_table")
+ && message.contains("already exist")
+ ));
+}
+
+#[tokio::test]
+async fn test_rest_catalog_maps_invalid_partition_request() {
+ let ctx = setup_catalog(vec!["default"]).await;
+ let identifier = Identifier::new("default", "managed_table");
+ ctx.server
+ .set_create_partitions_error_status(Some(StatusCode::BAD_REQUEST));
+
+ let error = ctx
+ .catalog
+ .create_partitions(
+ &identifier,
+ vec![HashMap::from([(
+ "unknown".to_string(),
+ "value".to_string(),
+ )])],
+ false,
+ )
+ .await
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ paimon::Error::DataInvalid { message, .. }
+ if message.contains("default.managed_table")
+ ));
+}
+
+#[tokio::test]
+async fn test_rest_catalog_create_partitions_maps_missing_table() {
+ let ctx = setup_catalog(vec!["default"]).await;
+ let identifier = Identifier::new("default", "missing");
+
+ let error = ctx
+ .catalog
+ .create_partitions(
+ &identifier,
+ vec![HashMap::from([(
+ "dt".to_string(),
+ "2026-07-22".to_string(),
+ )])],
+ false,
+ )
+ .await
+ .unwrap_err();
+
+ assert!(matches!(
+ error,
+ paimon::Error::TableNotExist { full_name } if full_name ==
"default.missing"
+ ));
+}
+
+#[tokio::test]
+async fn test_rest_catalog_looks_up_partitions_by_names_in_batches() {
+ let ctx = setup_catalog(vec!["default"]).await;
+ let identifier = Identifier::new("default", "managed_table");
+ ctx.server.add_table("default", "managed_table");
+ let spec = |value: usize| HashMap::from([("dt".to_string(),
value.to_string())]);
+ let registered = (0..2500).step_by(2).map(spec).collect::<Vec<_>>();
+ ctx.server
+ .set_table_partitions("default", "managed_table", registered.clone());
+ let requested = (0..2500).map(spec).collect::<Vec<_>>();
+
+ assert!(ctx
+ .catalog
+ .list_partitions_by_names(&identifier, Vec::new())
+ .await
+ .unwrap()
+ .is_empty());
+ let found = ctx
+ .catalog
+ .list_partitions_by_names(&identifier, requested.clone())
+ .await
+ .unwrap();
+
+ assert_eq!(
+ found
+ .into_iter()
+ .map(|partition| partition.spec)
+ .collect::<Vec<_>>(),
+ registered
+ );
+ let calls = ctx
+ .server
+ .table_partition_list_by_names_calls("default", "managed_table");
+ assert_eq!(
+ calls.iter().map(Vec::len).collect::<Vec<_>>(),
+ vec![1000, 1000, 500]
+ );
+ assert_eq!(calls.concat(), requested);
+}
+
+#[tokio::test]
+async fn
test_rest_catalog_looks_up_partitions_by_names_through_the_listing_when_unsupported()
{
+ let ctx = setup_catalog(vec!["default"]).await;
+ let identifier = Identifier::new("default", "managed_table");
+ ctx.server.add_table("default", "managed_table");
+ let registered = HashMap::from([("dt".to_string(),
"2026-07-22".to_string())]);
+ let missing = HashMap::from([("dt".to_string(),
"2026-07-23".to_string())]);
+ ctx.server
+ .set_table_partitions("default", "managed_table",
vec![registered.clone()]);
+ ctx.server
+
.set_list_partitions_by_names_error_status(Some(StatusCode::NOT_IMPLEMENTED));
+
+ let found = ctx
+ .catalog
+ .list_partitions_by_names(&identifier, vec![registered.clone(),
missing])
+ .await
+ .unwrap();
+
+ assert_eq!(
+ found
+ .into_iter()
+ .map(|partition| partition.spec)
+ .collect::<Vec<_>>(),
+ vec![registered]
+ );
+ assert_eq!(
+ ctx.server
+ .table_partition_list_by_names_calls("default", "managed_table")
+ .len(),
+ 1
+ );
+ assert_eq!(
+ ctx.server
+ .table_partition_list_name_patterns("default", "managed_table"),
+ vec![None]
+ );
+}
+
// ==================== Database Tests ====================
#[tokio::test]