mod common;
use std::sync::Arc;
use datafusion::arrow::array::{
Array, BooleanArray, Int32Array, Int64Array, ListArray, StringArray,
};
use datafusion::arrow::datatypes::{DataType, Field, TimeUnit};
use datafusion::arrow::record_batch::RecordBatch;
use paimon::catalog::Identifier;
use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
use paimon_datafusion::SQLContext;
const FIXTURE_TABLE: &str = "test_tantivy_fulltext";
fn extract_test_warehouse() -> (tempfile::TempDir, String) {
let archive_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("testdata/test_tantivy_fulltext.tar.gz");
let file = std::fs::File::open(&archive_path)
.unwrap_or_else(|e| panic!("Failed to open {}: {e}", archive_path.display()));
let decoder = flate2::read::GzDecoder::new(file);
let mut archive = tar::Archive::new(decoder);
let tmp = tempfile::tempdir().expect("Failed to create temp dir");
let db_dir = tmp.path().join("default.db");
std::fs::create_dir_all(&db_dir).unwrap();
archive.unpack(&db_dir).unwrap();
let warehouse = format!("file://{}", tmp.path().display());
(tmp, warehouse)
}
async fn create_context() -> (SQLContext, Arc<dyn Catalog>, tempfile::TempDir) {
let (tmp, warehouse) = extract_test_warehouse();
let mut options = Options::new();
options.set(CatalogOptions::WAREHOUSE, warehouse);
let catalog = FileSystemCatalog::new(options).expect("Failed to create catalog");
let catalog: Arc<dyn Catalog> = Arc::new(catalog);
let mut ctx = SQLContext::new();
ctx.register_catalog("paimon", catalog.clone())
.await
.expect("Failed to register catalog");
(ctx, catalog, tmp)
}
async fn run_sql(ctx: &SQLContext, sql: &str) -> Vec<RecordBatch> {
ctx.sql(sql)
.await
.unwrap_or_else(|e| panic!("Failed to plan `{sql}`: {e}"))
.collect()
.await
.unwrap_or_else(|e| panic!("Failed to execute `{sql}`: {e}"))
}
#[tokio::test]
async fn test_options_system_table() {
let (ctx, catalog, _tmp) = create_context().await;
let sql = format!("SELECT key, value FROM paimon.default.{FIXTURE_TABLE}$options");
let batches = run_sql(&ctx, &sql).await;
assert!(!batches.is_empty(), "$options should return ≥1 batch");
let schema = batches[0].schema();
assert_eq!(schema.field(0).name(), "key");
assert_eq!(schema.field(1).name(), "value");
let mut actual: Vec<(String, String)> = Vec::new();
for batch in &batches {
let keys = batch
.column(0)
.as_any()
.downcast_ref::<StringArray>()
.expect("key column is Utf8");
let values = batch
.column(1)
.as_any()
.downcast_ref::<StringArray>()
.expect("value column is Utf8");
for i in 0..batch.num_rows() {
actual.push((keys.value(i).to_string(), values.value(i).to_string()));
}
}
actual.sort();
let identifier = Identifier::new("default".to_string(), FIXTURE_TABLE.to_string());
let table = catalog
.get_table(&identifier)
.await
.expect("fixture table should load");
let mut expected: Vec<(String, String)> = table
.schema()
.options()
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
expected.sort();
assert_eq!(actual, expected, "$options rows should match table options");
}
#[tokio::test]
async fn test_table_indexes_system_table() {
let (ctx, catalog, _tmp) = create_context().await;
let sql = format!("SELECT * FROM paimon.default.{FIXTURE_TABLE}$table_indexes");
let batches = run_sql(&ctx, &sql).await;
assert!(!batches.is_empty(), "$table_indexes should return ≥1 batch");
let dv_meta_fields = vec![
Arc::new(Field::new("f0", DataType::Utf8, false)),
Arc::new(Field::new("f1", DataType::Int32, false)),
Arc::new(Field::new("f2", DataType::Int32, false)),
Arc::new(Field::new("_CARDINALITY", DataType::Int64, true)),
]
.into();
let expected_columns = [
("partition", DataType::Utf8),
("bucket", DataType::Int32),
("index_type", DataType::Utf8),
("file_name", DataType::Utf8),
("file_size", DataType::Int64),
("row_count", DataType::Int64),
(
"dv_ranges",
DataType::List(Arc::new(Field::new(
"item",
DataType::Struct(dv_meta_fields),
true,
))),
),
("row_range_start", DataType::Int64),
("row_range_end", DataType::Int64),
("index_field_id", DataType::Int32),
("index_field_name", DataType::Utf8),
];
let arrow_schema = batches[0].schema();
for (i, (name, dtype)) in expected_columns.iter().enumerate() {
let field = arrow_schema.field(i);
assert_eq!(field.name(), name, "column {i} name");
assert_eq!(field.data_type(), dtype, "column {i} type");
}
let identifier = Identifier::new("default".to_string(), FIXTURE_TABLE.to_string());
let table = catalog
.get_table(&identifier)
.await
.expect("fixture table should load");
let sm =
paimon::table::SnapshotManager::new(table.file_io().clone(), table.location().to_string());
let latest = sm
.get_latest_snapshot()
.await
.unwrap()
.expect("fixture has snapshots");
let index_manifest = latest
.index_manifest()
.expect("fixture should have an index manifest");
let expected_entries =
paimon::spec::IndexManifest::read(table.file_io(), &sm.manifest_path(index_manifest))
.await
.unwrap()
.into_iter()
.filter(|entry| entry.kind == paimon::spec::FileKind::Add)
.collect::<Vec<_>>();
let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(
total_rows,
expected_entries.len(),
"$table_indexes rows should match the latest index manifest entries"
);
let batch = &batches[0];
let partitions = batch
.column(0)
.as_any()
.downcast_ref::<StringArray>()
.expect("partition is Utf8");
let buckets = batch
.column(1)
.as_any()
.downcast_ref::<Int32Array>()
.expect("bucket is Int32");
let index_types = batch
.column(2)
.as_any()
.downcast_ref::<StringArray>()
.expect("index_type is Utf8");
let file_names = batch
.column(3)
.as_any()
.downcast_ref::<StringArray>()
.expect("file_name is Utf8");
let file_sizes = batch
.column(4)
.as_any()
.downcast_ref::<Int64Array>()
.expect("file_size is Int64");
let row_counts = batch
.column(5)
.as_any()
.downcast_ref::<Int64Array>()
.expect("row_count is Int64");
let dv_ranges = batch
.column(6)
.as_any()
.downcast_ref::<ListArray>()
.expect("dv_ranges is ListArray");
let row_range_starts = batch
.column(7)
.as_any()
.downcast_ref::<Int64Array>()
.expect("row_range_start is Int64");
let row_range_ends = batch
.column(8)
.as_any()
.downcast_ref::<Int64Array>()
.expect("row_range_end is Int64");
let index_field_ids = batch
.column(9)
.as_any()
.downcast_ref::<Int32Array>()
.expect("index_field_id is Int32");
let index_field_names = batch
.column(10)
.as_any()
.downcast_ref::<StringArray>()
.expect("index_field_name is Utf8");
for (row, expected) in expected_entries.iter().enumerate() {
assert!(!partitions.is_null(row), "partition should be non-null");
assert_eq!(buckets.value(row), expected.bucket);
assert_eq!(index_types.value(row), expected.index_file.index_type);
assert_eq!(file_names.value(row), expected.index_file.file_name);
assert_eq!(
file_sizes.value(row),
i64::from(expected.index_file.file_size)
);
assert_eq!(
row_counts.value(row),
i64::from(expected.index_file.row_count)
);
assert_eq!(
dv_ranges.is_null(row),
expected.index_file.deletion_vectors_ranges.is_none()
);
if let Some(global_meta) = &expected.index_file.global_index_meta {
assert_eq!(row_range_starts.value(row), global_meta.row_range_start);
assert_eq!(row_range_ends.value(row), global_meta.row_range_end);
assert_eq!(index_field_ids.value(row), global_meta.index_field_id);
let expected_field_name = table
.schema()
.fields()
.iter()
.find(|field| field.id() == global_meta.index_field_id)
.map(|field| field.name());
assert_eq!(
(!index_field_names.is_null(row)).then(|| index_field_names.value(row)),
expected_field_name
);
} else {
assert!(row_range_starts.is_null(row));
assert!(row_range_ends.is_null(row));
assert!(index_field_ids.is_null(row));
assert!(index_field_names.is_null(row));
}
}
}
#[tokio::test]
async fn test_unknown_system_table_name_returns_not_found() {
let (ctx, _catalog, _tmp) = create_context().await;
let sql = format!("SELECT * FROM paimon.default.{FIXTURE_TABLE}$nonsense");
let err = ctx
.sql(&sql)
.await
.expect_err("unknown system table should not resolve");
let msg = err.to_string();
assert!(
msg.contains("nonsense") || msg.to_lowercase().contains("not found"),
"unexpected error for unknown system table: {msg}"
);
}
#[tokio::test]
async fn test_schemas_system_table() {
let (ctx, catalog, _tmp) = create_context().await;
let sql = format!("SELECT * FROM paimon.default.{FIXTURE_TABLE}$schemas");
let batches = run_sql(&ctx, &sql).await;
assert!(!batches.is_empty(), "$schemas should return ≥1 batch");
let arrow_schema = batches[0].schema();
let expected_columns = [
("schema_id", DataType::Int64),
("fields", DataType::Utf8),
("partition_keys", DataType::Utf8),
("primary_keys", DataType::Utf8),
("options", DataType::Utf8),
("comment", DataType::Utf8),
(
"update_time",
DataType::Timestamp(TimeUnit::Millisecond, None),
),
];
for (i, (name, dtype)) in expected_columns.iter().enumerate() {
let field = arrow_schema.field(i);
assert_eq!(field.name(), name, "column {i} name");
assert_eq!(field.data_type(), dtype, "column {i} type");
}
let identifier = Identifier::new("default".to_string(), FIXTURE_TABLE.to_string());
let table = catalog
.get_table(&identifier)
.await
.expect("fixture table should load");
let all_schemas = table
.schema_manager()
.list_all()
.await
.expect("list_all should succeed");
let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(
total_rows,
all_schemas.len(),
"$schemas rows should match list_all() length"
);
let mut ids: Vec<i64> = Vec::new();
for batch in &batches {
let col = batch
.column(0)
.as_any()
.downcast_ref::<Int64Array>()
.expect("schema_id is Int64");
for i in 0..batch.num_rows() {
ids.push(col.value(i));
}
}
let mut sorted = ids.clone();
sorted.sort_unstable();
assert_eq!(ids, sorted, "schema_id column should be ascending");
let last_batch = batches.last().unwrap();
let last_idx = last_batch.num_rows() - 1;
let latest = table.schema();
let json_columns: [(usize, &str, String); 4] = [
(
1,
"fields",
serde_json::to_string(latest.fields()).expect("serialize fields"),
),
(
2,
"partition_keys",
serde_json::to_string(latest.partition_keys()).expect("serialize partition_keys"),
),
(
3,
"primary_keys",
serde_json::to_string(latest.primary_keys()).expect("serialize primary_keys"),
),
(
4,
"options",
serde_json::to_string(latest.options()).expect("serialize options"),
),
];
for (col_idx, col_name, expected) in &json_columns {
let col = last_batch
.column(*col_idx)
.as_any()
.downcast_ref::<StringArray>()
.unwrap_or_else(|| panic!("column {col_name} is not Utf8"));
let actual: serde_json::Value = serde_json::from_str(col.value(last_idx))
.unwrap_or_else(|e| panic!("parse actual `{col_name}`: {e}"));
let expected: serde_json::Value = serde_json::from_str(expected)
.unwrap_or_else(|e| panic!("parse expected `{col_name}`: {e}"));
assert_eq!(
actual, expected,
"latest-row `{col_name}` JSON should round-trip"
);
}
}
#[tokio::test]
async fn test_missing_base_table_for_system_table_errors() {
let (ctx, _catalog, _tmp) = create_context().await;
let sql = "SELECT * FROM paimon.default.does_not_exist$options";
let err = ctx
.sql(sql)
.await
.expect_err("missing base table should error");
let msg = err.to_string();
assert!(
msg.contains("does_not_exist") && msg.contains("$options"),
"expected error to mention both base table and system name, got: {msg}"
);
}
#[tokio::test]
async fn test_snapshots_system_table() {
let (ctx, catalog, _tmp) = create_context().await;
let sql = format!("SELECT * FROM paimon.default.{FIXTURE_TABLE}$snapshots");
let batches = run_sql(&ctx, &sql).await;
assert!(!batches.is_empty(), "$snapshots should return ≥1 batch");
let arrow_schema = batches[0].schema();
let expected_columns = [
("snapshot_id", DataType::Int64),
("schema_id", DataType::Int64),
("commit_user", DataType::Utf8),
("commit_identifier", DataType::Int64),
("commit_kind", DataType::Utf8),
(
"commit_time",
DataType::Timestamp(TimeUnit::Millisecond, None),
),
("base_manifest_list", DataType::Utf8),
("delta_manifest_list", DataType::Utf8),
("changelog_manifest_list", DataType::Utf8),
("total_record_count", DataType::Int64),
("delta_record_count", DataType::Int64),
("changelog_record_count", DataType::Int64),
("watermark", DataType::Int64),
("next_row_id", DataType::Int64),
];
for (i, (name, dtype)) in expected_columns.iter().enumerate() {
let field = arrow_schema.field(i);
assert_eq!(field.name(), name, "column {i} name");
assert_eq!(field.data_type(), dtype, "column {i} type");
}
let identifier = Identifier::new("default".to_string(), FIXTURE_TABLE.to_string());
let table = catalog
.get_table(&identifier)
.await
.expect("fixture table should load");
let sm =
paimon::table::SnapshotManager::new(table.file_io().clone(), table.location().to_string());
let all = sm.list_all().await.expect("list_all should succeed");
let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(
total_rows,
all.len(),
"$snapshots rows should match list_all() length"
);
let mut ids: Vec<i64> = Vec::new();
for batch in &batches {
let col = batch
.column(0)
.as_any()
.downcast_ref::<Int64Array>()
.expect("snapshot_id is Int64");
for i in 0..batch.num_rows() {
ids.push(col.value(i));
}
}
let mut sorted = ids.clone();
sorted.sort_unstable();
assert_eq!(ids, sorted, "snapshot_id should be ascending");
let last_batch = batches.last().unwrap();
let kind_col = last_batch
.column(4)
.as_any()
.downcast_ref::<StringArray>()
.expect("commit_kind is Utf8");
let kind = kind_col.value(last_batch.num_rows() - 1);
assert!(
["APPEND", "COMPACT", "OVERWRITE", "ANALYZE"].contains(&kind),
"unexpected commit_kind: {kind}"
);
}
#[tokio::test]
async fn test_branches_system_table_empty_when_no_branch_dir() {
let (ctx, _catalog, _tmp) = create_context().await;
let sql = format!("SELECT * FROM paimon.default.{FIXTURE_TABLE}$branches");
let batches = run_sql(&ctx, &sql).await;
assert!(!batches.is_empty(), "$branches should return ≥1 batch");
let arrow_schema = batches[0].schema();
let expected_columns = [
("branch_name", DataType::Utf8),
(
"create_time",
DataType::Timestamp(TimeUnit::Millisecond, None),
),
];
for (i, (name, dtype)) in expected_columns.iter().enumerate() {
let field = arrow_schema.field(i);
assert_eq!(field.name(), name, "column {i} name");
assert_eq!(field.data_type(), dtype, "column {i} type");
}
let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(total_rows, 0, "fixture has no branch dir, expected 0 rows");
}
#[tokio::test]
async fn test_branches_system_table_with_seeded_branches() {
let (ctx, _catalog, tmp) = create_context().await;
let table_dir = tmp.path().join("default.db").join(FIXTURE_TABLE);
let branch_dir = table_dir.join("branch");
std::fs::create_dir_all(&branch_dir).expect("create branch dir");
std::fs::create_dir_all(branch_dir.join("branch-b1")).unwrap();
std::fs::create_dir_all(branch_dir.join("branch-b2")).unwrap();
let sql = format!("SELECT branch_name FROM paimon.default.{FIXTURE_TABLE}$branches");
let batches = run_sql(&ctx, &sql).await;
let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(total_rows, 2, "expected two seeded branches");
let mut names: Vec<String> = Vec::new();
for batch in &batches {
let names_col = batch
.column(0)
.as_any()
.downcast_ref::<StringArray>()
.expect("branch_name is Utf8");
for i in 0..batch.num_rows() {
names.push(names_col.value(i).to_string());
}
}
let mut sorted_names = names.clone();
sorted_names.sort();
assert_eq!(names, sorted_names, "branch_name should be ascending");
assert_eq!(names, vec!["b1".to_string(), "b2".to_string()]);
}
#[tokio::test]
async fn test_tags_system_table_empty_when_no_tag_dir() {
let (ctx, _catalog, _tmp) = create_context().await;
let sql = format!("SELECT * FROM paimon.default.{FIXTURE_TABLE}$tags");
let batches = run_sql(&ctx, &sql).await;
assert!(!batches.is_empty(), "$tags should return ≥1 batch");
let arrow_schema = batches[0].schema();
let expected_columns = [
("tag_name", DataType::Utf8),
("snapshot_id", DataType::Int64),
("schema_id", DataType::Int64),
(
"commit_time",
DataType::Timestamp(TimeUnit::Millisecond, None),
),
("record_count", DataType::Int64),
(
"create_time",
DataType::Timestamp(TimeUnit::Millisecond, None),
),
("time_retained", DataType::Utf8),
];
for (i, (name, dtype)) in expected_columns.iter().enumerate() {
let field = arrow_schema.field(i);
assert_eq!(field.name(), name, "column {i} name");
assert_eq!(field.data_type(), dtype, "column {i} type");
}
let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(total_rows, 0, "fixture has no tag dir, expected 0 rows");
}
#[tokio::test]
async fn test_tags_system_table_with_seeded_tags() {
let (ctx, catalog, tmp) = create_context().await;
let identifier = Identifier::new("default".to_string(), FIXTURE_TABLE.to_string());
let table = catalog.get_table(&identifier).await.unwrap();
let sm =
paimon::table::SnapshotManager::new(table.file_io().clone(), table.location().to_string());
let earliest = sm.list_all().await.unwrap().into_iter().next().unwrap();
let table_dir = tmp.path().join("default.db").join(FIXTURE_TABLE);
let tag_dir = table_dir.join("tag");
std::fs::create_dir_all(&tag_dir).expect("create tag dir");
let src = table_dir
.join("snapshot")
.join(format!("snapshot-{}", earliest.id()));
std::fs::copy(&src, tag_dir.join("tag-v1")).unwrap();
std::fs::copy(&src, tag_dir.join("tag-v2")).unwrap();
let sql = format!("SELECT tag_name, snapshot_id FROM paimon.default.{FIXTURE_TABLE}$tags");
let batches = run_sql(&ctx, &sql).await;
let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(total_rows, 2, "expected two seeded tags");
let mut names: Vec<String> = Vec::new();
let mut snap_ids: Vec<i64> = Vec::new();
for batch in &batches {
let names_col = batch
.column(0)
.as_any()
.downcast_ref::<StringArray>()
.expect("tag_name is Utf8");
let snap_col = batch
.column(1)
.as_any()
.downcast_ref::<Int64Array>()
.expect("snapshot_id is Int64");
for i in 0..batch.num_rows() {
names.push(names_col.value(i).to_string());
snap_ids.push(snap_col.value(i));
}
}
let mut sorted_names = names.clone();
sorted_names.sort();
assert_eq!(names, sorted_names, "tag_name should be ascending");
assert_eq!(names, vec!["v1".to_string(), "v2".to_string()]);
assert_eq!(snap_ids, vec![earliest.id(), earliest.id()]);
}
#[tokio::test]
async fn test_manifests_system_table() {
let (ctx, catalog, _tmp) = create_context().await;
let sql = format!("SELECT * FROM paimon.default.{FIXTURE_TABLE}$manifests");
let batches = run_sql(&ctx, &sql).await;
assert!(!batches.is_empty(), "$manifests should return ≥1 batch");
let arrow_schema = batches[0].schema();
let expected_columns = [
("file_name", DataType::Utf8),
("file_size", DataType::Int64),
("num_added_files", DataType::Int64),
("num_deleted_files", DataType::Int64),
("schema_id", DataType::Int64),
("min_partition_stats", DataType::Utf8),
("max_partition_stats", DataType::Utf8),
("min_row_id", DataType::Int64),
("max_row_id", DataType::Int64),
];
for (i, (name, dtype)) in expected_columns.iter().enumerate() {
let field = arrow_schema.field(i);
assert_eq!(field.name(), name, "column {i} name");
assert_eq!(field.data_type(), dtype, "column {i} type");
}
let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
assert!(total_rows > 0, "fixture should have at least one manifest");
for batch in &batches {
let names = batch
.column(0)
.as_any()
.downcast_ref::<StringArray>()
.expect("file_name is Utf8");
let sizes = batch
.column(1)
.as_any()
.downcast_ref::<Int64Array>()
.expect("file_size is Int64");
for i in 0..batch.num_rows() {
assert!(!names.value(i).is_empty(), "file_name must be non-empty");
assert!(sizes.value(i) >= 0, "file_size must be non-negative");
}
}
let identifier = Identifier::new("default".to_string(), FIXTURE_TABLE.to_string());
let table = catalog.get_table(&identifier).await.unwrap();
let sm =
paimon::table::SnapshotManager::new(table.file_io().clone(), table.location().to_string());
let latest = sm
.get_latest_snapshot()
.await
.unwrap()
.expect("fixture has snapshots");
let mut expected = paimon::spec::ManifestList::read(
table.file_io(),
&sm.manifest_path(latest.base_manifest_list()),
)
.await
.unwrap()
.len();
expected += paimon::spec::ManifestList::read(
table.file_io(),
&sm.manifest_path(latest.delta_manifest_list()),
)
.await
.unwrap()
.len();
if let Some(changelog) = latest.changelog_manifest_list() {
expected += paimon::spec::ManifestList::read(table.file_io(), &sm.manifest_path(changelog))
.await
.unwrap()
.len();
}
assert_eq!(
total_rows, expected,
"$manifests rows should match base + delta + changelog manifest entries of the latest snapshot"
);
}
#[tokio::test]
async fn test_manifests_system_table_partition_stats() {
let (_tmp, sql_context) = common::setup_sql_context().await;
common::exec(
&sql_context,
"CREATE TABLE paimon.test_db.manifest_stats (id INT, pt INT) PARTITIONED BY (pt)",
)
.await;
common::exec(
&sql_context,
"INSERT INTO paimon.test_db.manifest_stats VALUES (1, 1), (2, 2)",
)
.await;
let batches = sql_context
.sql(
"SELECT min_partition_stats, max_partition_stats \
FROM paimon.test_db.manifest_stats$manifests",
)
.await
.expect("$manifests query should plan")
.collect()
.await
.expect("$manifests query should execute");
let mut stats = Vec::new();
for batch in &batches {
let mins = batch
.column(0)
.as_any()
.downcast_ref::<StringArray>()
.expect("min_partition_stats is Utf8");
let maxs = batch
.column(1)
.as_any()
.downcast_ref::<StringArray>()
.expect("max_partition_stats is Utf8");
for row in 0..batch.num_rows() {
stats.push((
(!mins.is_null(row)).then(|| mins.value(row).to_string()),
(!maxs.is_null(row)).then(|| maxs.value(row).to_string()),
));
}
}
stats.sort();
assert!(
!stats.is_empty(),
"$manifests should return partition stats"
);
let min_partition = stats
.iter()
.filter_map(|(min, _)| min.as_deref())
.map(single_int_partition_stat)
.min();
let max_partition = stats
.iter()
.filter_map(|(_, max)| max.as_deref())
.map(single_int_partition_stat)
.max();
assert_eq!(min_partition, Some(1));
assert_eq!(max_partition, Some(2));
}
#[tokio::test]
async fn test_files_system_table() {
let (ctx, catalog, _tmp) = create_context().await;
let sql = format!("SELECT * FROM paimon.default.{FIXTURE_TABLE}$files");
let batches = run_sql(&ctx, &sql).await;
assert!(
!batches.is_empty(),
"$files should return at least one batch"
);
let arrow_schema = batches[0].schema();
let expected_columns = [
("partition", DataType::Utf8),
("bucket", DataType::Int32),
("file_path", DataType::Utf8),
("file_format", DataType::Utf8),
("schema_id", DataType::Int64),
("level", DataType::Int32),
("record_count", DataType::Int64),
("file_size_in_bytes", DataType::Int64),
("min_key", DataType::Utf8),
("max_key", DataType::Utf8),
("null_value_counts", DataType::Utf8),
("min_value_stats", DataType::Utf8),
("max_value_stats", DataType::Utf8),
("min_sequence_number", DataType::Int64),
("max_sequence_number", DataType::Int64),
(
"creation_time",
DataType::Timestamp(TimeUnit::Millisecond, None),
),
("delete_row_count", DataType::Int64),
("file_source", DataType::Utf8),
("first_row_id", DataType::Int64),
(
"write_cols",
DataType::List(Arc::new(Field::new("item", DataType::Utf8, true))),
),
];
for (i, (name, dtype)) in expected_columns.iter().enumerate() {
let field = arrow_schema.field(i);
assert_eq!(field.name(), name, "column {i} name");
assert_eq!(field.data_type(), dtype, "column {i} type");
}
let identifier = Identifier::new("default".to_string(), FIXTURE_TABLE.to_string());
let table = catalog.get_table(&identifier).await.unwrap();
let plan = table
.new_read_builder()
.new_scan()
.with_scan_all_files()
.plan()
.await
.unwrap();
let expected_rows: usize = plan
.splits()
.iter()
.map(|split| split.data_files().len())
.sum();
let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(total_rows, expected_rows);
assert!(total_rows > 0, "fixture should contain data files");
for batch in &batches {
let paths = batch
.column(2)
.as_any()
.downcast_ref::<StringArray>()
.expect("file_path is Utf8");
let formats = batch
.column(3)
.as_any()
.downcast_ref::<StringArray>()
.expect("file_format is Utf8");
let null_counts = batch
.column(10)
.as_any()
.downcast_ref::<StringArray>()
.expect("null_value_counts is Utf8");
for i in 0..batch.num_rows() {
assert!(!paths.value(i).is_empty(), "file_path must be non-empty");
assert!(
!formats.value(i).is_empty(),
"file_format must be non-empty"
);
assert!(
null_counts.value(i).starts_with('{') && null_counts.value(i).ends_with('}'),
"null_value_counts should be Java-map-like"
);
}
}
}
fn single_int_partition_stat(value: &str) -> i32 {
value
.strip_prefix('{')
.and_then(|s| s.strip_suffix('}'))
.expect("partition stats should use row cast braces")
.parse()
.expect("partition stats should contain one int partition value")
}
#[tokio::test]
async fn test_partitions_system_table() {
let (ctx, _catalog, _tmp) = create_context().await;
let sql = format!("SELECT * FROM paimon.default.{FIXTURE_TABLE}$partitions");
let batches = run_sql(&ctx, &sql).await;
assert!(!batches.is_empty(), "$partitions should return ≥1 batch");
let arrow_schema = batches[0].schema();
let expected_columns = [
("partition", DataType::Utf8),
("record_count", DataType::Int64),
("file_size_in_bytes", DataType::Int64),
("file_count", DataType::Int64),
(
"last_update_time",
DataType::Timestamp(TimeUnit::Millisecond, None),
),
(
"created_at",
DataType::Timestamp(TimeUnit::Millisecond, None),
),
("created_by", DataType::Utf8),
("updated_by", DataType::Utf8),
("options", DataType::Utf8),
("total_buckets", DataType::Int32),
("done", DataType::Boolean),
];
for (i, (name, dtype)) in expected_columns.iter().enumerate() {
let field = arrow_schema.field(i);
assert_eq!(field.name(), name, "column {i} name");
assert_eq!(field.data_type(), dtype, "column {i} type");
}
let mut partition_strings: Vec<Option<String>> = Vec::new();
let mut record_counts: Vec<i64> = Vec::new();
let mut file_counts: Vec<i64> = Vec::new();
let mut file_sizes: Vec<i64> = Vec::new();
for batch in &batches {
let parts = batch
.column(0)
.as_any()
.downcast_ref::<StringArray>()
.expect("partition is Utf8");
let rc = batch
.column(1)
.as_any()
.downcast_ref::<Int64Array>()
.expect("record_count is Int64");
let sz = batch
.column(2)
.as_any()
.downcast_ref::<Int64Array>()
.expect("file_size_in_bytes is Int64");
let fc = batch
.column(3)
.as_any()
.downcast_ref::<Int64Array>()
.expect("file_count is Int64");
for i in 0..batch.num_rows() {
partition_strings.push(if parts.is_null(i) {
None
} else {
Some(parts.value(i).to_string())
});
record_counts.push(rc.value(i));
file_sizes.push(sz.value(i));
file_counts.push(fc.value(i));
}
}
for fc in &file_counts {
assert!(*fc >= 1, "file_count must be ≥ 1 per partition");
}
for sz in &file_sizes {
assert!(*sz >= 0, "file_size_in_bytes must be non-negative");
}
for rc in &record_counts {
assert!(*rc >= 0, "record_count must be non-negative");
}
let mut sorted = partition_strings.clone();
sorted.sort();
assert_eq!(
partition_strings, sorted,
"rows should be sorted by partition string"
);
for batch in &batches {
for col_idx in 5..=8 {
for i in 0..batch.num_rows() {
assert!(
batch.column(col_idx).is_null(i),
"column {col_idx} row {i} should be null"
);
}
}
}
for batch in &batches {
let tb = batch
.column(9)
.as_any()
.downcast_ref::<Int32Array>()
.expect("total_buckets is Int32");
let done = batch
.column(10)
.as_any()
.downcast_ref::<BooleanArray>()
.expect("done is Boolean");
for i in 0..batch.num_rows() {
assert_eq!(tb.value(i), 0, "total_buckets default for FS catalog");
assert!(!done.value(i), "done default for FS catalog");
}
}
}