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 a17688d5 feat(index): support partition-scoped global index drops
(#1106)
a17688d5 is described below
commit a17688d5e85747f9a9323bd2f83f625a3a982da7
Author: QuakeWang <[email protected]>
AuthorDate: Thu Oct 15 14:17:47 2026 +0800
feat(index): support partition-scoped global index drops (#1106)
---
Cargo.lock | 1 +
crates/integrations/datafusion/Cargo.toml | 1 +
crates/integrations/datafusion/src/procedures.rs | 330 +++++++-
crates/integrations/datafusion/tests/procedures.rs | 941 ++++++++++++++++++++-
crates/paimon/src/spec/mod.rs | 2 +-
crates/paimon/src/spec/partition_utils.rs | 11 +-
.../paimon/src/table/global_index_drop_builder.rs | 441 +++++++++-
7 files changed, 1714 insertions(+), 13 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
index 22ea40a7..e1b2da67 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -4758,6 +4758,7 @@ dependencies = [
"arrow-select",
"async-trait",
"axum",
+ "bigdecimal",
"bytes",
"chrono",
"constant_time_eq",
diff --git a/crates/integrations/datafusion/Cargo.toml
b/crates/integrations/datafusion/Cargo.toml
index a2f78ae2..dfca7278 100644
--- a/crates/integrations/datafusion/Cargo.toml
+++ b/crates/integrations/datafusion/Cargo.toml
@@ -35,6 +35,7 @@ vortex = ["paimon/vortex"]
[dependencies]
arrow-select = { workspace = true }
async-trait = "0.1"
+bigdecimal = "0.4"
chrono = "0.4"
constant_time_eq = { workspace = true }
datafusion = { workspace = true }
diff --git a/crates/integrations/datafusion/src/procedures.rs
b/crates/integrations/datafusion/src/procedures.rs
index b908da7d..ce150229 100644
--- a/crates/integrations/datafusion/src/procedures.rs
+++ b/crates/integrations/datafusion/src/procedures.rs
@@ -41,6 +41,11 @@
//!
//! The `index_type` argument of the three global index procedures is
//! case-insensitive and surrounding whitespace is ignored.
+//! `drop_global_index` accepts partition alternatives such as
+//! `partitions => 'dt=2026-10-10,hr=1;dt=2026-10-11,hr=2'`; omitted keys are
unrestricted.
+//! Explicit empty or invalid partition specifications fail before deleting
any index.
+//! Timezone-less LTZ partition values use local time for non-legacy names and
UTC for
+//! legacy names. Ambiguous or nonexistent local times require an explicit
offset.
//!
//! `column_names` and `excluded_column_names` are comma-separated lists, not
SQL arrays:
//! `column_names => 'id, region'`. Java's Spark procedures take
`ARRAY<STRING>` there, but a
@@ -53,6 +58,7 @@ use std::sync::Arc;
use datafusion::arrow::array::{ArrayRef, StringArray};
use datafusion::arrow::datatypes::{DataType as ArrowDataType, Field, Schema};
use datafusion::arrow::record_batch::RecordBatch;
+use datafusion::common::ScalarValue;
use datafusion::error::{DataFusionError, Result as DFResult};
use datafusion::prelude::{DataFrame, SessionContext};
use datafusion::sql::sqlparser::ast::{
@@ -65,7 +71,7 @@ use paimon::api::{
};
use paimon::catalog::{Catalog, Identifier, RESTCatalog};
use paimon::lumina::{is_lumina_index_type, LUMINA_IDENTIFIER};
-use paimon::spec::Snapshot;
+use paimon::spec::{CoreOptions, DataType, Datum, Predicate, PredicateBuilder,
Snapshot};
use paimon::table::{
normalize_global_index_type, BranchManager, SnapshotManager, Table,
TagManager,
SUPPORTED_GLOBAL_INDEX_TYPES_FOR_DROP,
@@ -173,8 +179,6 @@ fn declared_parameters(proc_name: &str) -> Option<&'static
[&'static str]> {
"create_tag_from_timestamp" => &["table", "tag", "timestamp"],
"rename_branch" => &["table", "from_branch", "to_branch"],
"create_global_index" => &["table", "index_column", "index_type",
"options"],
- // `partitions` is declared but not yet implemented; it still reaches
its
- // own "not supported yet" error rather than being reported as unknown.
"drop_global_index" => &[
"table",
"index_column",
@@ -776,17 +780,17 @@ async fn proc_drop_global_index(
"unsupported global index type '{index_type_arg}'; supported:
{SUPPORTED_GLOBAL_INDEX_TYPES_FOR_DROP}"
)));
}
- if args.contains_key("partitions") {
- return Err(DataFusionError::NotImplemented(
- "drop_global_index partitions are not supported yet".to_string(),
- ));
- }
let dry_run = bool_arg(args, "dry_run")?;
let mut builder = table.new_global_index_drop_builder();
builder.with_index_column(index_column);
builder.with_index_type(index_type);
builder.with_dry_run(dry_run);
+ if let Some(partitions) = args.get("partitions") {
+ builder
+ .with_partition_filter(parse_global_index_partitions(&table,
partitions)?)
+ .map_err(to_datafusion_error)?;
+ }
let dropped = builder.execute().await.map_err(to_datafusion_error)?;
if dry_run {
return utf8_result(
@@ -800,6 +804,193 @@ async fn proc_drop_global_index(
ok_result(ctx)
}
+/// Semicolon-separated alternatives, each containing comma-separated
key=value constraints.
+fn parse_global_index_partitions(table: &Table, partitions: &str) ->
DFResult<Predicate> {
+ let partition_fields = table.schema().partition_fields();
+ if partition_fields.is_empty() {
+ return Err(DataFusionError::Plan(
+ "Cannot filter global index partitions on an unpartitioned
table".to_string(),
+ ));
+ }
+ let pb = PredicateBuilder::new(table.schema().fields());
+ let options = CoreOptions::new(table.schema().options());
+ let mut alternatives = Vec::new();
+ for partition in partitions.split(';') {
+ let mut constraints = Vec::new();
+ let mut keys = std::collections::HashSet::new();
+ for entry in partition.split(',') {
+ let (key, value) = entry.split_once('=').ok_or_else(|| {
+ DataFusionError::Plan(format!(
+ "Invalid partitions entry '{entry}'. Expected key=value"
+ ))
+ })?;
+ // Java ParameterUtils uses String.trim(), not Unicode whitespace
trimming.
+ let key = key.trim_matches(|ch| ch <= ' ');
+ let value = value.trim_matches(|ch| ch <= ' ');
+ let field = partition_fields
+ .iter()
+ .find(|field| field.name() == key)
+ .ok_or_else(|| {
+ DataFusionError::Plan(format!("Column '{key}' is not a
partition column"))
+ })?;
+ if !keys.insert(key) {
+ return Err(DataFusionError::Plan(format!(
+ "Duplicate partition column '{key}'"
+ )));
+ }
+ if value.is_empty() {
+ return Err(DataFusionError::Plan(format!(
+ "Empty partitions value for column '{key}'"
+ )));
+ }
+ let predicate = if value == options.partition_default_name() {
+ pb.is_null(key)
+ } else {
+ let datum = parse_global_index_partition_value(
+ value,
+ field.data_type(),
+ options.legacy_partition_name(),
+ )
+ .ok_or_else(|| {
+ DataFusionError::Plan(format!(
+ "Invalid partitions value '{value}' for column '{key}'
with type {:?}",
+ field.data_type()
+ ))
+ })?;
+ pb.equal(key, datum)
+ };
+ constraints.push(predicate.map_err(to_datafusion_error)?);
+ }
+ alternatives.push(Predicate::and(constraints));
+ }
+ Ok(Predicate::or(alternatives))
+}
+
+fn parse_global_index_partition_value(
+ value: &str,
+ data_type: &DataType,
+ legacy_partition_name: bool,
+) -> Option<Datum> {
+ match data_type {
+ DataType::Char(typ) => {
+ (value.chars().count() <= typ.length()).then(||
Datum::String(value.to_string()))
+ }
+ DataType::VarChar(typ) => (value.chars().count() <= typ.length() as
usize)
+ .then(|| Datum::String(value.to_string())),
+ DataType::Binary(_) => Some(Datum::Bytes(value.as_bytes().to_vec())),
+ DataType::VarBinary(typ) => {
+ (value.len() <= typ.length() as usize).then(||
Datum::Bytes(value.as_bytes().to_vec()))
+ }
+ DataType::Float(_) => {
+ // Match TypeUtils.castFromString's check for FLOAT precision loss.
+ let double = value.parse::<f64>().ok()?;
+ let float = value.parse::<f32>().ok()?;
+ (f64::from(float) == double
+ || (float.is_nan() && double.is_nan())
+ || paimon::spec::format_java_float(float).parse::<f64>().ok()?
== double)
+ .then_some(Datum::Float(float))
+ }
+ DataType::Double(_) => value.parse().ok().map(Datum::Double),
+ DataType::Decimal(typ) => {
+ // Java Decimal.fromBigDecimal rounds HALF_UP; Arrow's string cast
truncates.
+ if value.contains('_') {
+ return None;
+ }
+ let decimal = value
+ .parse::<bigdecimal::BigDecimal>()
+ .ok()?
+ .with_scale_round(i64::from(typ.scale()),
bigdecimal::RoundingMode::HalfUp);
+ let unscaled = decimal
+ .as_bigint_and_exponent()
+ .0
+ .to_string()
+ .parse::<i128>()
+ .ok()?;
+ if unscaled
+ .unsigned_abs()
+ .checked_ilog10()
+ .map_or(1, |log| log + 1)
+ > typ.precision()
+ {
+ return None;
+ }
+ Some(Datum::Decimal {
+ unscaled,
+ precision: typ.precision(),
+ scale: typ.scale(),
+ })
+ }
+ DataType::Time(_) => {
+ paimon::table::parse_format_partition_value(value,
data_type).or_else(|| {
+ let arrow_type =
paimon::arrow::paimon_type_to_arrow(data_type).ok()?;
+ let scalar = ScalarValue::try_from_string(value.to_string(),
&arrow_type).ok()?;
+ crate::filter_pushdown::scalar_to_datum(&scalar, data_type)
+ })
+ }
+ DataType::Timestamp(typ) => {
+ parse_global_index_partition_timestamp(value, data_type,
typ.precision(), false)
+ }
+ DataType::LocalZonedTimestamp(typ) =>
parse_global_index_partition_timestamp(
+ value,
+ data_type,
+ typ.precision(),
+ !legacy_partition_name,
+ ),
+ _ => paimon::table::parse_format_partition_value(value, data_type),
+ }
+}
+
+fn parse_global_index_partition_timestamp(
+ value: &str,
+ data_type: &DataType,
+ precision: u32,
+ local_timezone: bool,
+) -> Option<Datum> {
+ if value.bytes().all(|byte| byte.is_ascii_digit()) {
+ // Java only accepts numeric timestamps at these precisions.
+ if !matches!(precision, 0 | 3 | 6 | 9) {
+ return None;
+ }
+ let arrow_type = paimon::arrow::paimon_type_to_arrow(data_type).ok()?;
+ let scalar = ScalarValue::Int64(Some(value.parse().ok()?))
+ .cast_to(&arrow_type)
+ .ok()?;
+ return crate::filter_pushdown::scalar_to_datum(&scalar, data_type);
+ }
+ // Arrow accepts extra fractional digits by truncating them; reject any
loss of identity.
+ if value.split_once('.').is_some_and(|(_, fraction)| {
+ fraction
+ .bytes()
+ .take_while(u8::is_ascii_digit)
+ .skip(9)
+ .any(|byte| byte != b'0')
+ }) {
+ return None;
+ }
+ // Legacy partition names omit seconds when both seconds and fractions are
zero.
+ let value = chrono::NaiveDateTime::parse_from_str(value, "%Y-%m-%dT%H:%M")
+ .map(|value| value.format("%Y-%m-%dT%H:%M:%S").to_string())
+ .unwrap_or_else(|_| value.to_string());
+ let timestamp = if local_timezone {
+ // Non-legacy LTZ names use the system-local timezone. Preserve
explicit offsets
+ // and fail on ambiguous/nonexistent local times instead of falling
back to UTC.
+
datafusion::arrow::compute::kernels::cast_utils::string_to_datetime(&chrono::Local,
&value)
+ .ok()?
+ .with_timezone(&chrono::Utc)
+ } else {
+
datafusion::arrow::compute::kernels::cast_utils::string_to_datetime(&chrono::Utc,
&value)
+ .ok()?
+ };
+ // Stored partitions can retain fractions beyond the declared precision or
Arrow unit.
+ let millis = timestamp.timestamp_millis();
+ let nanos = (timestamp.timestamp_subsec_nanos() % 1_000_000) as i32;
+ match data_type {
+ DataType::Timestamp(_) => Some(Datum::Timestamp { millis, nanos }),
+ DataType::LocalZonedTimestamp(_) => Some(Datum::LocalZonedTimestamp {
millis, nanos }),
+ _ => None,
+ }
+}
+
/// Canonicalize a procedure's `index_type` argument: trim, then lowercase.
/// Mirrors `indexType.toLowerCase(Locale.ROOT).trim()` in Java's Flink and
Spark
/// `CreateGlobalIndexProcedure` / `DropGlobalIndexProcedure`. Normalizing at
this
@@ -1272,6 +1463,129 @@ mod tests {
use paimon::io::FileIOBuilder;
use paimon::spec::CommitKind;
+ #[test]
+ fn test_global_index_partition_values_are_typed_and_strict() {
+ use paimon::spec::{
+ CharType, DateType, DecimalType, FloatType, IntType, TimeType,
TimestampType,
+ VarCharType,
+ };
+ assert_eq!(
+ parse_global_index_partition_value("01",
&DataType::Int(IntType::new()), true),
+ Some(Datum::Int(1))
+ );
+ for text in ["1.5", "2147483648", "bad"] {
+ assert!(
+ parse_global_index_partition_value(text,
&DataType::Int(IntType::new()), true)
+ .is_none()
+ );
+ }
+ assert!(parse_global_index_partition_value(
+ "2026-02-30",
+ &DataType::Date(DateType::new()),
+ true
+ )
+ .is_none());
+ assert_eq!(
+ parse_global_index_partition_value(
+ "1970-01-02",
+ &DataType::Date(DateType::new()),
+ true
+ ),
+ Some(Datum::Date(1))
+ );
+ assert_eq!(
+ parse_global_index_partition_value(
+ "a=b",
+ &DataType::VarChar(VarCharType::new(3).unwrap()),
+ true,
+ ),
+ Some(Datum::String("a=b".into()))
+ );
+ assert!(parse_global_index_partition_value(
+ "abcd",
+ &DataType::Char(CharType::new(3).unwrap()),
+ true,
+ )
+ .is_none());
+ assert_eq!(
+ parse_global_index_partition_value(
+ "12:00:00.123",
+ &DataType::Time(TimeType::new(3).unwrap()),
+ true,
+ ),
+ Some(Datum::Time(43_200_123))
+ );
+ let decimal = DataType::Decimal(DecimalType::new(4, 2).unwrap());
+ for (text, unscaled) in [("1.235", 124), ("-1.235", -124), ("1.235e1",
1235)] {
+ assert_eq!(
+ parse_global_index_partition_value(text, &decimal, true),
+ Some(Datum::Decimal {
+ unscaled,
+ precision: 4,
+ scale: 2
+ })
+ );
+ }
+ for text in ["99.995", "bad", "1_2.34"] {
+ assert!(parse_global_index_partition_value(text, &decimal,
true).is_none());
+ }
+ let timestamp = DataType::Timestamp(TimestampType::new(1).unwrap());
+ assert_eq!(
+ parse_global_index_partition_value("1970-01-01 00:00:00.199",
×tamp, true),
+ Some(Datum::Timestamp {
+ millis: 199,
+ nanos: 0
+ })
+ );
+ assert!(parse_global_index_partition_value("123", ×tamp,
true).is_none());
+ assert!(parse_global_index_partition_value("bad", ×tamp,
true).is_none());
+ let float = DataType::Float(FloatType::new());
+ assert_eq!(
+ parse_global_index_partition_value("1.1", &float, true),
+ Some(Datum::Float(1.1))
+ );
+ for text in ["1e100", "1e-100", "1.234567891"] {
+ assert!(parse_global_index_partition_value(text, &float,
true).is_none());
+ }
+ }
+
+ #[test]
+ fn test_global_index_timestamp_partition_values_preserve_identity() {
+ use paimon::spec::{LocalZonedTimestampType, TimestampType};
+
+ for precision in 0..=9 {
+ for zoned in [false, true] {
+ let data_type = if zoned {
+
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(precision).unwrap())
+ } else {
+ DataType::Timestamp(TimestampType::new(precision).unwrap())
+ };
+ for (text, millis, nanos) in [
+ ("1970-01-01T00:00:00.123456789Z", 123, 456_789),
+ ("1969-12-31T23:59:59.999999999Z", -1, 999_999),
+ ("2500-01-01T00:00:00Z", 16_725_225_600_000, 0),
+ ] {
+ let expected = if zoned {
+ Datum::LocalZonedTimestamp { millis, nanos }
+ } else {
+ Datum::Timestamp { millis, nanos }
+ };
+ assert_eq!(
+ parse_global_index_partition_value(text, &data_type,
true),
+ Some(expected),
+ "{data_type:?}: {text}"
+ );
+ }
+ assert!(parse_global_index_partition_value(
+ "1970-01-01T00:00:00.1234567891Z",
+ &data_type,
+ true,
+ )
+ .is_none());
+ }
+ }
+ }
+
fn test_file_io() -> paimon::io::FileIO {
FileIOBuilder::new("memory").build().unwrap()
}
diff --git a/crates/integrations/datafusion/tests/procedures.rs
b/crates/integrations/datafusion/tests/procedures.rs
index abc06c63..21bfc73e 100644
--- a/crates/integrations/datafusion/tests/procedures.rs
+++ b/crates/integrations/datafusion/tests/procedures.rs
@@ -1133,7 +1133,11 @@ async fn
test_drop_global_index_dry_run_rejects_invalid_args() {
("dry_run => NULL", "Unsupported argument value"),
(
"dry_run => true, partitions => 'pt=p1'",
- "drop_global_index partitions are not supported yet",
+ "unpartitioned table",
+ ),
+ (
+ "dry_run => true, partitions => NULL",
+ "Unsupported argument value",
),
] {
assert_sql_error(
@@ -1148,6 +1152,941 @@ async fn
test_drop_global_index_dry_run_rejects_invalid_args() {
}
}
+async fn setup_partitioned_global_index_table(
+ table_name: &str,
+ populated: bool,
+) -> (
+ tempfile::TempDir,
+ paimon_datafusion::SQLContext,
+ paimon::Table,
+) {
+ let (tmp, catalog) = create_test_env();
+ let sql_context = create_sql_context(catalog.clone()).await;
+ exec(&sql_context, "CREATE SCHEMA paimon.test_db").await;
+ exec(
+ &sql_context,
+ &format!(
+ "CREATE TABLE paimon.test_db.{table_name} (id INT, dt DATE, name
VARCHAR(100), hr INT) \
+ PARTITIONED BY (hr, dt) WITH (\
+ 'row-tracking.enabled' = 'true',\
+ 'data-evolution.enabled' = 'true',\
+ 'global-index.enabled' = 'true',\
+ 'sorted-index.records-per-range' = '10'\
+ )"
+ ),
+ )
+ .await;
+ if populated {
+ exec(
+ &sql_context,
+ &format!(
+ "INSERT INTO paimon.test_db.{table_name} (id, dt, name, hr)
VALUES \
+ (1, DATE '2026-10-10', 'a', 1), (2, DATE '2026-10-10', 'b',
2), \
+ (3, DATE '2026-10-11', 'c', 1), (4, DATE '2026-10-11', 'd',
2), \
+ (5, DATE '2026-10-12', 'e', 1), (6, NULL, 'f', 1)"
+ ),
+ )
+ .await;
+ for (column, index_type) in [
+ ("id", "btree"),
+ ("name", "btree"),
+ ("id,name", "btree"),
+ ("id", "bitmap"),
+ ] {
+ exec(
+ &sql_context,
+ &format!(
+ "CALL sys.create_global_index(table =>
'test_db.{table_name}', \
+ index_column => '{column}', index_type => '{index_type}')"
+ ),
+ )
+ .await;
+ }
+ }
+ let table = catalog
+ .get_table(&Identifier::new("test_db", table_name))
+ .await
+ .unwrap();
+ (tmp, sql_context, table)
+}
+
+async fn global_index_entries(
+ table: &paimon::Table,
+) -> BTreeMap<String, paimon::spec::IndexManifestEntry> {
+ let sm = table.snapshot_manager();
+ let snapshot = sm.get_latest_snapshot().await.unwrap().unwrap();
+ let Some(manifest_name) = snapshot.index_manifest() else {
+ return BTreeMap::new();
+ };
+ paimon::spec::IndexManifest::read(table.file_io(),
&sm.manifest_path(manifest_name))
+ .await
+ .unwrap()
+ .into_iter()
+ .map(|entry| (entry.index_file.file_name.clone(), entry))
+ .collect()
+}
+
+#[tokio::test]
+async fn
test_drop_global_index_partitions_preserve_other_partitions_and_definitions() {
+ let day = |text: &str| {
+ (chrono::NaiveDate::parse_from_str(text, "%Y-%m-%d").unwrap()
+ - chrono::DateTime::UNIX_EPOCH.date_naive())
+ .num_days() as i32
+ };
+ let d10 = Some(day("2026-10-10"));
+ let d11 = Some(day("2026-10-11"));
+ for (case, columns, partitions, selected) in [
+ (
+ "dates",
+ "id",
+ "dt=2026-10-10;dt=2026-10-11",
+ vec![(1, d10), (2, d10), (1, d11), (2, d11)],
+ ),
+ (
+ "pairs",
+ "id",
+ " dt = 2026-10-10, hr = 01 ; hr=2,dt=2026-10-11 ",
+ vec![(1, d10), (2, d11)],
+ ),
+ (
+ "composite",
+ "id,name",
+ "dt=2026-10-10,hr=1;dt=2026-10-11,hr=2",
+ vec![(1, d10), (2, d11)],
+ ),
+ ("null", "id", "dt=__DEFAULT_PARTITION__", vec![(1, None)]),
+ ] {
+ let name = format!("drop_partitions_{case}");
+ let (tmp, sql_context, table) =
setup_partitioned_global_index_table(&name, true).await;
+ let before = table_files(tmp.path());
+ let entries = global_index_entries(&table).await;
+ let id = table
+ .schema()
+ .fields()
+ .iter()
+ .find(|field| field.name() == "id")
+ .unwrap()
+ .id();
+ let name_id = table
+ .schema()
+ .fields()
+ .iter()
+ .find(|field| field.name() == "name")
+ .unwrap()
+ .id();
+ let extra_ids = if columns == "id" {
+ vec![]
+ } else {
+ vec![name_id]
+ };
+ let mut expected = entries.clone();
+ expected.retain(|_, entry| {
+ let row =
paimon::spec::BinaryRow::from_serialized_bytes(&entry.partition).unwrap();
+ let partition = (
+ row.get_int(0).unwrap(),
+ if row.is_null_at(1) {
+ None
+ } else {
+ Some(row.get_int(1).unwrap())
+ },
+ );
+ let target = entry.index_file.index_type == "btree"
+ && entry
+ .index_file
+ .global_index_meta
+ .as_ref()
+ .is_some_and(|meta| {
+ meta.index_field_id == id
+ &&
meta.extra_field_ids.as_deref().unwrap_or_default() == extra_ids
+ })
+ && selected.contains(&partition);
+ !target
+ });
+ let matched = entries.len() - expected.len();
+ assert_eq!(matched, selected.len(), "{case}");
+ let snapshot_id = table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap()
+ .id();
+ let call = format!(
+ "CALL sys.drop_global_index(table => 'test_db.{name}',
index_column => '{columns}', \
+ partitions => '{partitions}'"
+ );
+ let result = sql_context
+ .sql(&format!("{call}, dry_run => true)"))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(
+ string_value(result[0].column(0).as_ref(), 0),
+ format!("Would drop {matched} global index file(s)")
+ );
+ assert_eq!(table_files(tmp.path()), before, "{case}");
+ exec(&sql_context, &format!("{call})")).await;
+ assert_eq!(global_index_entries(&table).await, expected, "{case}");
+ assert_eq!(
+ table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap()
+ .id(),
+ snapshot_id + 1
+ );
+ }
+}
+
+#[tokio::test]
+async fn test_drop_global_index_partitions_no_match_does_not_commit() {
+ for populated in [false, true] {
+ let (tmp, sql_context, _) =
+ setup_partitioned_global_index_table("drop_partitions_empty",
populated).await;
+ let before = table_files(tmp.path());
+ for dry_run in [true, false] {
+ let result = sql_context.sql(&format!(
+ "CALL sys.drop_global_index(table =>
'test_db.drop_partitions_empty', index_column => 'id', \
+ partitions => 'dt=2026-10-09', dry_run => {dry_run})"
+ )).await.unwrap().collect().await.unwrap();
+ assert_eq!(
+ string_value(result[0].column(0).as_ref(), 0),
+ if dry_run {
+ "Would drop 0 global index file(s)"
+ } else {
+ "OK"
+ }
+ );
+ assert_eq!(table_files(tmp.path()), before);
+ }
+ }
+}
+
+#[tokio::test]
+async fn test_drop_global_index_partitions_string_keys_and_custom_null_name() {
+ let (tmp, catalog) = create_test_env();
+ let sql_context = create_sql_context(catalog.clone()).await;
+ exec(&sql_context, "CREATE SCHEMA paimon.test_db").await;
+ exec(
+ &sql_context,
+ "CREATE TABLE paimon.test_db.drop_string_partitions (id INT, dt
VARCHAR(20)) \
+ PARTITIONED BY (dt) WITH ('row-tracking.enabled'='true',
'data-evolution.enabled'='true', \
+ 'global-index.enabled'='true', 'partition.default-name'='NULL_PART')",
+ )
+ .await;
+ exec(&sql_context,
+ "INSERT INTO paimon.test_db.drop_string_partitions (id, dt) VALUES \
+ (1, '2026-10-10'), (2, '2026-10-11'), (3, '2026-10-12'), (4, 'a=b'),
(5, NULL), (6, 'NULL')"
+ ).await;
+ exec(&sql_context,
+ "CALL sys.create_global_index(table =>
'test_db.drop_string_partitions', index_column => 'id')"
+ ).await;
+ let table = catalog
+ .get_table(&Identifier::new("test_db", "drop_string_partitions"))
+ .await
+ .unwrap();
+ let mut expected = global_index_entries(&table).await;
+ assert_eq!(expected.len(), 6);
+ for (partitions, selected, matched) in [
+ (
+ "dt=2026-10-10;dt=2026-10-11",
+ vec![Some("2026-10-10"), Some("2026-10-11")],
+ 2,
+ ),
+ ("dt=a=b", vec![Some("a=b")], 1),
+ ("dt=NULL_PART", vec![None], 1),
+ ] {
+ let before = table_files(tmp.path());
+ let call = format!(
+ "CALL sys.drop_global_index(table =>
'test_db.drop_string_partitions', \
+ index_column => 'id', partitions => '{partitions}'"
+ );
+ let result = sql_context
+ .sql(&format!("{call}, dry_run => true)"))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(
+ string_value(result[0].column(0).as_ref(), 0),
+ format!("Would drop {matched} global index file(s)")
+ );
+ assert_eq!(table_files(tmp.path()), before);
+ exec(&sql_context, &format!("{call})")).await;
+ expected.retain(|_, entry| {
+ let row =
paimon::spec::BinaryRow::from_serialized_bytes(&entry.partition).unwrap();
+ let dt = if row.is_null_at(0) {
+ None
+ } else {
+ Some(row.get_string(0).unwrap())
+ };
+ !selected.contains(&dt)
+ });
+ assert_eq!(global_index_entries(&table).await, expected);
+ }
+}
+
+#[tokio::test]
+async fn test_drop_global_index_floating_partition_identity() {
+ for sql_type in ["FLOAT", "DOUBLE"] {
+ assert_floating_partition_drops(sql_type, &["-0.0", "NaN", "0.0",
"1.5"]).await;
+ }
+}
+
+#[tokio::test]
+async fn test_drop_global_index_float_generated_partition_names() {
+ assert_floating_partition_drops("FLOAT", &["147318.12", "-2096723.2",
"1.4E-45"]).await;
+}
+
+async fn assert_floating_partition_drops(sql_type: &str, values: &[&str]) {
+ let (tmp, catalog) = create_test_env();
+ let sql_context = create_sql_context(catalog.clone()).await;
+ exec(&sql_context, "CREATE SCHEMA paimon.test_db").await;
+ exec(
+ &sql_context,
+ &format!(
+ "CREATE TABLE paimon.test_db.drop_float_partitions (id INT, f
{sql_type}) \
+ PARTITIONED BY (f) WITH ('row-tracking.enabled'='true',
'data-evolution.enabled'='true', \
+ 'global-index.enabled'='true')"
+ ),
+ )
+ .await;
+ let rows = values
+ .iter()
+ .enumerate()
+ .map(|(i, value)| format!("({}, CAST('{value}' AS {sql_type}))", i +
1))
+ .collect::<Vec<_>>()
+ .join(", ");
+ exec(
+ &sql_context,
+ &format!("INSERT INTO paimon.test_db.drop_float_partitions (id, f)
VALUES {rows}"),
+ )
+ .await;
+ exec(&sql_context,
+ "CALL sys.create_global_index(table =>
'test_db.drop_float_partitions', index_column => 'id')"
+ ).await;
+ let table = catalog
+ .get_table(&Identifier::new("test_db", "drop_float_partitions"))
+ .await
+ .unwrap();
+ let directory_names =
std::fs::read_dir(table.location().strip_prefix("file://").unwrap())
+ .unwrap()
+ .map(|entry| entry.unwrap().file_name().into_string().unwrap())
+ .filter(|name| name.starts_with("f="))
+ .collect::<std::collections::BTreeSet<_>>();
+ assert_eq!(
+ directory_names,
+ values.iter().map(|value| format!("f={value}")).collect()
+ );
+ let mut expected = global_index_entries(&table).await;
+ assert_eq!(expected.len(), values.len());
+ if sql_type == "FLOAT" {
+ let before = table_files(tmp.path());
+ for partitions in ["f=1e100", "f=1e-100", "f=1.234567891",
"f=-0.0;f=bad"] {
+ for dry_run in [true, false] {
+ assert_sql_error(
+ &sql_context,
+ &format!(
+ "CALL sys.drop_global_index(table =>
'test_db.drop_float_partitions', \
+ index_column => 'id', partitions => '{partitions}',
dry_run => {dry_run})"
+ ),
+ "Invalid partitions value",
+ )
+ .await;
+ assert_eq!(table_files(tmp.path()), before);
+ }
+ }
+ }
+ for value in values {
+ // Use the directory name produced by the writer as the procedure
parameter.
+ let partition_name =
directory_names.get(&format!("f={value}")).unwrap();
+ let selected = if sql_type == "FLOAT" {
+ f64::from(value.parse::<f32>().unwrap())
+ } else {
+ value.parse::<f64>().unwrap()
+ };
+ let before = table_files(tmp.path());
+ let snapshot_id = table
+ .snapshot_manager()
+ .get_latest_snapshot_id()
+ .await
+ .unwrap()
+ .unwrap();
+ let call = format!(
+ "CALL sys.drop_global_index(table =>
'test_db.drop_float_partitions', \
+ index_column => 'id', partitions => '{partition_name}'"
+ );
+ let result = sql_context
+ .sql(&format!("{call}, dry_run => true)"))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(
+ string_value(result[0].column(0).as_ref(), 0),
+ "Would drop 1 global index file(s)",
+ "{sql_type}: {partition_name}"
+ );
+ assert_eq!(table_files(tmp.path()), before);
+ exec(&sql_context, &format!("{call})")).await;
+ let previous_len = expected.len();
+ expected.retain(|_, entry| {
+ let row =
paimon::spec::BinaryRow::from_serialized_bytes(&entry.partition).unwrap();
+ let actual = if sql_type == "FLOAT" {
+ f64::from(row.get_float(0).unwrap())
+ } else {
+ row.get_double(0).unwrap()
+ };
+ !(actual.to_bits() == selected.to_bits() || (actual.is_nan() &&
selected.is_nan()))
+ });
+ assert_eq!(expected.len(), previous_len - 1);
+ assert_eq!(
+ global_index_entries(&table).await,
+ expected,
+ "{sql_type}: {partition_name}"
+ );
+ assert_eq!(
+ table
+ .snapshot_manager()
+ .get_latest_snapshot_id()
+ .await
+ .unwrap(),
+ Some(snapshot_id + 1)
+ );
+ }
+}
+
+#[tokio::test]
+async fn test_drop_global_index_native_timestamp_partition_identity() {
+ use paimon::spec::{DataType, Datum, IntType, LocalZonedTimestampType,
Schema, TimestampType};
+
+ for precision in [1, 2, 4, 5, 7, 8] {
+ for zoned in [false, true] {
+ let (tmp, catalog) = create_test_env();
+ let sql_context = create_sql_context(catalog.clone()).await;
+ exec(&sql_context, "CREATE SCHEMA paimon.test_db").await;
+ let data_type = if zoned {
+
DataType::LocalZonedTimestamp(LocalZonedTimestampType::new(precision).unwrap())
+ } else {
+ DataType::Timestamp(TimestampType::new(precision).unwrap())
+ };
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("other", DataType::Int(IntType::new()))
+ .column("ts", data_type.clone())
+ .partition_keys(["ts"])
+ .options([
+ ("row-tracking.enabled".into(), "true".into()),
+ ("data-evolution.enabled".into(), "true".into()),
+ ("global-index.enabled".into(), "true".into()),
+ ("partition.legacy-name".into(), "false".into()),
+ ])
+ .build()
+ .unwrap();
+ let identifier = Identifier::new("test_db",
"drop_native_timestamp");
+ catalog
+ .create_table(&identifier, schema, false)
+ .await
+ .unwrap();
+ let (first, selected, millis, nanos) = match precision {
+ 1 | 2 => ("100", "199", 199, 0),
+ 4 | 5 => ("123400", "123499", 123, 499_000),
+ _ => ("123456700", "123456799", 123, 456_799),
+ };
+ exec(
+ &sql_context,
+ &format!(
+ "INSERT INTO paimon.test_db.drop_native_timestamp (id,
other, ts) VALUES \
+ (1, 10, TIMESTAMP '1970-01-01 00:00:00.{first}Z'), \
+ (2, 20, TIMESTAMP '1970-01-01 00:00:00.{selected}Z')"
+ ),
+ )
+ .await;
+ for (column, index_type) in [("id", "btree"), ("other", "btree"),
("id", "bitmap")] {
+ exec(
+ &sql_context,
+ &format!(
+ "CALL sys.create_global_index(table =>
'test_db.drop_native_timestamp', \
+ index_column => '{column}', index_type => '{index_type}')"
+ ),
+ )
+ .await;
+ }
+ let table = catalog.get_table(&identifier).await.unwrap();
+ assert_eq!(table.schema().partition_fields()[0].data_type(),
&data_type);
+ assert_eq!(
+ row_count(
+ &sql_context,
+ &format!(
+ "SELECT * FROM
paimon.test_db.`drop_native_timestamp$partitions` \
+ WHERE partition LIKE 'ts=%.{selected}'"
+ )
+ )
+ .await,
+ 1
+ );
+ let selected_datum = if zoned {
+ Datum::LocalZonedTimestamp { millis, nanos }
+ } else {
+ Datum::Timestamp { millis, nanos }
+ };
+ let mut expected = global_index_entries(&table).await;
+ assert_eq!(expected.len(), 6);
+ expected.retain(|_, entry| {
+ let row =
paimon::spec::BinaryRow::from_serialized_bytes(&entry.partition).unwrap();
+ !(entry.index_file.index_type == "btree"
+ && entry
+ .index_file
+ .global_index_meta
+ .as_ref()
+ .unwrap()
+ .index_field_id
+ == 0
+ && row.get_datum(0, &data_type).unwrap().as_ref() ==
Some(&selected_datum))
+ });
+ assert_eq!(expected.len(), 5);
+ let before = table_files(tmp.path());
+ let snapshot_id = table
+ .snapshot_manager()
+ .get_latest_snapshot_id()
+ .await
+ .unwrap()
+ .unwrap();
+ let suffix = if zoned { "Z" } else { "" };
+ let absent = format!("{}8", &selected[..selected.len() - 1]);
+ let no_match = format!(
+ "CALL sys.drop_global_index(table =>
'test_db.drop_native_timestamp', \
+ index_column => 'id', partitions =>
'ts=1970-01-01T00:00:00.{absent}{suffix}'"
+ );
+ let batches = sql_context
+ .sql(&format!("{no_match}, dry_run => true)"))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(
+ string_value(batches[0].column(0).as_ref(), 0),
+ "Would drop 0 global index file(s)"
+ );
+ exec(&sql_context, &format!("{no_match})")).await;
+ assert_eq!(table_files(tmp.path()), before);
+ for dry_run in [true, false] {
+ assert_sql_error(&sql_context, &format!(
+ "CALL sys.drop_global_index(table =>
'test_db.drop_native_timestamp', \
+ index_column => 'id', partitions =>
'ts=1970-01-01T00:00:00.{selected}{suffix};\
+ ts=1970-01-01T00:00:00.1234567891{suffix}', dry_run =>
{dry_run})"
+ ), "Invalid partitions value").await;
+ assert_eq!(table_files(tmp.path()), before);
+ }
+ let call = format!(
+ "CALL sys.drop_global_index(table =>
'test_db.drop_native_timestamp', \
+ index_column => 'id', partitions =>
'ts=1970-01-01T00:00:00.{selected}{suffix}'"
+ );
+ let batches = sql_context
+ .sql(&format!("{call}, dry_run => true)"))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(
+ string_value(batches[0].column(0).as_ref(), 0),
+ "Would drop 1 global index file(s)"
+ );
+ assert_eq!(table_files(tmp.path()), before);
+ exec(&sql_context, &format!("{call})")).await;
+ assert_eq!(
+ global_index_entries(&table).await,
+ expected,
+ "{data_type:?}"
+ );
+ assert_eq!(
+ table
+ .snapshot_manager()
+ .get_latest_snapshot_id()
+ .await
+ .unwrap(),
+ Some(snapshot_id + 1)
+ );
+ assert_eq!(
+ row_count(
+ &sql_context,
+ "SELECT * FROM paimon.test_db.drop_native_timestamp"
+ )
+ .await,
+ 2
+ );
+ }
+ }
+}
+
+#[tokio::test]
+async fn test_drop_global_index_ltz_partitions_use_display_timezone() {
+ const CHILD: &str = "PAIMON_DROP_LTZ_PARTITIONS_CHILD";
+ if std::env::var_os(CHILD).is_none() {
+ // Local timezone is process-global; isolate it from parallel SQL
tests.
+ for timezone in ["Asia/Shanghai", "America/New_York",
"Africa/Monrovia"] {
+ let output =
std::process::Command::new(std::env::current_exe().unwrap())
+ .args([
+ "--exact",
+
"test_drop_global_index_ltz_partitions_use_display_timezone",
+ ])
+ .env("TZ", timezone)
+ .env(CHILD, timezone)
+ .output()
+ .unwrap();
+ assert!(
+ output.status.success(),
+ "{timezone}:\n{}{}",
+ String::from_utf8_lossy(&output.stdout),
+ String::from_utf8_lossy(&output.stderr)
+ );
+ }
+ return;
+ }
+ let timezone = std::env::var(CHILD).unwrap();
+ if timezone == "Asia/Shanghai" {
+ let instants = ["1970-01-01 00:00:00+00:00", "1970-01-01
08:00:00+00:00"];
+ for (legacy, input, selected) in [
+ (false, "1970-01-01 08:00:00.000", 0),
+ (false, "1970-01-01T08:00:00.000+08:00", 0),
+ (false, "1970-01-01T08:00:00.000Z", 28_800_000),
+ (false, "0", 0),
+ (true, "1970-01-01T00:00", 0),
+ ] {
+ assert_timestamp_partition_drop(
+ "TIMESTAMP(3) WITH TIME ZONE",
+ legacy,
+ instants,
+ input,
+ (selected, 0),
+ )
+ .await;
+ }
+ assert_timestamp_partition_drop(
+ "TIMESTAMP(3)",
+ false,
+ instants,
+ "1970-01-01 00:00:00.000",
+ (0, 0),
+ )
+ .await;
+ assert_timestamp_partition_drop("TIMESTAMP(3)", true, instants,
"1970-01-01T00:00", (0, 0))
+ .await;
+ for (sql_type, input, expected_nanos) in [
+ (
+ "TIMESTAMP(6) WITH TIME ZONE",
+ "1970-01-01 08:00:00.123456",
+ 456_000,
+ ),
+ (
+ "TIMESTAMP(9) WITH TIME ZONE",
+ "1970-01-01 08:00:00.123456789",
+ 456_789,
+ ),
+ ] {
+ assert_timestamp_partition_drop(
+ sql_type,
+ false,
+ [
+ "1970-01-01 00:00:00.123456789+00:00",
+ "1970-01-01 08:00:00.123456789+00:00",
+ ],
+ input,
+ (123, expected_nanos),
+ )
+ .await;
+ }
+ } else if timezone == "America/New_York" {
+ // Summer offset is -04:00, not the timezone's winter offset of -05:00.
+ assert_timestamp_partition_drop(
+ "TIMESTAMP(3) WITH TIME ZONE",
+ false,
+ ["2024-07-01 16:00:00+00:00", "2024-07-01 17:00:00+00:00"],
+ "2024-07-01 12:00:00.000",
+ (1_719_849_600_000, 0),
+ )
+ .await;
+ assert_timestamp_partition_drop(
+ "TIMESTAMP(3) WITH TIME ZONE",
+ false,
+ ["2024-11-03 05:30:00+00:00", "2024-11-03 06:30:00+00:00"],
+ "2024-11-03T01:30:00-04:00",
+ (1_730_611_800_000, 0),
+ )
+ .await;
+ } else {
+ // Monrovia's historical offset of -00:44:30 must not shift the
instant by 30 seconds.
+ for input in ["1970-12-31 23:15:30.000", "1971-01-01T00:00:00Z"] {
+ assert_timestamp_partition_drop(
+ "TIMESTAMP(3) WITH TIME ZONE",
+ false,
+ ["1971-01-01 00:00:00+00:00", "1971-01-01 00:00:30+00:00"],
+ input,
+ (31_536_000_000, 0),
+ )
+ .await;
+ }
+ }
+}
+
+async fn assert_timestamp_partition_drop(
+ sql_type: &str,
+ legacy: bool,
+ instants: [&str; 2],
+ input: &str,
+ selected: (i64, i32),
+) {
+ let (tmp, catalog) = create_test_env();
+ let sql_context = create_sql_context(catalog.clone()).await;
+ exec(&sql_context, "CREATE SCHEMA paimon.test_db").await;
+ exec(
+ &sql_context,
+ &format!(
+ "CREATE TABLE paimon.test_db.drop_ltz_partitions (id INT, name
STRING, ts {sql_type}) \
+ PARTITIONED BY (ts) WITH ('row-tracking.enabled'='true',
'data-evolution.enabled'='true', \
+ 'global-index.enabled'='true', 'partition.legacy-name'='{legacy}')"
+ ),
+ )
+ .await;
+ exec(
+ &sql_context,
+ &format!(
+ "INSERT INTO paimon.test_db.drop_ltz_partitions (id, name, ts)
VALUES \
+ (1, 'a', TIMESTAMP '{}'), (2, 'b', TIMESTAMP '{}')",
+ instants[0], instants[1]
+ ),
+ )
+ .await;
+ for (column, index_type) in [("id", "btree"), ("name", "btree"), ("id",
"bitmap")] {
+ exec(
+ &sql_context,
+ &format!(
+ "CALL sys.create_global_index(table =>
'test_db.drop_ltz_partitions', \
+ index_column => '{column}', index_type => '{index_type}')"
+ ),
+ )
+ .await;
+ }
+ let table = catalog
+ .get_table(&Identifier::new("test_db", "drop_ltz_partitions"))
+ .await
+ .unwrap();
+ let partition_type =
table.schema().partition_fields()[0].data_type().clone();
+ let selected_datum = match partition_type {
+ paimon::spec::DataType::Timestamp(_) => paimon::spec::Datum::Timestamp
{
+ millis: selected.0,
+ nanos: selected.1,
+ },
+ paimon::spec::DataType::LocalZonedTimestamp(_) => {
+ paimon::spec::Datum::LocalZonedTimestamp {
+ millis: selected.0,
+ nanos: selected.1,
+ }
+ }
+ other => panic!("Unexpected partition type: {other:?}"),
+ };
+ let mut expected = global_index_entries(&table).await;
+ assert_eq!(expected.len(), 6);
+ if input.contains(' ') || legacy {
+ assert_eq!(row_count(&sql_context, &format!(
+ "SELECT * FROM paimon.test_db.`drop_ltz_partitions$partitions`
WHERE partition = 'ts={input}'"
+ )).await, 1);
+ }
+ expected.retain(|_, entry| {
+ let row =
paimon::spec::BinaryRow::from_serialized_bytes(&entry.partition).unwrap();
+ !(entry.index_file.index_type == "btree"
+ && entry
+ .index_file
+ .global_index_meta
+ .as_ref()
+ .unwrap()
+ .index_field_id
+ == 0
+ && row.get_datum(0, &partition_type).unwrap().as_ref() ==
Some(&selected_datum))
+ });
+ assert_eq!(expected.len(), 5);
+ let before = table_files(tmp.path());
+ let snapshot_id = table
+ .snapshot_manager()
+ .get_latest_snapshot_id()
+ .await
+ .unwrap()
+ .unwrap();
+ if std::env::var("TZ").unwrap() == "America/New_York" {
+ for invalid in [
+ "2024-03-10 02:30:00",
+ "2024-11-03 01:30:00",
+ "not-a-timestamp",
+ ] {
+ for dry_run in [true, false] {
+ assert_sql_error(&sql_context, &format!(
+ "CALL sys.drop_global_index(table =>
'test_db.drop_ltz_partitions', index_column => 'id', \
+ partitions => 'ts={input};ts={invalid}', dry_run =>
{dry_run})"
+ ), "Invalid partitions value").await;
+ assert_eq!(table_files(tmp.path()), before);
+ }
+ }
+ }
+ let call = format!(
+ "CALL sys.drop_global_index(table => 'test_db.drop_ltz_partitions',
index_column => 'id', partitions => 'ts={input}'"
+ );
+ let result = sql_context
+ .sql(&format!("{call}, dry_run => true)"))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(
+ string_value(result[0].column(0).as_ref(), 0),
+ "Would drop 1 global index file(s)"
+ );
+ assert_eq!(table_files(tmp.path()), before);
+ exec(&sql_context, &format!("{call})")).await;
+ assert_eq!(
+ global_index_entries(&table).await,
+ expected,
+ "{sql_type}, legacy={legacy}, {input}"
+ );
+ assert_eq!(
+ table
+ .snapshot_manager()
+ .get_latest_snapshot_id()
+ .await
+ .unwrap(),
+ Some(snapshot_id + 1)
+ );
+ assert_eq!(
+ row_count(
+ &sql_context,
+ "SELECT * FROM paimon.test_db.drop_ltz_partitions"
+ )
+ .await,
+ 2
+ );
+}
+
+#[tokio::test]
+async fn test_drop_global_index_partitions_timestamp_and_decimal_are_exact() {
+ let (tmp, catalog) = create_test_env();
+ let sql_context = create_sql_context(catalog.clone()).await;
+ exec(&sql_context, "CREATE SCHEMA paimon.test_db").await;
+ exec(&sql_context,
+ "CREATE TABLE paimon.test_db.drop_typed_partitions (id INT, ts
TIMESTAMP(3), price DECIMAL(10,2)) \
+ PARTITIONED BY (ts, price) WITH ('row-tracking.enabled'='true',
'data-evolution.enabled'='true', \
+ 'global-index.enabled'='true')"
+ ).await;
+ exec(&sql_context,
+ "INSERT INTO paimon.test_db.drop_typed_partitions (id, ts, price)
VALUES \
+ (1, TIMESTAMP '2026-10-10 12:00:00.199', 1.24), (2, TIMESTAMP
'2026-10-10 12:00:00.199', 1.23), \
+ (3, TIMESTAMP '2026-10-10 12:00:00.2', 1.24)"
+ ).await;
+ exec(&sql_context,
+ "CALL sys.create_global_index(table =>
'test_db.drop_typed_partitions', index_column => 'id')"
+ ).await;
+ let table = catalog
+ .get_table(&Identifier::new("test_db", "drop_typed_partitions"))
+ .await
+ .unwrap();
+ let before = table_files(tmp.path());
+ let mut expected = global_index_entries(&table).await;
+ assert_eq!(expected.len(), 3);
+ let millis =
+ chrono::NaiveDateTime::parse_from_str("2026-10-10 12:00:00.199",
"%Y-%m-%d %H:%M:%S%.f")
+ .unwrap()
+ .and_utc()
+ .timestamp_millis();
+ expected.retain(|_, entry| {
+ let row =
paimon::spec::BinaryRow::from_serialized_bytes(&entry.partition).unwrap();
+ !(row.get_long(0).unwrap() == millis && row.get_long(1).unwrap() ==
124)
+ });
+ assert_eq!(expected.len(), 2);
+ let no_match = "CALL sys.drop_global_index(table =>
'test_db.drop_typed_partitions', index_column => 'id', \
+ partitions => 'ts=2026-10-10 12:00:00.1999,price=1.235'";
+ let result = sql_context
+ .sql(&format!("{no_match}, dry_run => true)"))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(
+ string_value(result[0].column(0).as_ref(), 0),
+ "Would drop 0 global index file(s)"
+ );
+ exec(&sql_context, &format!("{no_match})")).await;
+ assert_eq!(table_files(tmp.path()), before);
+ let call = "CALL sys.drop_global_index(table =>
'test_db.drop_typed_partitions', index_column => 'id', \
+ partitions => 'ts=2026-10-10 12:00:00.199,price=1.235'";
+ let result = sql_context
+ .sql(&format!("{call}, dry_run => true)"))
+ .await
+ .unwrap()
+ .collect()
+ .await
+ .unwrap();
+ assert_eq!(
+ string_value(result[0].column(0).as_ref(), 0),
+ "Would drop 1 global index file(s)"
+ );
+ assert_eq!(table_files(tmp.path()), before);
+ exec(&sql_context, &format!("{call})")).await;
+ assert_eq!(global_index_entries(&table).await, expected);
+}
+
+#[tokio::test]
+async fn test_drop_global_index_partitions_invalid_parameters_never_commit() {
+ for populated in [false, true] {
+ let (tmp, sql_context, _) =
+ setup_partitioned_global_index_table("drop_partitions_invalid",
populated).await;
+ let before = table_files(tmp.path());
+ for partitions in [
+ "",
+ " ",
+ ";",
+ "dt",
+ "dt=",
+ "=2026-10-10",
+ "missing=x",
+ "id=1",
+ "dt=not-a-date",
+ "dt=2026-02-30",
+ "hr=not-an-int",
+ "hr=1.5",
+ "hr=2147483648",
+ "dt=2026-10-10;",
+ ";dt=2026-10-10",
+ "dt=2026-10-10;;dt=2026-10-11",
+ "dt=2026-10-10,",
+ ",dt=2026-10-10",
+ "dt=2026-10-10,,hr=1",
+ "dt=2026-10-10,dt=2026-10-11",
+ "dt=2026-10-10;missing=x",
+ "dt=2026-10-10;hr=bad",
+ ] {
+ for dry_run in [true, false] {
+ assert_sql_error(&sql_context, &format!(
+ "CALL sys.drop_global_index(table =>
'test_db.drop_partitions_invalid', index_column => 'id', \
+ partitions => '{partitions}', dry_run => {dry_run})"
+ ), "partition").await;
+ assert_eq!(
+ table_files(tmp.path()),
+ before,
+ "{partitions:?}, dry_run={dry_run}"
+ );
+ }
+ }
+ }
+}
+
#[tokio::test]
async fn test_drop_global_index_removes_btree_and_reads_fallback() {
let (_tmp, sql_context) =
setup_btree_global_index_table("btree_drop").await;
diff --git a/crates/paimon/src/spec/mod.rs b/crates/paimon/src/spec/mod.rs
index 743004c7..02c50c9c 100644
--- a/crates/paimon/src/spec/mod.rs
+++ b/crates/paimon/src/spec/mod.rs
@@ -119,11 +119,11 @@ pub use types::*;
mod partition;
pub use partition::Partition;
mod partition_utils;
-pub use partition_utils::is_java_whitespace_only;
pub(crate) use partition_utils::{
bucket_path, bucket_path_under, data_file_path, escape_path_name,
relative_bucket_path,
unescape_path_name, PartitionComputer,
};
+pub use partition_utils::{format_java_float, is_java_whitespace_only};
mod predicate;
pub(crate) use predicate::datum_cmp;
pub(crate) use predicate::eval_row;
diff --git a/crates/paimon/src/spec/partition_utils.rs
b/crates/paimon/src/spec/partition_utils.rs
index e6fe1dda..f534c1f4 100644
--- a/crates/paimon/src/spec/partition_utils.rs
+++ b/crates/paimon/src/spec/partition_utils.rs
@@ -24,7 +24,7 @@
//! - Java `PartitionPathUtils` for path escaping and assembly
use crate::common::date_time::{format_time, format_timestamp};
-use crate::common::number::format_java_float;
+use crate::common::number::format_java_float as format_java_floating;
use crate::error::*;
use crate::spec::types::DataType;
use crate::spec::BinaryRow;
@@ -393,7 +393,7 @@ fn format_partition_value(
DataType::Int(_) => row.get_int(pos)?.to_string(),
DataType::BigInt(_) => row.get_long(pos)?.to_string(),
DataType::Float(_) => format_java_float(row.get_float(pos)?),
- DataType::Double(_) => format_java_float(row.get_double(pos)?),
+ DataType::Double(_) => format_java_floating(row.get_double(pos)?),
DataType::Char(_) | DataType::VarChar(_) => {
let s = row.get_string(pos)?;
@@ -485,6 +485,13 @@ fn format_partition_value(
Ok(value)
}
+/// Java's shortest-decimal string contract (JDK 19+), including its notation
+/// thresholds and closest choice among one- and two-digit decimals. Rust's
+/// shortest Display chooses different digits at ties; Ryƫ uses ties-to-even.
+pub fn format_java_float(value: f32) -> String {
+ format_java_floating(value)
+}
+
/// Decode as Java's UTF-8 decoder does for `BinaryString.toString()`.
/// Rust's lossy decoder replaces each byte of a UTF-8 encoded surrogate,
/// while Java replaces the complete malformed surrogate sequence once.
diff --git a/crates/paimon/src/table/global_index_drop_builder.rs
b/crates/paimon/src/table/global_index_drop_builder.rs
index 7b2f811b..d0bb2bf5 100644
--- a/crates/paimon/src/table/global_index_drop_builder.rs
+++ b/crates/paimon/src/table/global_index_drop_builder.rs
@@ -19,7 +19,10 @@ use super::global_index_build_common::IndexColumns;
use super::global_index_types::{
normalize_global_index_type, BTREE_GLOBAL_INDEX_TYPE,
SUPPORTED_GLOBAL_INDEX_TYPES_FOR_DROP,
};
-use crate::spec::{FileKind, IndexFileMeta, IndexManifest};
+use crate::spec::{
+ eval_row, extract_datum, field_idx_to_partition_idx, BinaryRow, DataType,
Datum, FileKind,
+ IndexFileMeta, IndexManifest, Predicate, PredicateOperator,
+};
use crate::table::{CommitMessage, Table, TableCommit};
use crate::{Error, Result};
use std::collections::HashMap;
@@ -28,6 +31,7 @@ pub struct GlobalIndexDropBuilder<'a> {
table: &'a Table,
index_column: Option<IndexColumns>,
index_type: String,
+ partition_filter: Option<Predicate>,
dry_run: bool,
}
@@ -37,6 +41,7 @@ impl<'a> GlobalIndexDropBuilder<'a> {
table,
index_column: None,
index_type: BTREE_GLOBAL_INDEX_TYPE.to_string(),
+ partition_filter: None,
dry_run: false,
}
}
@@ -59,6 +64,16 @@ impl<'a> GlobalIndexDropBuilder<'a> {
self
}
+ /// Restrict the drop using table-field indices. Repeated calls combine
filters with AND.
+ pub fn with_partition_filter(&mut self, filter: Predicate) -> Result<&mut
Self> {
+ super::partition_filter::validate_partition_filter(self.table,
&filter)?;
+ self.partition_filter = Some(match self.partition_filter.take() {
+ Some(previous) => Predicate::and(vec![previous, filter]),
+ None => filter,
+ });
+ Ok(self)
+ }
+
/// Return the matched index file count without committing when enabled.
pub fn with_dry_run(&mut self, dry_run: bool) -> &mut Self {
self.dry_run = dry_run;
@@ -95,6 +110,21 @@ impl<'a> GlobalIndexDropBuilder<'a> {
.skip(1)
.map(|field| field.id())
.collect::<Vec<_>>();
+ let partition_filter = self
+ .partition_filter
+ .as_ref()
+ .map(|filter| {
+ let mapping = field_idx_to_partition_idx(
+ self.table.schema().fields(),
+ self.table.schema().partition_keys(),
+ );
+ filter
+ .remap_field_index(&mapping)
+ .ok_or_else(|| Error::ConfigInvalid {
+ message: "Partition filter must reference only
partition keys".to_string(),
+ })
+ })
+ .transpose()?;
// The table's manager resolves a REST-managed table's latest snapshot
// through the catalog, like reads and commit validation do.
@@ -132,6 +162,20 @@ impl<'a> GlobalIndexDropBuilder<'a> {
{
continue;
}
+ if let Some(filter) = &partition_filter {
+ // Deletion requires exact matching, not conservative scan
pruning.
+ let partition =
BinaryRow::from_serialized_bytes(&entry.partition)?;
+ if partition.arity() as usize !=
self.table.schema().partition_keys().len() {
+ return Err(Error::DataInvalid {
+ message: "Index partition row arity does not match
partition keys"
+ .to_string(),
+ source: None,
+ });
+ }
+ if !eval_partition_filter(filter, &partition)? {
+ continue;
+ }
+ }
dropped += 1;
deletions_by_partition_bucket
.entry((entry.partition, entry.bucket))
@@ -172,6 +216,92 @@ impl<'a> GlobalIndexDropBuilder<'a> {
}
}
+/// Preserve Java partition comparisons without changing general row
evaluation.
+fn eval_partition_filter(predicate: &Predicate, row: &BinaryRow) ->
Result<bool> {
+ use std::cmp::Ordering::{Equal, Greater, Less};
+ use PredicateOperator::*;
+
+ match predicate {
+ Predicate::And(children) => {
+ for child in children {
+ if !eval_partition_filter(child, row)? {
+ return Ok(false);
+ }
+ }
+ Ok(true)
+ }
+ Predicate::Or(children) => {
+ for child in children {
+ if eval_partition_filter(child, row)? {
+ return Ok(true);
+ }
+ }
+ Ok(false)
+ }
+ Predicate::Not(inner) => Ok(!eval_partition_filter(inner, row)?),
+ Predicate::Leaf {
+ index,
+ data_type,
+ op,
+ literals,
+ ..
+ } if matches!(data_type, DataType::Float(_) | DataType::Double(_))
+ && !matches!(op, IsNull | IsNotNull) =>
+ {
+ let Some(value) = extract_datum(row, *index, data_type)? else {
+ return Ok(false);
+ };
+ let compare = |literal: &Datum| {
+ // Widening FLOAT to DOUBLE is exact, including the sign of
zero.
+ let (left, right) = match (&value, literal) {
+ (Datum::Float(left), Datum::Float(right)) => {
+ (f64::from(*left), f64::from(*right))
+ }
+ (Datum::Double(left), Datum::Double(right)) => (*left,
*right),
+ _ => return None,
+ };
+ Some(match (left.is_nan(), right.is_nan()) {
+ // Java canonicalizes every NaN before comparing
floating-point bits.
+ (true, true) => Equal,
+ (true, false) => Greater,
+ (false, true) => Less,
+ (false, false) => left.total_cmp(&right),
+ })
+ };
+ let first = || literals.first().and_then(compare);
+ Ok(match op {
+ Eq => first() == Some(Equal),
+ NotEq => matches!(first(), Some(Less | Greater)),
+ Lt => first() == Some(Less),
+ LtEq => matches!(first(), Some(Less | Equal)),
+ Gt => first() == Some(Greater),
+ GtEq => matches!(first(), Some(Greater | Equal)),
+ In => literals
+ .iter()
+ .any(|literal| compare(literal) == Some(Equal)),
+ NotIn => !literals
+ .iter()
+ .any(|literal| compare(literal) == Some(Equal)),
+ Between | NotBetween => {
+ let between = matches!(first(), Some(Greater | Equal))
+ && matches!(literals.get(1).and_then(compare),
Some(Less | Equal));
+ if *op == Between {
+ between
+ } else {
+ !between
+ }
+ }
+ _ => {
+ return Err(Error::ConfigInvalid {
+ message: format!("Unsupported floating partition
operator '{op}'"),
+ })
+ }
+ })
+ }
+ _ => eval_row(predicate, row),
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -187,6 +317,83 @@ mod tests {
use indexmap::IndexMap;
use std::collections::BTreeMap;
+ #[test]
+ fn test_floating_partition_comparisons_follow_java_ordering() {
+ use crate::spec::{BinaryRowBuilder, DoubleType, FloatType};
+ use PredicateOperator::*;
+
+ for data_type in [
+ DataType::Float(FloatType::new()),
+ DataType::Double(DoubleType::new()),
+ ] {
+ let datum = |value| match data_type {
+ DataType::Float(_) => Datum::Float(value as f32),
+ _ => Datum::Double(value),
+ };
+ // Java compares all NaN signs and payloads equally, above
positive infinity.
+ let values = [
+ f64::NEG_INFINITY,
+ -0.0,
+ 0.0,
+ f64::INFINITY,
+ f64::NAN,
+ f64::from_bits(0xfff8_1234_5678_0000),
+ ];
+ let mut rows = values
+ .iter()
+ .map(|value| {
+ let mut row = BinaryRowBuilder::new(1);
+ row.write_datum(0, &datum(*value), &data_type);
+ row.build()
+ })
+ .collect::<Vec<_>>();
+ let mut null = BinaryRowBuilder::new(1);
+ null.set_null_at(0);
+ rows.push(null.build());
+ let leaf = |op, literals: Vec<f64>| Predicate::Leaf {
+ column: "f".into(),
+ index: 0,
+ data_type: data_type.clone(),
+ op,
+ literals: literals.into_iter().map(datum).collect(),
+ };
+ for (op, literals, expected) in [
+ (Eq, vec![0.0], vec![2]),
+ (Eq, vec![f64::NAN], vec![4, 5]),
+ (NotEq, vec![0.0], vec![0, 1, 3, 4, 5]),
+ (Lt, vec![0.0], vec![0, 1]),
+ (LtEq, vec![0.0], vec![0, 1, 2]),
+ (Gt, vec![0.0], vec![3, 4, 5]),
+ (GtEq, vec![0.0], vec![2, 3, 4, 5]),
+ (In, vec![-0.0, f64::NAN], vec![1, 4, 5]),
+ (NotIn, vec![-0.0, f64::NAN], vec![0, 2, 3]),
+ (Between, vec![-0.0, 0.0], vec![1, 2]),
+ (NotBetween, vec![-0.0, 0.0], vec![0, 3, 4, 5]),
+ (IsNull, vec![], vec![6]),
+ (IsNotNull, vec![], vec![0, 1, 2, 3, 4, 5]),
+ ] {
+ let predicate = leaf(op, literals);
+ for (i, row) in rows.iter().enumerate() {
+ assert_eq!(
+ eval_partition_filter(&predicate, row).unwrap(),
+ expected.contains(&i),
+ "{data_type:?}: {op}, row {i}"
+ );
+ }
+ }
+ let nested = Predicate::and(vec![
+ Predicate::or(vec![leaf(Eq, vec![0.0]), leaf(Eq,
vec![f64::NAN])]),
+ Predicate::Not(Box::new(leaf(Eq, vec![0.0]))),
+ ]);
+ for (i, row) in rows.iter().enumerate() {
+ assert_eq!(
+ eval_partition_filter(&nested, row).unwrap(),
+ matches!(i, 4 | 5)
+ );
+ }
+ }
+ }
+
#[tokio::test]
async fn test_query_auth_table_refuses_index_builds_and_drops() {
let table = crate::table::query_auth_table();
@@ -394,6 +601,238 @@ mod tests {
files
}
+ async fn partitioned_index_table(path: &str) -> Table {
+ let schema = Schema::builder()
+ .column("id", DataType::Int(IntType::new()))
+ .column("dt", DataType::VarChar(VarCharType::string_type()))
+ .column("name", DataType::VarChar(VarCharType::string_type()))
+ .column("hr", DataType::Int(IntType::new()))
+ .partition_keys(["hr", "dt"])
+ .build()
+ .unwrap();
+ let table = Table::new(
+ FileIOBuilder::new("memory").build().unwrap(),
+ Identifier::new("default", "partitioned_indexes"),
+ path.to_string(),
+ TableSchema::new(0, &schema),
+ None,
+ );
+ setup_dirs(&table).await;
+ let mut messages = Vec::new();
+ for (i, (dt, hr)) in [
+ (Some("2026-10-10"), 1),
+ (Some("2026-10-10"), 2),
+ (Some("2026-10-11"), 1),
+ (Some("2026-10-11"), 2),
+ (None, 1),
+ ]
+ .into_iter()
+ .enumerate()
+ {
+ let mut partition = crate::spec::BinaryRowBuilder::new(2);
+ partition.write_int(0, hr);
+ match dt {
+ Some(dt) => partition.write_string(1, dt),
+ None => partition.set_null_at(1),
+ }
+ let start = i as i64 * 10;
+ let end = start + 9;
+ let mut composite =
+ global_index_file("btree", &format!("composite-{i}"), 0,
start, end);
+ composite
+ .global_index_meta
+ .as_mut()
+ .unwrap()
+ .extra_field_ids = Some(vec![2]);
+ let mut message = CommitMessage::new(
+ partition.build_serialized(),
+ 0,
+ vec![data_file(&format!("data-{i}.parquet"))],
+ );
+ message.new_index_files = vec![
+ global_index_file("btree", &format!("id-{i}"), 0, start, end),
+ composite,
+ global_index_file("btree", &format!("name-{i}"), 2, start,
end),
+ global_index_file("bitmap", &format!("bitmap-{i}"), 0, start,
end),
+ hash_index_file(&format!("hash-{i}")),
+ ];
+ messages.push(message);
+ }
+ TableCommit::new(table.clone(), "setup".into())
+ .commit(messages)
+ .await
+ .unwrap();
+ table
+ }
+
+ #[tokio::test]
+ async fn test_drop_global_index_partition_filter_preserves_other_entries()
{
+ let table =
partitioned_index_table("memory:/drop_partition_alternatives").await;
+ let pb = crate::spec::PredicateBuilder::new(table.schema().fields());
+ let filter = Predicate::or(vec![
+ pb.partition_predicate(&[
+ ("dt", Some(crate::spec::Datum::String("2026-10-10".into()))),
+ ("hr", Some(crate::spec::Datum::Int(1))),
+ ])
+ .unwrap(),
+ pb.partition_predicate(&[
+ ("dt", Some(crate::spec::Datum::String("2026-10-11".into()))),
+ ("hr", Some(crate::spec::Datum::Int(2))),
+ ])
+ .unwrap(),
+ ]);
+ for columns in ["id", "id,name"] {
+ let before = table_files(&table).await;
+ let previous_entries = latest_index_entries(&table).await;
+ let snapshot_id = table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap()
+ .id();
+ let mut builder = table.new_global_index_drop_builder();
+ builder.with_index_column(columns).with_dry_run(true);
+ builder.with_partition_filter(filter.clone()).unwrap();
+ assert_eq!(builder.execute().await.unwrap(), 2);
+ assert_eq!(table_files(&table).await, before);
+
+ builder.with_dry_run(false);
+ assert_eq!(builder.execute().await.unwrap(), 2);
+ let prefix = if columns == "id" { "id" } else { "composite" };
+ let expected = previous_entries
+ .into_iter()
+ .filter(|entry| {
+ ![format!("{prefix}-0"), format!("{prefix}-3")]
+ .contains(&entry.index_file.file_name)
+ })
+ .map(|entry| (entry.index_file.file_name.clone(), entry))
+ .collect::<BTreeMap<_, _>>();
+ let remaining = latest_index_entries(&table)
+ .await
+ .into_iter()
+ .map(|entry| (entry.index_file.file_name.clone(), entry))
+ .collect::<BTreeMap<_, _>>();
+ assert_eq!(remaining, expected);
+ assert_eq!(
+ table
+ .snapshot_manager()
+ .get_latest_snapshot()
+ .await
+ .unwrap()
+ .unwrap()
+ .id(),
+ snapshot_id + 1
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn test_drop_global_index_partition_filters_combine_with_and() {
+ let table =
partitioned_index_table("memory:/drop_partition_and").await;
+ let pb = crate::spec::PredicateBuilder::new(table.schema().fields());
+ let mut builder = table.new_global_index_drop_builder();
+ builder.with_index_column("id").with_dry_run(true);
+ builder
+ .with_partition_filter(
+ pb.equal("dt", crate::spec::Datum::String("2026-10-10".into()))
+ .unwrap(),
+ )
+ .unwrap();
+ assert_eq!(builder.execute().await.unwrap(), 2);
+ builder
+ .with_partition_filter(pb.equal("hr",
crate::spec::Datum::Int(2)).unwrap())
+ .unwrap();
+ assert_eq!(builder.execute().await.unwrap(), 1);
+ builder.with_dry_run(false);
+ assert_eq!(builder.execute().await.unwrap(), 1);
+ let remaining = latest_index_entries(&table).await;
+ assert!(!remaining
+ .iter()
+ .any(|entry| entry.index_file.file_name == "id-1"));
+ assert!(remaining
+ .iter()
+ .any(|entry| entry.index_file.file_name == "id-0"));
+ }
+
+ #[tokio::test]
+ async fn test_drop_global_index_partition_no_match_and_null() {
+ let table =
partitioned_index_table("memory:/drop_partition_no_match").await;
+ let pb = crate::spec::PredicateBuilder::new(table.schema().fields());
+ let before = table_files(&table).await;
+ for dry_run in [true, false] {
+ let mut builder = table.new_global_index_drop_builder();
+ builder.with_index_column("id").with_dry_run(dry_run);
+ builder
+ .with_partition_filter(pb.equal("hr",
crate::spec::Datum::Int(99)).unwrap())
+ .unwrap();
+ assert_eq!(builder.execute().await.unwrap(), 0);
+ assert_eq!(table_files(&table).await, before);
+ }
+ let mut builder = table.new_global_index_drop_builder();
+ builder.with_index_column("id");
+ builder
+ .with_partition_filter(pb.is_null("dt").unwrap())
+ .unwrap();
+ assert_eq!(builder.execute().await.unwrap(), 1);
+ assert!(!latest_index_entries(&table)
+ .await
+ .iter()
+ .any(|entry| entry.index_file.file_name == "id-4"));
+ }
+
+ #[tokio::test]
+ async fn test_drop_global_index_partition_filter_rejects_data_columns() {
+ let table =
partitioned_index_table("memory:/drop_partition_invalid").await;
+ let pb = crate::spec::PredicateBuilder::new(table.schema().fields());
+ let before = table_files(&table).await;
+ for filter in [
+ pb.equal("id", crate::spec::Datum::Int(1)).unwrap(),
+ Predicate::or(vec![pb.is_null("dt").unwrap(),
pb.is_null("name").unwrap()]),
+ ] {
+ assert!(table
+ .new_global_index_drop_builder()
+ .with_partition_filter(filter)
+ .is_err());
+ }
+ assert_eq!(table_files(&table).await, before);
+ let unpartitioned = test_table("memory:/drop_unpartitioned_filter");
+ assert!(unpartitioned
+ .new_global_index_drop_builder()
+ .with_partition_filter(Predicate::AlwaysTrue)
+ .is_err());
+ }
+
+ #[tokio::test]
+ async fn test_drop_global_index_partition_filter_rejects_invalid_rows() {
+ for partition in [vec![0, 0], BinaryRow::new(0).to_serialized_bytes()]
{
+ let table =
partitioned_index_table("memory:/drop_partition_invalid_row").await;
+ let sm = table.snapshot_manager();
+ let snapshot = sm.get_latest_snapshot().await.unwrap().unwrap();
+ let path = sm.manifest_path(snapshot.index_manifest().unwrap());
+ let mut entries = latest_index_entries(&table).await;
+ entries
+ .iter_mut()
+ .find(|entry| entry.index_file.file_name == "id-0")
+ .unwrap()
+ .partition = partition;
+ IndexManifest::write(table.file_io(), &path, &entries)
+ .await
+ .unwrap();
+ let before = table_files(&table).await;
+ for dry_run in [true, false] {
+ let pb =
crate::spec::PredicateBuilder::new(table.schema().fields());
+ let mut builder = table.new_global_index_drop_builder();
+ builder.with_index_column("id").with_dry_run(dry_run);
+ builder
+ .with_partition_filter(pb.is_null("dt").unwrap())
+ .unwrap();
+ assert!(builder.execute().await.is_err());
+ assert_eq!(table_files(&table).await, before);
+ }
+ }
+ }
+
#[tokio::test]
async fn test_drop_global_index_dry_run_preserves_files() {
for index_type in [