use std::collections::HashMap;
use std::sync::Arc;
mod common;
use common::string_value;
use datafusion::arrow::array::{Array, Int32Array};
use datafusion::arrow::record_batch::RecordBatch;
use datafusion::arrow::util::display::array_value_to_string;
use datafusion::catalog::CatalogProvider;
use datafusion::datasource::TableProvider;
use datafusion::logical_expr::{col, lit, TableProviderFilterPushDown};
use datafusion::physical_plan::{displayable, ExecutionPlan};
use datafusion::prelude::SessionConfig;
use paimon::catalog::Identifier;
use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
use paimon_datafusion::{PaimonCatalogProvider, PaimonTableProvider, SQLContext};
fn get_test_warehouse() -> String {
std::env::var("PAIMON_TEST_WAREHOUSE").unwrap_or_else(|_| "/tmp/paimon-warehouse".to_string())
}
fn create_catalog() -> FileSystemCatalog {
let warehouse = get_test_warehouse();
let mut options = Options::new();
options.set(CatalogOptions::WAREHOUSE, warehouse);
FileSystemCatalog::new(options).expect("Failed to create catalog")
}
async fn create_context() -> SQLContext {
let catalog = create_catalog();
let catalog: Arc<dyn Catalog> = Arc::new(catalog);
let mut ctx = SQLContext::new();
ctx.register_catalog("paimon", catalog)
.await
.expect("Failed to register catalog");
ctx
}
async fn create_provider(table_name: &str) -> PaimonTableProvider {
let catalog = create_catalog();
let identifier = Identifier::new("default", table_name);
let table = catalog
.get_table(&identifier)
.await
.expect("Failed to get table");
PaimonTableProvider::try_new(table).expect("Failed to create table provider")
}
async fn create_provider_with_options(
table_name: &str,
extra_options: HashMap<String, String>,
) -> PaimonTableProvider {
let catalog = create_catalog();
let identifier = Identifier::new("default", table_name);
let table = catalog
.get_table(&identifier)
.await
.expect("Failed to get table")
.copy_with_options(extra_options);
PaimonTableProvider::try_new(table).expect("Failed to create table provider")
}
async fn read_rows(table_name: &str) -> Vec<(i32, String)> {
let sql = format!("SELECT id, name FROM paimon.default.{table_name}");
let batches = collect_query(&sql)
.await
.expect("Failed to collect query result");
assert!(
!batches.is_empty(),
"Expected at least one batch from table {table_name}"
);
let mut actual_rows = extract_id_name_rows(&batches);
actual_rows.sort_by_key(|(id, _)| *id);
actual_rows
}
async fn collect_query(sql: &str) -> datafusion::error::Result<Vec<RecordBatch>> {
let ctx = create_context().await;
ctx.sql(sql).await?.collect().await
}
fn collect_rows(batches: &[RecordBatch]) -> Vec<Vec<String>> {
let mut rows = Vec::new();
for batch in batches {
for row_index in 0..batch.num_rows() {
let mut row = Vec::with_capacity(batch.num_columns());
for column in batch.columns() {
if column.is_null(row_index) {
row.push("NULL".to_string());
} else {
row.push(
array_value_to_string(column.as_ref(), row_index)
.expect("query result value should format"),
);
}
}
rows.push(row);
}
}
rows
}
async fn assert_sql_rows(sql: &str, expected: &[&[&str]]) {
let batches = collect_query(sql).await.expect("query should succeed");
let actual = collect_rows(&batches);
let expected: Vec<Vec<String>> = expected
.iter()
.map(|row| row.iter().map(|value| value.to_string()).collect())
.collect();
assert_eq!(actual, expected, "unexpected result for SQL: {sql}");
}
async fn assert_time_travel_schema_evolution_rows(
tag: &str,
select_list: &str,
where_clause: Option<&str>,
expected: &[&[&str]],
) {
let sql = match where_clause {
Some(where_clause) => format!(
"SELECT {select_list} FROM paimon.default.time_travel_schema_evolution VERSION AS OF '{tag}' WHERE {where_clause} ORDER BY id"
),
None => format!(
"SELECT {select_list} FROM paimon.default.time_travel_schema_evolution VERSION AS OF '{tag}' ORDER BY id"
),
};
assert_sql_rows(&sql, expected).await;
}
async fn create_physical_plan(sql: &str) -> datafusion::error::Result<Arc<dyn ExecutionPlan>> {
let ctx = create_context().await;
ctx.sql(sql).await?.create_physical_plan().await
}
fn extract_id_name_rows(
batches: &[datafusion::arrow::record_batch::RecordBatch],
) -> Vec<(i32, String)> {
let mut rows = Vec::new();
for batch in batches {
let id_array = batch
.column_by_name("id")
.and_then(|column| column.as_any().downcast_ref::<Int32Array>())
.expect("Expected Int32Array for id column");
let name_array = batch
.column_by_name("name")
.expect("Expected string array for name column");
for row_index in 0..batch.num_rows() {
rows.push((
id_array.value(row_index),
string_value(name_array.as_ref(), row_index).to_string(),
));
}
}
rows
}
fn format_physical_plan(plan: &Arc<dyn ExecutionPlan>) -> String {
displayable(plan.as_ref()).indent(true).to_string()
}
fn paimon_scan_lines(plan_text: &str) -> Vec<&str> {
plan_text
.lines()
.filter(|line| line.contains("PaimonTableScan:"))
.collect()
}
#[tokio::test]
async fn test_read_log_table_via_datafusion() {
let actual_rows = read_rows("simple_log_table").await;
let expected_rows = vec![
(1, "alice".to_string()),
(2, "bob".to_string()),
(3, "carol".to_string()),
];
assert_eq!(
actual_rows, expected_rows,
"Rows should match expected values"
);
}
#[tokio::test]
async fn test_read_primary_key_table_via_datafusion() {
let actual_rows = read_rows("simple_dv_pk_table").await;
let expected_rows = vec![
(1, "alice-v2".to_string()),
(2, "bob-v2".to_string()),
(3, "carol-v2".to_string()),
(4, "dave-v2".to_string()),
(5, "eve-v2".to_string()),
(6, "frank-v1".to_string()),
];
assert_eq!(
actual_rows, expected_rows,
"Primary key table rows should match expected values"
);
}
#[tokio::test]
async fn test_projection_via_datafusion() {
let batches = collect_query("SELECT id FROM paimon.default.simple_log_table")
.await
.expect("Subset projection should succeed");
assert!(
!batches.is_empty(),
"Expected at least one batch from projected query"
);
let mut actual_ids = Vec::new();
for batch in &batches {
let schema = batch.schema();
let field_names: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect();
assert_eq!(
field_names,
vec!["id"],
"Projected query should only return 'id' column"
);
let id_array = batch
.column_by_name("id")
.and_then(|col| col.as_any().downcast_ref::<Int32Array>())
.expect("Expected Int32Array for id column");
for i in 0..id_array.len() {
actual_ids.push(id_array.value(i));
}
}
actual_ids.sort();
assert_eq!(
actual_ids,
vec![1, 2, 3],
"Projected id values should match"
);
}
#[tokio::test]
async fn test_supports_partition_filters_pushdown() {
let provider = create_provider("multi_partitioned_log_table").await;
let partition_filter = col("dt").eq(lit("2024-01-01"));
let mixed_and_filter = col("dt").eq(lit("2024-01-01")).and(col("id").gt(lit(1)));
let data_filter = col("id").gt(lit(1));
let supports = provider
.supports_filters_pushdown(&[&partition_filter, &mixed_and_filter, &data_filter])
.expect("supports_filters_pushdown should succeed");
assert_eq!(
supports,
vec![
TableProviderFilterPushDown::Exact,
TableProviderFilterPushDown::Inexact,
TableProviderFilterPushDown::Inexact,
]
);
}
#[tokio::test]
async fn test_scan_partition_count_respects_session_config() {
let provider = create_provider("partitioned_log_table").await;
let config = SessionConfig::new().with_target_partitions(8);
let ctx = datafusion::prelude::SessionContext::new_with_config(config);
let state = ctx.state();
let plan = provider
.scan(&state, None, &[], None)
.await
.expect("scan() should succeed");
let partition_count = plan.properties().output_partitioning().partition_count();
assert!(
partition_count > 1,
"partitioned_log_table should produce >1 partitions, got {partition_count}"
);
let config_single = SessionConfig::new().with_target_partitions(1);
let ctx_single = datafusion::prelude::SessionContext::new_with_config(config_single);
let state_single = ctx_single.state();
let plan_single = provider
.scan(&state_single, None, &[], None)
.await
.expect("scan() should succeed with target_partitions=1");
assert_eq!(
plan_single
.properties()
.output_partitioning()
.partition_count(),
1,
"target_partitions=1 should coalesce all splits into exactly 1 partition"
);
}
#[tokio::test]
async fn test_partition_filter_query_via_datafusion() {
let batches = collect_query(
"SELECT id, name FROM paimon.default.partitioned_log_table WHERE dt = '2024-01-01'",
)
.await
.expect("Partition filter query should succeed");
let mut actual_rows = extract_id_name_rows(&batches);
actual_rows.sort_by_key(|(id, _)| *id);
assert_eq!(
actual_rows,
vec![(1, "alice".to_string()), (2, "bob".to_string())]
);
}
#[tokio::test]
async fn test_multi_partition_filter_query_via_datafusion() {
let batches = collect_query(
"SELECT id, name FROM paimon.default.multi_partitioned_log_table WHERE dt = '2024-01-01' AND hr = 10",
)
.await
.expect("Multi-partition filter query should succeed");
let mut actual_rows = extract_id_name_rows(&batches);
actual_rows.sort_by_key(|(id, _)| *id);
assert_eq!(
actual_rows,
vec![(1, "alice".to_string()), (2, "bob".to_string())]
);
}
#[tokio::test]
async fn test_mixed_and_filter_keeps_residual_datafusion_filter() {
let batches = collect_query(
"SELECT id, name FROM paimon.default.partitioned_log_table WHERE dt = '2024-01-01' AND id > 1",
)
.await
.expect("Mixed filter query should succeed");
let actual_rows = extract_id_name_rows(&batches);
assert_eq!(actual_rows, vec![(2, "bob".to_string())]);
}
#[tokio::test]
async fn test_partially_translated_filter_keeps_partition_pruning_and_correctness() {
let sql = "SELECT id, name FROM paimon.default.multi_partitioned_log_table WHERE dt = '2024-01-01' AND hr + 1 > 20 LIMIT 1";
let plan = create_physical_plan(sql)
.await
.expect("Physical plan creation should succeed");
let plan_text = format_physical_plan(&plan);
let scan_lines = paimon_scan_lines(&plan_text);
assert!(
!scan_lines.is_empty(),
"plan should contain a PaimonTableScan, plan:\n{plan_text}"
);
assert!(
scan_lines
.iter()
.any(|line| line.contains("predicate=dt = '2024-01-01'")),
"The translated partition predicate should still be pushed into PaimonTableScan, plan:\n{plan_text}"
);
assert!(
scan_lines.iter().all(|line| !line.contains("fetch=")),
"Partially translated filters should not revive the removed fetch contract, plan:\n{plan_text}"
);
let batches = collect_query(sql)
.await
.expect("Partially translated filter + LIMIT query should succeed");
let rows = extract_id_name_rows(&batches);
assert_eq!(
rows,
vec![(3, "carol".to_string())],
"The residual filter should still be enforced above the scan"
);
}
#[tokio::test]
async fn test_temporal_filter_pushdown_via_datafusion_scan() {
let (_tmp, sql_context) = common::setup_sql_context().await;
sql_context
.sql(
"CREATE TABLE paimon.test_db.temporal_filter_pushdown (
id INT,
name STRING,
ts TIMESTAMP(6),
lzts TIMESTAMP(6) WITH TIME ZONE
)",
)
.await
.expect("CREATE TABLE should succeed")
.collect()
.await
.expect("CREATE TABLE should collect");
sql_context
.sql(
"INSERT INTO paimon.test_db.temporal_filter_pushdown VALUES
(1, 'alice', TIMESTAMP '2024-01-01 00:00:00.123456', TIMESTAMP '2024-01-01 00:00:00.123456+00:00'),
(2, 'bob', TIMESTAMP '2024-01-01 00:00:00.654321', TIMESTAMP '2024-01-01 00:00:00.654321+00:00'),
(3, 'carol', TIMESTAMP '2024-01-02 00:00:00.123456', TIMESTAMP '2024-01-02 00:00:00.123456+00:00')",
)
.await
.expect("INSERT should succeed")
.collect()
.await
.expect("INSERT should collect");
let timestamp_sql = "SELECT id, name FROM paimon.test_db.temporal_filter_pushdown \
WHERE ts = TIMESTAMP '2024-01-01 00:00:00.123456' AND id + 1 > 2";
let plan = sql_context
.sql(timestamp_sql)
.await
.expect("SQL planning should succeed")
.create_physical_plan()
.await
.expect("Physical plan creation should succeed");
let plan_text = format_physical_plan(&plan);
let scan_lines = paimon_scan_lines(&plan_text);
assert!(
!scan_lines.is_empty(),
"plan should contain a PaimonTableScan, plan:\n{plan_text}"
);
assert!(
scan_lines
.iter()
.any(|line| line.contains("predicate=ts = TS(")),
"Temporal predicate should be pushed into PaimonTableScan, plan:\n{plan_text}"
);
assert!(
!plan_text.contains("FilterExec"),
"PaimonTableScan should evaluate accepted runtime filters exactly, plan:\n{plan_text}"
);
assert!(
scan_lines
.iter()
.any(|line| line.contains("runtime_filters=[")),
"Residual filter should be retained by PaimonTableScan, plan:\n{plan_text}"
);
let rows = common::collect_id_name(&sql_context, timestamp_sql).await;
assert!(
rows.is_empty(),
"Residual filter should remove the row matched by the pushed temporal predicate"
);
let local_zoned_sql = "SELECT id, name FROM paimon.test_db.temporal_filter_pushdown \
WHERE lzts = TIMESTAMP '2024-01-01 00:00:00.654321+00:00' AND id + 1 > 3";
let plan = sql_context
.sql(local_zoned_sql)
.await
.expect("SQL planning should succeed")
.create_physical_plan()
.await
.expect("Physical plan creation should succeed");
let plan_text = format_physical_plan(&plan);
let scan_lines = paimon_scan_lines(&plan_text);
assert!(
!scan_lines.is_empty(),
"plan should contain a PaimonTableScan, plan:\n{plan_text}"
);
assert!(
scan_lines
.iter()
.any(|line| line.contains("predicate=lzts = LZTS(")),
"Local zoned timestamp predicate should be pushed into PaimonTableScan, plan:\n{plan_text}"
);
assert!(
!plan_text.contains("FilterExec"),
"PaimonTableScan should evaluate accepted runtime filters exactly, plan:\n{plan_text}"
);
assert!(
scan_lines
.iter()
.any(|line| line.contains("runtime_filters=[")),
"Residual filter should be retained by PaimonTableScan, plan:\n{plan_text}"
);
let rows = common::collect_id_name(&sql_context, local_zoned_sql).await;
assert!(
rows.is_empty(),
"Residual filter should remove the row matched by the pushed local zoned timestamp predicate"
);
}
#[tokio::test]
async fn test_negative_temporal_filter_pushdown_via_datafusion_scan() {
let (_tmp, sql_context) = common::setup_sql_context().await;
sql_context
.sql(
"CREATE TABLE paimon.test_db.negative_temporal_pushdown (
id INT,
name STRING,
ts TIMESTAMP(6)
)",
)
.await
.expect("CREATE TABLE should succeed")
.collect()
.await
.expect("CREATE TABLE should collect");
for values in [
"(1, 'neg_us', TIMESTAMP '1969-12-31 23:59:59.999999')",
"(2, 'epoch', TIMESTAMP '1970-01-01 00:00:00.000000')",
"(3, 'post', TIMESTAMP '1970-01-01 00:00:00.000001')",
] {
sql_context
.sql(&format!(
"INSERT INTO paimon.test_db.negative_temporal_pushdown VALUES {values}"
))
.await
.expect("INSERT should succeed")
.collect()
.await
.expect("INSERT should collect");
}
let eq_sql = "SELECT id, name FROM paimon.test_db.negative_temporal_pushdown \
WHERE ts = TIMESTAMP '1969-12-31 23:59:59.999999'";
let plan = sql_context
.sql(eq_sql)
.await
.expect("SQL planning should succeed")
.create_physical_plan()
.await
.expect("Physical plan creation should succeed");
let plan_text = format_physical_plan(&plan);
let scan_lines = paimon_scan_lines(&plan_text);
assert!(
scan_lines
.iter()
.any(|line| line.contains("predicate=ts = TS(")),
"Temporal predicate should be pushed into PaimonTableScan, plan:\n{plan_text}"
);
let rows = common::collect_id_name(&sql_context, eq_sql).await;
assert_eq!(
rows,
vec![(1, "neg_us".to_string())],
"Pushed equality on a pre-epoch sub-millisecond timestamp must match the stored row"
);
let range_sql = "SELECT id, name FROM paimon.test_db.negative_temporal_pushdown \
WHERE ts < TIMESTAMP '1970-01-01 00:00:00.000000'";
let rows = common::collect_id_name(&sql_context, range_sql).await;
assert_eq!(
rows,
vec![(1, "neg_us".to_string())],
"Pushed range predicate must order pre-epoch fractional timestamps correctly"
);
}
#[tokio::test]
async fn test_limit_pushdown_on_data_evolution_table_returns_merged_rows() {
let batches = collect_query("SELECT id, name FROM paimon.default.data_evolution_table LIMIT 3")
.await
.expect("Limit query on data evolution table should succeed");
let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(
total_rows, 3,
"LIMIT 3 should return exactly 3 rows for data evolution table"
);
let mut rows = extract_id_name_rows(&batches);
rows.sort_by_key(|(id, _)| *id);
assert_eq!(
rows,
vec![
(1, "alice-v2".to_string()),
(2, "bob".to_string()),
(3, "carol-v2".to_string()),
],
"Data evolution table LIMIT 3 should return merged rows"
);
}
#[tokio::test]
async fn test_limit_pushdown_marks_safe_scan_limit_hint_and_keeps_correctness() {
let sql = "SELECT id, name FROM paimon.default.simple_log_table LIMIT 2";
let plan = create_physical_plan(sql)
.await
.expect("Physical plan creation should succeed");
let plan_text = format_physical_plan(&plan);
let scan_lines = paimon_scan_lines(&plan_text);
assert!(
scan_lines
.iter()
.any(|line| line.contains("limit=2") && !line.contains("fetch=")),
"Safe LIMIT query should push a scan limit hint into PaimonTableScan, plan:\n{plan_text}"
);
let batches = collect_query(sql)
.await
.expect("LIMIT query should succeed");
let total_rows: usize = batches.iter().map(|batch| batch.num_rows()).sum();
assert_eq!(total_rows, 2, "LIMIT 2 should still return exactly 2 rows");
}
#[tokio::test]
async fn test_offset_limit_pushdown_keeps_correctness_without_fetch_contract() {
let sql = "SELECT id, name FROM paimon.default.partitioned_log_table OFFSET 1 LIMIT 1";
let plan = create_physical_plan(sql)
.await
.expect("Physical plan creation should succeed");
let plan_text = format_physical_plan(&plan);
let scan_lines = paimon_scan_lines(&plan_text);
assert!(
plan_text.contains("GlobalLimitExec"),
"OFFSET queries should keep a GlobalLimitExec in DataFusion, plan:\n{plan_text}"
);
assert!(
scan_lines.iter().all(|line| !line.contains("fetch=")),
"OFFSET + LIMIT should not rely on the removed DataFusion fetch contract in PaimonTableScan, plan:\n{plan_text}"
);
let batches = collect_query(sql)
.await
.expect("OFFSET + LIMIT query should succeed");
let total_rows: usize = batches.iter().map(|batch| batch.num_rows()).sum();
assert_eq!(
total_rows, 1,
"OFFSET 1 LIMIT 1 should still return exactly 1 row"
);
}
#[tokio::test]
async fn test_inexact_filter_limit_keeps_correctness_without_fetch_contract() {
let sql = "SELECT id, name FROM paimon.default.partitioned_log_table WHERE id > 1 LIMIT 1";
let plan = create_physical_plan(sql)
.await
.expect("Physical plan creation should succeed");
let plan_text = format_physical_plan(&plan);
let scan_lines = paimon_scan_lines(&plan_text);
assert!(
!scan_lines.is_empty(),
"plan should contain a PaimonTableScan, plan:\n{plan_text}"
);
assert!(
scan_lines.iter().all(|line| !line.contains("fetch=")),
"Inexact filter queries should not revive the removed fetch contract, plan:\n{plan_text}"
);
let batches = collect_query(sql)
.await
.expect("Inexact filter + LIMIT query should succeed");
let total_rows: usize = batches.iter().map(|batch| batch.num_rows()).sum();
assert_eq!(
total_rows, 1,
"Inexact filter + LIMIT should still return exactly 1 row"
);
}
#[tokio::test]
async fn test_residual_filter_limit_keeps_connector_limit_and_correctness() {
let sql = "SELECT id, name FROM paimon.default.simple_log_table WHERE id + 1 > 3 LIMIT 1";
let plan = create_physical_plan(sql)
.await
.expect("Physical plan creation should succeed");
let plan_text = format_physical_plan(&plan);
let scan_lines = paimon_scan_lines(&plan_text);
assert!(
!scan_lines.is_empty(),
"plan should contain a PaimonTableScan, plan:\n{plan_text}"
);
assert!(
scan_lines.iter().all(|line| !line.contains("fetch=")),
"Residual filter queries should not revive the removed fetch contract, plan:\n{plan_text}"
);
assert!(
scan_lines
.iter()
.all(|line| !line.contains(", limit=")),
"Residual filter queries should not push a scan limit hint when residual filters stay above the scan, plan:\n{plan_text}"
);
let batches = collect_query(sql)
.await
.expect("Residual filter + LIMIT query should succeed");
let rows = extract_id_name_rows(&batches);
assert_eq!(
rows,
vec![(3, "carol".to_string())],
"Residual filter + LIMIT should still return the matching row"
);
}
#[tokio::test]
async fn test_query_via_catalog_provider() {
let catalog = create_catalog();
let catalog: Arc<dyn Catalog> = Arc::new(catalog);
let mut ctx = SQLContext::new();
ctx.register_catalog("paimon", catalog)
.await
.expect("Failed to register catalog");
let df = ctx
.sql("SELECT id, name FROM paimon.default.simple_log_table")
.await
.expect("Failed to execute query");
let batches = df.collect().await.expect("Failed to collect results");
let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(total_rows, 3, "Expected 3 rows from simple_log_table");
}
#[tokio::test]
async fn test_missing_database_returns_no_schema() {
let catalog = create_catalog();
let provider = PaimonCatalogProvider::new(
None,
Arc::new(catalog),
Default::default(),
Default::default(),
None,
);
assert!(
provider.schema("definitely_missing_database").is_none(),
"missing databases should not resolve to a schema provider"
);
}
async fn create_time_travel_context() -> SQLContext {
let catalog = create_catalog();
let catalog: Arc<dyn Catalog> = Arc::new(catalog);
let mut ctx = SQLContext::new();
ctx.register_catalog("paimon", catalog)
.await
.expect("Failed to register catalog");
ctx
}
#[tokio::test]
async fn test_time_travel_by_snapshot_id() {
let ctx = create_time_travel_context().await;
let batches = ctx
.sql("SELECT id, name FROM paimon.default.time_travel_table VERSION AS OF 1")
.await
.expect("time travel query should parse")
.collect()
.await
.expect("time travel query should execute");
let mut rows = extract_id_name_rows(&batches);
rows.sort_by_key(|(id, _)| *id);
assert_eq!(
rows,
vec![(1, "alice".to_string()), (2, "bob".to_string())],
"Snapshot 1 should contain only the first batch of rows"
);
let batches = ctx
.sql("SELECT id, name FROM paimon.default.time_travel_table VERSION AS OF 2")
.await
.expect("time travel query should parse")
.collect()
.await
.expect("time travel query should execute");
let mut rows = extract_id_name_rows(&batches);
rows.sort_by_key(|(id, _)| *id);
assert_eq!(
rows,
vec![
(1, "alice".to_string()),
(2, "bob".to_string()),
(3, "carol".to_string()),
(4, "dave".to_string()),
],
"Snapshot 2 should contain all rows"
);
}
#[tokio::test]
async fn test_time_travel_by_tag_name() {
let ctx = create_time_travel_context().await;
let batches = ctx
.sql("SELECT id, name FROM paimon.default.time_travel_table VERSION AS OF 'snapshot1'")
.await
.expect("tag time travel query should parse")
.collect()
.await
.expect("tag time travel query should execute");
let mut rows = extract_id_name_rows(&batches);
rows.sort_by_key(|(id, _)| *id);
assert_eq!(
rows,
vec![(1, "alice".to_string()), (2, "bob".to_string())],
"Tag 'snapshot1' should contain only the first batch of rows"
);
let batches = ctx
.sql("SELECT id, name FROM paimon.default.time_travel_table VERSION AS OF 'snapshot2'")
.await
.expect("tag time travel query should parse")
.collect()
.await
.expect("tag time travel query should execute");
let mut rows = extract_id_name_rows(&batches);
rows.sort_by_key(|(id, _)| *id);
assert_eq!(
rows,
vec![
(1, "alice".to_string()),
(2, "bob".to_string()),
(3, "carol".to_string()),
(4, "dave".to_string()),
],
"Tag 'snapshot2' should contain all rows"
);
}
#[tokio::test]
async fn time_travel_schema_evolution() {
assert_time_travel_schema_evolution_rows(
"before_add_column",
"id, name",
None,
&[&["1", "alice"], &["2", "bob"]],
)
.await;
assert_time_travel_schema_evolution_rows(
"before_add_column",
"id, name",
Some("id = 2"),
&[&["2", "bob"]],
)
.await;
assert_time_travel_schema_evolution_rows(
"after_add_column",
"id, name, age",
None,
&[
&["1", "alice", "NULL"],
&["2", "bob", "NULL"],
&["3", "carol", "30"],
&["4", "dave", "40"],
],
)
.await;
assert_time_travel_schema_evolution_rows(
"after_add_column",
"id, name, age",
Some("age IS NOT NULL"),
&[&["3", "carol", "30"], &["4", "dave", "40"]],
)
.await;
assert_time_travel_schema_evolution_rows(
"before_rename",
"id, name, age",
Some("id >= 3"),
&[&["3", "carol", "30"], &["4", "dave", "40"]],
)
.await;
assert_time_travel_schema_evolution_rows(
"after_rename",
"id, full_name, age",
None,
&[
&["1", "alice", "NULL"],
&["2", "bob", "NULL"],
&["3", "carol", "30"],
&["4", "dave", "40"],
&["5", "erin", "50"],
&["6", "frank", "60"],
],
)
.await;
assert_time_travel_schema_evolution_rows(
"after_rename",
"id, full_name, age",
Some("full_name LIKE 'f%'"),
&[&["6", "frank", "60"]],
)
.await;
assert_time_travel_schema_evolution_rows(
"before_drop",
"id, full_name, age",
Some("age >= 50"),
&[&["5", "erin", "50"], &["6", "frank", "60"]],
)
.await;
assert_time_travel_schema_evolution_rows(
"after_drop",
"id, full_name",
None,
&[
&["1", "alice"],
&["2", "bob"],
&["3", "carol"],
&["4", "dave"],
&["5", "erin"],
&["6", "frank"],
&["7", "grace"],
&["8", "hank"],
],
)
.await;
assert_time_travel_schema_evolution_rows(
"after_drop",
"id, full_name",
Some("id > 6"),
&[&["7", "grace"], &["8", "hank"]],
)
.await;
assert_time_travel_schema_evolution_rows(
"before_reorder",
"id, full_name",
Some("id = 8"),
&[&["8", "hank"]],
)
.await;
assert_time_travel_schema_evolution_rows(
"after_reorder",
"id, full_name",
None,
&[
&["1", "alice"],
&["2", "bob"],
&["3", "carol"],
&["4", "dave"],
&["5", "erin"],
&["6", "frank"],
&["7", "grace"],
&["8", "hank"],
&["9", "ivy"],
&["10", "jane"],
],
)
.await;
assert_time_travel_schema_evolution_rows(
"after_reorder",
"id, full_name",
Some("id >= 9"),
&[&["9", "ivy"], &["10", "jane"]],
)
.await;
}
#[tokio::test]
async fn test_time_travel_conflicting_selectors_fail() {
let provider = create_provider_with_options(
"time_travel_table",
HashMap::from([
("scan.version".to_string(), "1".to_string()),
("scan.timestamp-millis".to_string(), "1234".to_string()),
]),
)
.await;
let ctx = create_context().await;
ctx.register_temp_table("paimon.default.time_travel_table", Arc::new(provider))
.expect("Failed to register temp table");
let err = ctx
.sql("SELECT id, name FROM paimon.default.time_travel_table")
.await
.expect("query should parse")
.collect()
.await
.expect_err("conflicting time-travel selectors should fail");
let message = err.to_string();
assert!(
message.contains("Only one time-travel selector may be set"),
"unexpected conflict error: {message}"
);
assert!(
message.contains("scan.version"),
"conflict error should mention scan.version: {message}"
);
}
#[tokio::test]
async fn test_time_travel_invalid_version_fails() {
let provider = create_provider_with_options(
"time_travel_table",
HashMap::from([("scan.version".to_string(), "nonexistent-tag".to_string())]),
)
.await;
let ctx = create_context().await;
ctx.register_temp_table("paimon.default.time_travel_table", Arc::new(provider))
.expect("Failed to register temp table");
let err = ctx
.sql("SELECT id, name FROM paimon.default.time_travel_table")
.await
.expect("query should parse")
.collect()
.await
.expect_err("invalid version should fail");
let message = err.to_string();
assert!(
message.contains("is not a valid tag name or snapshot id"),
"unexpected invalid version error: {message}"
);
}
#[tokio::test]
async fn test_data_evolution_drop_column_null_fill() {
let batches =
collect_query("SELECT id, name, extra FROM paimon.default.data_evolution_drop_column")
.await
.expect("data_evolution_drop_column query should succeed");
let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(
total_rows, 3,
"Should return 3 rows (not silently drop rows from merge groups missing the new column)"
);
let mut rows: Vec<(i32, String, Option<String>)> = Vec::new();
for batch in &batches {
let id_array = batch
.column_by_name("id")
.and_then(|c| c.as_any().downcast_ref::<Int32Array>())
.expect("Expected Int32Array for id");
let name_array = batch
.column_by_name("name")
.expect("Expected string array for name");
let extra_array = batch
.column_by_name("extra")
.expect("Expected string array for extra");
for i in 0..batch.num_rows() {
let extra = if extra_array.is_null(i) {
None
} else {
Some(string_value(extra_array.as_ref(), i).to_string())
};
rows.push((
id_array.value(i),
string_value(name_array.as_ref(), i).to_string(),
extra,
));
}
}
rows.sort_by_key(|(id, _, _)| *id);
assert_eq!(
rows,
vec![
(1, "alice-v2".to_string(), None),
(2, "bob".to_string(), None),
(3, "carol".to_string(), Some("new".to_string())),
],
"Old rows should have extra=NULL, new row should have extra='new'"
);
}
#[tokio::test]
async fn test_sql_read_format_schema_evolution_add_column() {
assert_sql_rows(
"SELECT id, name, age FROM paimon.default.format_schema_evolution_add_column ORDER BY id",
&[
&["1", "alice", "NULL"],
&["2", "bob", "NULL"],
&["3", "carol", "30"],
&["4", "dave", "40"],
&["5", "eve", "50"],
&["6", "frank", "60"],
],
)
.await;
assert_sql_rows(
"SELECT id, age FROM paimon.default.format_schema_evolution_add_column WHERE age IS NULL ORDER BY id",
&[&["1", "NULL"], &["2", "NULL"]],
)
.await;
}
#[tokio::test]
async fn test_sql_read_partitioned_format_schema_evolution_add_column() {
assert_sql_rows(
"SELECT id, name, extra FROM paimon.default.partitioned_format_schema_evolution_add_column WHERE dt = '2024-01-02' ORDER BY id",
&[
&["2", "bob", "NULL"],
&["5", "eve", "avro-extra-1"],
],
)
.await;
assert_sql_rows(
"SELECT id, name, extra FROM paimon.default.partitioned_format_schema_evolution_add_column WHERE dt = '2024-01-01' AND extra IS NULL ORDER BY id",
&[&["1", "alice", "NULL"]],
)
.await;
assert_sql_rows(
"SELECT dt, id, extra FROM paimon.default.partitioned_format_schema_evolution_add_column ORDER BY id",
&[
&["2024-01-01", "1", "NULL"],
&["2024-01-02", "2", "NULL"],
&["2024-01-01", "3", "orc-extra-1"],
&["2024-01-03", "4", "orc-extra-2"],
&["2024-01-02", "5", "avro-extra-1"],
&["2024-01-03", "6", "avro-extra-2"],
],
)
.await;
}
#[tokio::test]
async fn test_sql_read_format_schema_evolution_type_promotion() {
assert_sql_rows(
"SELECT id, value FROM paimon.default.format_schema_evolution_type_promotion ORDER BY id",
&[
&["1", "100"],
&["2", "200"],
&["3", "3000000000"],
&["4", "4000000000"],
&["5", "5000000000"],
&["6", "6000000000"],
],
)
.await;
assert_sql_rows(
"SELECT id, value FROM paimon.default.format_schema_evolution_type_promotion WHERE value > 3000000000 ORDER BY id",
&[&["4", "4000000000"], &["5", "5000000000"], &["6", "6000000000"]],
)
.await;
}
#[tokio::test]
async fn test_sql_read_schema_evolution_rename_column() {
assert_sql_rows(
"SELECT id, renamed_payload FROM paimon.default.schema_evolution_rename_column ORDER BY id",
&[
&["1", "parquet-old"],
&["2", "parquet-old-2"],
&["3", "orc-new"],
&["4", "avro-new"],
],
)
.await;
assert_sql_rows(
"SELECT id, renamed_payload FROM paimon.default.schema_evolution_rename_column WHERE renamed_payload LIKE '%new' ORDER BY id",
&[&["3", "orc-new"], &["4", "avro-new"]],
)
.await;
}
#[tokio::test]
async fn test_sql_read_mixed_format_schema_evolution_drop_column() {
assert_sql_rows(
"SELECT id, name FROM paimon.default.mixed_format_schema_evolution_drop_column ORDER BY id",
&[
&["1", "parquet-alice"],
&["2", "parquet-bob"],
&["3", "orc-carol"],
&["4", "orc-dave"],
&["5", "avro-eve"],
&["6", "avro-frank"],
],
)
.await;
assert_sql_rows(
"SELECT id, name FROM paimon.default.mixed_format_schema_evolution_drop_column WHERE name LIKE 'avro-%' ORDER BY id",
&[&["5", "avro-eve"], &["6", "avro-frank"]],
)
.await;
}
#[tokio::test]
async fn test_sql_read_mixed_format_schema_evolution_reorder_move_column() {
assert_sql_rows(
"SELECT right_value, left_value, id FROM paimon.default.mixed_format_schema_evolution_reorder_move_column ORDER BY id",
&[
&["parquet-right-1", "parquet-left-1", "1"],
&["parquet-right-2", "parquet-left-2", "2"],
&["orc-right-3", "orc-left-3", "3"],
&["orc-right-4", "orc-left-4", "4"],
&["avro-right-5", "avro-left-5", "5"],
&["avro-right-6", "avro-left-6", "6"],
],
)
.await;
assert_sql_rows(
"SELECT id, right_value FROM paimon.default.mixed_format_schema_evolution_reorder_move_column WHERE right_value LIKE 'orc-%' ORDER BY id",
&[&["3", "orc-right-3"], &["4", "orc-right-4"]],
)
.await;
}
#[tokio::test]
async fn test_read_complex_type_table_via_datafusion() {
let batches = collect_query(
"SELECT id, int_array, string_map, row_field FROM paimon.default.complex_type_table ORDER BY id",
)
.await
.expect("Complex type query should succeed");
let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
assert_eq!(total_rows, 3, "Expected 3 rows from complex_type_table");
for batch in &batches {
let schema = batch.schema();
assert!(
schema.field_with_name("int_array").is_ok(),
"int_array column should exist"
);
assert!(
schema.field_with_name("string_map").is_ok(),
"string_map column should exist"
);
assert!(
schema.field_with_name("row_field").is_ok(),
"row_field column should exist"
);
}
let mut rows: Vec<(i32, String, String, String)> = Vec::new();
for batch in &batches {
let id_array = batch
.column_by_name("id")
.and_then(|c| c.as_any().downcast_ref::<Int32Array>())
.expect("Expected Int32Array for id");
let int_array_col = batch.column_by_name("int_array").expect("int_array");
let string_map_col = batch.column_by_name("string_map").expect("string_map");
let row_field_col = batch.column_by_name("row_field").expect("row_field");
for i in 0..batch.num_rows() {
use datafusion::arrow::util::display::ArrayFormatter;
let fmt_opts = datafusion::arrow::util::display::FormatOptions::default();
let arr_fmt = ArrayFormatter::try_new(int_array_col.as_ref(), &fmt_opts).unwrap();
let map_fmt = ArrayFormatter::try_new(string_map_col.as_ref(), &fmt_opts).unwrap();
let row_fmt = ArrayFormatter::try_new(row_field_col.as_ref(), &fmt_opts).unwrap();
rows.push((
id_array.value(i),
arr_fmt.value(i).to_string(),
map_fmt.value(i).to_string(),
row_fmt.value(i).to_string(),
));
}
}
rows.sort_by_key(|(id, _, _, _)| *id);
assert_eq!(rows[0].0, 1);
assert_eq!(rows[0].1, "[1, 2, 3]");
assert_eq!(rows[0].2, "{a: 10, b: 20}");
assert_eq!(rows[0].3, "{name: alice, value: 100}");
assert_eq!(rows[1].0, 2);
assert_eq!(rows[1].1, "[4, 5]");
assert_eq!(rows[1].2, "{c: 30}");
assert_eq!(rows[1].3, "{name: bob, value: 200}");
assert_eq!(rows[2].0, 3);
assert_eq!(rows[2].1, "[]");
assert_eq!(rows[2].2, "{}");
assert_eq!(rows[2].3, "{name: carol, value: 300}");
}
#[tokio::test]
async fn test_select_row_id_from_data_evolution_table() {
use datafusion::arrow::array::Int64Array;
let ctx = create_context().await;
let batches = ctx
.sql(r#"SELECT "_ROW_ID", id, name FROM paimon.default.data_evolution_table"#)
.await
.expect("SQL should parse")
.collect()
.await
.expect("query should execute");
assert!(!batches.is_empty());
let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
assert!(total_rows > 0);
for batch in &batches {
let row_id_col = batch
.column_by_name("_ROW_ID")
.expect("_ROW_ID column should exist");
let row_id_array = row_id_col
.as_any()
.downcast_ref::<Int64Array>()
.expect("_ROW_ID should be Int64");
for i in 0..batch.num_rows() {
assert!(
row_id_array.is_valid(i),
"_ROW_ID should not be null for data evolution table"
);
assert!(row_id_array.value(i) >= 0);
}
}
}
#[tokio::test]
async fn test_filter_row_id_from_data_evolution_table() {
use datafusion::arrow::array::Int64Array;
let ctx = create_context().await;
let all_batches = ctx
.sql(r#"SELECT "_ROW_ID" FROM paimon.default.data_evolution_table"#)
.await
.expect("SQL")
.collect()
.await
.expect("collect");
let all_count: usize = all_batches.iter().map(|b| b.num_rows()).sum();
let filtered_batches = ctx
.sql(r#"SELECT "_ROW_ID", id FROM paimon.default.data_evolution_table WHERE "_ROW_ID" = 0"#)
.await
.expect("SQL")
.collect()
.await
.expect("collect");
let filtered_count: usize = filtered_batches.iter().map(|b| b.num_rows()).sum();
assert!(filtered_count <= all_count);
for batch in &filtered_batches {
let row_id_array = batch
.column_by_name("_ROW_ID")
.expect("_ROW_ID")
.as_any()
.downcast_ref::<Int64Array>()
.expect("Int64");
for i in 0..batch.num_rows() {
assert_eq!(row_id_array.value(i), 0);
}
}
}
#[tokio::test]
async fn test_case_insensitive_column_not_supported_via_sql() {
let (_tmp, sql_context) = common::setup_sql_context().await;
sql_context
.sql("CREATE TABLE paimon.test_db.mixed_case_cols (id INT, \"Name\" STRING)")
.await
.expect("CREATE TABLE should succeed")
.collect()
.await
.expect("CREATE TABLE should collect");
sql_context
.sql("SELECT \"Name\" FROM paimon.test_db.mixed_case_cols")
.await
.expect("exact-case column reference should resolve");
let err = sql_context
.sql("SELECT name FROM paimon.test_db.mixed_case_cols")
.await
.expect_err("case-mismatched column must fail at planning, not resolve case-insensitively");
let msg = err.to_string().to_lowercase();
assert!(
msg.contains("name"),
"expected a column-resolution error referencing `name`, got: {err}"
);
}
#[cfg(feature = "fulltext")]
mod fulltext_tests {
use std::sync::Arc;
use bytes::Bytes;
use datafusion::arrow::array::Int32Array;
use paimon::catalog::Identifier;
use paimon::spec::{GlobalIndexMeta, IndexFileMeta};
use paimon::table::BranchManager;
use paimon::{Catalog, CatalogOptions, CommitMessage, FileSystemCatalog, Options, TableCommit};
use paimon_datafusion::{register_full_text_search, SQLContext};
use super::common::string_value;
use paimon_ftindex_core::io::PosWriter;
use paimon_ftindex_core::{FullTextIndexConfig, FullTextIndexWriter};
async fn create_fulltext_context() -> (SQLContext, Arc<FileSystemCatalog>, tempfile::TempDir) {
let tmp = tempfile::tempdir().expect("Failed to create temp dir");
let warehouse = format!("file://{}", tmp.path().display());
let mut options = Options::new();
options.set(CatalogOptions::WAREHOUSE, warehouse);
let catalog = Arc::new(FileSystemCatalog::new(options).expect("Failed to create catalog"));
let mut ctx = SQLContext::new();
ctx.register_catalog("paimon", catalog.clone())
.await
.expect("Failed to register catalog");
register_full_text_search(ctx.ctx(), catalog.clone(), "default");
run_fulltext_sql(
&ctx,
"CREATE TABLE paimon.default.test_fulltext (
id INT,
content STRING
) WITH (
'row-tracking.enabled' = 'true',
'global-index.search-mode' = 'full'
)",
)
.await;
for insert in [
"INSERT INTO paimon.default.test_fulltext VALUES (0, 'alphaft lakehouse storage')",
"INSERT INTO paimon.default.test_fulltext VALUES (1, 'betafft gammaft full-text search')",
"INSERT INTO paimon.default.test_fulltext VALUES (2, 'alphaft supports indexed queries')",
"INSERT INTO paimon.default.test_fulltext VALUES (3, 'gammaft over raw fallback rows')",
"INSERT INTO paimon.default.test_fulltext VALUES (4, 'alphaft tables can be queried')",
] {
run_fulltext_sql(&ctx, insert).await;
}
build_fulltext_index(catalog.as_ref()).await;
(ctx, catalog, tmp)
}
async fn build_fulltext_index(catalog: &dyn Catalog) {
let table = catalog
.get_table(&Identifier::new("default", "test_fulltext"))
.await
.expect("table should exist");
let content_field_id = table
.schema()
.fields()
.iter()
.find(|field| field.name() == "content")
.expect("content field")
.id();
let mut writer =
FullTextIndexWriter::new(FullTextIndexConfig::new().with_text_fields(["content"]))
.expect("full-text writer");
for (row_id, content) in [
(0, "alphaft lakehouse storage"),
(1, "betafft gammaft full-text search"),
(2, "alphaft supports indexed queries"),
(3, "gammaft over raw fallback rows"),
(4, "alphaft tables can be queried"),
] {
writer
.add_document_fields(row_id, [("content", content)])
.expect("add full-text document");
}
let mut index_bytes = Vec::new();
writer
.write(&mut PosWriter::new(&mut index_bytes))
.expect("write full-text index");
let index_file_name = "ft-datafusion-fulltext.index";
table
.file_io()
.mkdirs(&format!(
"{}/index/",
table.location().trim_end_matches('/')
))
.await
.expect("create index dir");
table
.file_io()
.new_output(&format!(
"{}/index/{index_file_name}",
table.location().trim_end_matches('/')
))
.expect("index output")
.write(Bytes::from(index_bytes.clone()))
.await
.expect("write index file");
let mut message = CommitMessage::new(Vec::new(), 0, Vec::new());
message.new_index_files = vec![IndexFileMeta {
index_type: "full-text".to_string(),
file_name: index_file_name.to_string(),
file_size: i64::try_from(index_bytes.len()).unwrap(),
row_count: 5,
deletion_vectors_ranges: None,
global_index_meta: Some(GlobalIndexMeta {
row_range_start: 0,
row_range_end: 4,
index_field_id: content_field_id,
extra_field_ids: None,
index_meta: None,
source_meta: None,
}),
}];
TableCommit::new(table, "test-user".to_string())
.commit(vec![message])
.await
.expect("commit full-text index manifest");
}
async fn run_fulltext_sql(ctx: &SQLContext, sql: &str) {
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}"));
}
fn extract_id_content_rows(
batches: &[datafusion::arrow::record_batch::RecordBatch],
) -> Vec<(i32, String)> {
let mut rows = Vec::new();
for batch in batches {
let id_array = batch
.column_by_name("id")
.and_then(|c| c.as_any().downcast_ref::<Int32Array>())
.expect("Expected Int32Array for id");
let content_array = batch
.column_by_name("content")
.expect("Expected string array for content");
for i in 0..batch.num_rows() {
rows.push((
id_array.value(i),
string_value(content_array.as_ref(), i).to_string(),
));
}
}
rows.sort_by_key(|(id, _)| *id);
rows
}
#[tokio::test]
async fn test_full_text_search_paimon() {
let (ctx, _catalog, _tmp) = create_fulltext_context().await;
let batches = ctx
.sql("SELECT id, content FROM full_text_search('paimon.default.test_fulltext', 'content', 'alphaft', 10)")
.await
.expect("SQL should parse")
.collect()
.await
.expect("query should execute");
let rows = extract_id_content_rows(&batches);
let ids: Vec<i32> = rows.iter().map(|(id, _)| *id).collect();
assert_eq!(
ids,
vec![0, 2, 4],
"Searching 'alphaft' should match rows 0, 2, 4"
);
}
#[tokio::test]
async fn test_full_text_search_branch() {
let (ctx, catalog, _tmp) = create_fulltext_context().await;
let identifier = Identifier::new("default", "test_fulltext");
let table = catalog.get_table(&identifier).await.expect("load table");
let snapshot_manager = table.snapshot_manager();
let snapshot = snapshot_manager
.get_latest_snapshot()
.await
.expect("load latest snapshot")
.expect("latest snapshot");
table
.tag_manager()
.create("branch-source", &snapshot)
.await
.expect("create tag");
BranchManager::new(table.file_io().clone(), table.location().to_string())
.create_branch_from_tag("b1", "branch-source")
.await
.expect("create branch");
table
.file_io()
.delete_dir(&snapshot_manager.snapshot_dir())
.await
.expect("delete main snapshots");
let batches = ctx
.sql("SELECT id, content FROM full_text_search('paimon.default.test_fulltext$branch_b1', 'content', 'alphaft', 10)")
.await
.expect("SQL should parse")
.collect()
.await
.expect("branch query should execute");
let rows = extract_id_content_rows(&batches);
let ids: Vec<i32> = rows.iter().map(|(id, _)| *id).collect();
assert_eq!(ids, vec![0, 2, 4]);
}
#[tokio::test]
async fn test_full_text_search_betafft() {
let (ctx, _catalog, _tmp) = create_fulltext_context().await;
let batches = ctx
.sql("SELECT id, content FROM full_text_search('paimon.default.test_fulltext', 'content', 'betafft', 10)")
.await
.expect("SQL should parse")
.collect()
.await
.expect("query should execute");
let rows = extract_id_content_rows(&batches);
let ids: Vec<i32> = rows.iter().map(|(id, _)| *id).collect();
assert_eq!(ids, vec![1], "Searching 'betafft' should match row 1");
}
#[tokio::test]
async fn test_full_text_search_search() {
let (ctx, _catalog, _tmp) = create_fulltext_context().await;
let batches = ctx
.sql("SELECT id, content FROM full_text_search('paimon.default.test_fulltext', 'content', 'gammaft', 10)")
.await
.expect("SQL should parse")
.collect()
.await
.expect("query should execute");
let rows = extract_id_content_rows(&batches);
let ids: Vec<i32> = rows.iter().map(|(id, _)| *id).collect();
assert_eq!(
ids,
vec![1, 3],
"Searching 'gammaft' should match rows 1, 3"
);
}
}
mod vector_search_tests {
use std::sync::Arc;
use datafusion::arrow::array::{ArrayRef, Float32Builder, Int32Array, ListBuilder};
use datafusion::arrow::datatypes::{
DataType as ArrowDataType, Field as ArrowField, Schema as ArrowSchema,
};
use datafusion::arrow::record_batch::RecordBatch;
use datafusion::datasource::MemTable;
use paimon::catalog::Identifier;
use paimon::spec::{ArrayType, DataType, FloatType, IntType, Schema};
use paimon::table::BranchManager;
use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
use paimon_datafusion::{register_vector_search, SQLContext};
use super::common::string_value;
fn extract_test_warehouse(archive_name: &str) -> (tempfile::TempDir, String) {
let archive_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("testdata")
.join(archive_name);
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_vector_search_context(
archive_name: &str,
) -> (SQLContext, Arc<FileSystemCatalog>, tempfile::TempDir) {
let (tmp, warehouse) = extract_test_warehouse(archive_name);
let mut options = Options::new();
options.set(CatalogOptions::WAREHOUSE, warehouse);
let catalog = Arc::new(FileSystemCatalog::new(options).expect("Failed to create catalog"));
let mut ctx = SQLContext::new();
ctx.register_catalog("paimon", catalog.clone())
.await
.expect("Failed to register catalog");
register_vector_search(ctx.ctx(), catalog.clone(), "default");
(ctx, catalog, tmp)
}
async fn create_lumina_vector_search_context(
) -> (SQLContext, Arc<FileSystemCatalog>, tempfile::TempDir) {
create_vector_search_context("test_lumina_vector.tar.gz").await
}
async fn create_java_vindex_vector_search_context(
) -> (SQLContext, Arc<FileSystemCatalog>, tempfile::TempDir) {
create_vector_search_context("test_java_vindex_vector.tar.gz").await
}
async fn create_empty_vector_search_context(
) -> (SQLContext, Arc<FileSystemCatalog>, tempfile::TempDir) {
let tmp = tempfile::tempdir().expect("Failed to create temp dir");
let warehouse = format!("file://{}", tmp.path().display());
let mut options = Options::new();
options.set(CatalogOptions::WAREHOUSE, warehouse);
let catalog = Arc::new(FileSystemCatalog::new(options).expect("Failed to create catalog"));
let mut ctx = SQLContext::new();
ctx.register_catalog("paimon", catalog.clone())
.await
.expect("Failed to register catalog");
register_vector_search(ctx.ctx(), catalog.clone(), "default");
(ctx, catalog, tmp)
}
fn build_lumina_table_schema() -> Schema {
let mut options = std::collections::HashMap::new();
options.insert("row-tracking.enabled".to_string(), "true".to_string());
options.insert("data-evolution.enabled".to_string(), "true".to_string());
options.insert("global-index.enabled".to_string(), "true".to_string());
options.insert(
"global-index.row-count-per-shard".to_string(),
"3".to_string(),
);
options.insert("lumina.index.dimension".to_string(), "2".to_string());
options.insert("lumina.encoding.type".to_string(), "rawf32".to_string());
Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column(
"embedding",
DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
)
.options(options)
.build()
.expect("Failed to build table schema")
}
fn build_vindex_table_schema() -> Schema {
let mut options = std::collections::HashMap::new();
options.insert("row-tracking.enabled".to_string(), "true".to_string());
options.insert("data-evolution.enabled".to_string(), "true".to_string());
options.insert("global-index.enabled".to_string(), "true".to_string());
options.insert(
"global-index.row-count-per-shard".to_string(),
"3".to_string(),
);
Schema::builder()
.column("id", DataType::Int(IntType::new()))
.column(
"embedding",
DataType::Array(ArrayType::new(DataType::Float(FloatType::new()))),
)
.options(options)
.build()
.expect("Failed to build table schema")
}
fn build_vector_batch(ids: Vec<i32>, vectors: Vec<Vec<f32>>) -> RecordBatch {
let element_field = Arc::new(ArrowField::new("element", ArrowDataType::Float32, true));
let mut vector_builder =
ListBuilder::new(Float32Builder::new()).with_field(element_field.clone());
for vector in vectors {
for value in vector {
vector_builder.values().append_value(value);
}
vector_builder.append(true);
}
let schema = Arc::new(ArrowSchema::new(vec![
ArrowField::new("id", ArrowDataType::Int32, false),
ArrowField::new("embedding", ArrowDataType::List(element_field), true),
]));
RecordBatch::try_new(
schema,
vec![
Arc::new(Int32Array::from(ids)) as ArrayRef,
Arc::new(vector_builder.finish()) as ArrayRef,
],
)
.expect("Failed to build vector batch")
}
fn extract_ids(batches: &[datafusion::arrow::record_batch::RecordBatch]) -> Vec<i32> {
let mut ids = Vec::new();
for batch in batches {
let id_array = batch
.column_by_name("id")
.and_then(|c| c.as_any().downcast_ref::<Int32Array>())
.expect("Expected Int32Array for id");
for i in 0..batch.num_rows() {
ids.push(id_array.value(i));
}
}
ids.sort();
ids
}
fn extract_query_result_ids(
batches: &[datafusion::arrow::record_batch::RecordBatch],
) -> Vec<(i32, i32)> {
let mut rows = Vec::new();
for batch in batches {
let query_id_array = batch
.column_by_name("query_id")
.and_then(|c| c.as_any().downcast_ref::<Int32Array>())
.expect("Expected Int32Array for query_id");
let result_id_array = batch
.column_by_name("result_id")
.and_then(|c| c.as_any().downcast_ref::<Int32Array>())
.expect("Expected Int32Array for result_id");
for i in 0..batch.num_rows() {
rows.push((query_id_array.value(i), result_id_array.value(i)));
}
}
rows.sort();
rows
}
fn extract_index_rows(
batches: &[datafusion::arrow::record_batch::RecordBatch],
) -> Vec<(String, i64, i64, i64, String)> {
let mut rows = Vec::new();
for batch in batches {
let index_type_array = batch
.column_by_name("index_type")
.expect("Expected string array for index_type");
let row_count_array = batch
.column_by_name("row_count")
.and_then(|c| {
c.as_any()
.downcast_ref::<datafusion::arrow::array::Int64Array>()
})
.expect("Expected Int64Array for row_count");
let row_range_start_array = batch
.column_by_name("row_range_start")
.and_then(|c| {
c.as_any()
.downcast_ref::<datafusion::arrow::array::Int64Array>()
})
.expect("Expected Int64Array for row_range_start");
let row_range_end_array = batch
.column_by_name("row_range_end")
.and_then(|c| {
c.as_any()
.downcast_ref::<datafusion::arrow::array::Int64Array>()
})
.expect("Expected Int64Array for row_range_end");
let index_field_name_array = batch
.column_by_name("index_field_name")
.expect("Expected string array for index_field_name");
for row_index in 0..batch.num_rows() {
rows.push((
string_value(index_type_array.as_ref(), row_index).to_string(),
row_count_array.value(row_index),
row_range_start_array.value(row_index),
row_range_end_array.value(row_index),
string_value(index_field_name_array.as_ref(), row_index).to_string(),
));
}
}
rows.sort_by_key(|row| row.2);
rows
}
#[tokio::test]
async fn test_vector_search_top3() {
let (ctx, _catalog, _tmp) = create_lumina_vector_search_context().await;
let batches = ctx
.sql("SELECT id FROM vector_search('paimon.default.test_lumina_vector', 'embedding', '[1.0, 0.0, 0.0, 0.0]', 3)")
.await
.expect("SQL should parse")
.collect()
.await
.expect("query should execute");
let ids = extract_ids(&batches);
assert_eq!(ids.len(), 3);
assert!(ids.contains(&0), "exact match [1,0,0,0] should be in top 3");
}
#[tokio::test]
async fn test_vector_search_branch() {
let (ctx, catalog, _tmp) = create_java_vindex_vector_search_context().await;
let identifier = Identifier::new("default", "test_java_vindex_vector");
let table = catalog.get_table(&identifier).await.expect("load table");
let snapshot_manager = table.snapshot_manager();
let snapshot = snapshot_manager
.get_latest_snapshot()
.await
.expect("load latest snapshot")
.expect("latest snapshot");
table
.tag_manager()
.create("branch-source", &snapshot)
.await
.expect("create tag");
BranchManager::new(table.file_io().clone(), table.location().to_string())
.create_branch_from_tag("b1", "branch-source")
.await
.expect("create branch");
table
.file_io()
.delete_dir(&snapshot_manager.snapshot_dir())
.await
.expect("delete main snapshots");
let batches = ctx
.sql("SELECT id FROM vector_search('paimon.default.test_java_vindex_vector$branch_b1', 'embedding', '[1.0, 0.0, 0.0, 0.0]', 3)")
.await
.expect("SQL should parse")
.collect()
.await
.expect("branch query should execute");
let ids = extract_ids(&batches);
assert_eq!(ids.len(), 3);
assert!(ids.contains(&0));
}
#[tokio::test]
async fn test_vector_search_top6_returns_all() {
let (ctx, _catalog, _tmp) = create_lumina_vector_search_context().await;
let batches = ctx
.sql("SELECT id FROM vector_search('paimon.default.test_lumina_vector', 'embedding', '[1.0, 0.0, 0.0, 0.0]', 6)")
.await
.expect("SQL should parse")
.collect()
.await
.expect("query should execute");
let ids = extract_ids(&batches);
assert_eq!(ids, vec![0, 1, 2, 3, 4, 5]);
}
#[tokio::test]
async fn test_vector_search_without_matching_index_returns_empty() {
let (ctx, _catalog, _tmp) = create_lumina_vector_search_context().await;
let batches = ctx
.sql("SELECT id FROM vector_search('paimon.default.test_lumina_vector', 'missing_embedding', '[1.0]', 10)")
.await
.expect("SQL should parse")
.collect()
.await
.expect("query should execute");
let total_rows: usize = batches.iter().map(|batch| batch.num_rows()).sum();
assert_eq!(
total_rows, 0,
"vector_search without a matching Lumina index should not fall back to a full table scan"
);
}
#[tokio::test]
async fn test_vector_search_java_vindex_table() {
let (ctx, _catalog, _tmp) = create_java_vindex_vector_search_context().await;
let batches = ctx
.sql("SELECT id FROM vector_search('paimon.default.test_java_vindex_vector', 'embedding', '[1.0, 0.0, 0.0, 0.0]', 3)")
.await
.expect("SQL should parse")
.collect()
.await
.expect("query should execute");
let ids = extract_ids(&batches);
assert_eq!(ids, vec![0, 1, 2]);
}
#[tokio::test]
async fn test_vector_search_lateral_join_uses_query_vectors() {
let (ctx, _catalog, _tmp) = create_java_vindex_vector_search_context().await;
let query_batch = build_vector_batch(
vec![10, 20],
vec![vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]],
);
let query_table = MemTable::try_new(query_batch.schema(), vec![vec![query_batch]])
.expect("Failed to create query vector table");
ctx.register_temp_table("paimon.default.queries", Arc::new(query_table))
.expect("Failed to register query vector table");
let batches = ctx
.sql(
"SELECT q.id AS query_id, r.id AS result_id \
FROM paimon.default.queries q \
CROSS JOIN LATERAL vector_search('paimon.default.test_java_vindex_vector', 'embedding', q.embedding, 2) AS r \
ORDER BY query_id, result_id",
)
.await
.expect("lateral vector_search SQL should parse")
.collect()
.await
.expect("lateral vector_search query should execute");
let rows = extract_query_result_ids(&batches);
assert_eq!(rows, vec![(10, 0), (10, 1), (20, 1), (20, 2)]);
}
#[tokio::test]
#[ignore = "requires LUMINA_LIB_PATH"]
async fn test_lumina_build_then_vector_search_query() {
let (ctx, catalog, _tmp) = create_empty_vector_search_context().await;
let identifier = Identifier::new("default", "lumina_build_query_e2e");
catalog
.create_table(&identifier, build_lumina_table_schema(), false)
.await
.expect("Failed to create table");
let table = catalog
.get_table(&identifier)
.await
.expect("Failed to load table");
let write_builder = table
.new_write_builder()
.with_commit_user("test-user")
.expect("Failed to configure write builder");
let mut table_write = write_builder
.new_write()
.expect("Failed to create table write");
table_write
.write_arrow_batch(&build_vector_batch(
vec![0, 1, 2, 3, 4, 5],
vec![
vec![1.0, 0.0],
vec![0.9, 0.1],
vec![0.0, 1.0],
vec![-1.0, 0.0],
vec![0.0, -1.0],
vec![0.7, 0.3],
],
))
.await
.expect("Failed to write vector batch");
let messages = table_write
.prepare_commit()
.await
.expect("Failed to prepare commit");
write_builder
.new_commit()
.commit(messages)
.await
.expect("Failed to commit vector data");
ctx.sql("CALL sys.create_lumina_index(table => 'default.lumina_build_query_e2e', index_column => 'embedding')")
.await
.expect("Lumina index build SQL should parse")
.collect()
.await
.expect("Lumina index build SQL should execute");
let index_batches = ctx
.sql("SELECT index_type, row_count, row_range_start, row_range_end, index_field_name FROM paimon.default.`lumina_build_query_e2e$table_indexes` WHERE index_type = 'lumina'")
.await
.expect("index metadata SQL should parse")
.collect()
.await
.expect("index metadata query should execute");
let index_rows = extract_index_rows(&index_batches);
assert_eq!(
index_rows,
vec![
("lumina".to_string(), 3, 0, 2, "embedding".to_string()),
("lumina".to_string(), 3, 3, 5, "embedding".to_string()),
]
);
let search_batches = ctx
.sql("SELECT id FROM vector_search('paimon.default.lumina_build_query_e2e', 'embedding', '[1.0, 0.0]', 2)")
.await
.expect("vector_search SQL should parse")
.collect()
.await
.expect("vector_search query should execute");
let ids = extract_ids(&search_batches);
assert_eq!(ids.len(), 2);
assert!(ids.contains(&0), "exact vector match should be returned");
assert!(
ids.iter().any(|id| matches!(id, 1 | 5)),
"one same-direction neighbor should be returned, got {ids:?}"
);
}
#[tokio::test]
async fn test_vindex_build_then_vector_search_query() {
let (ctx, catalog, _tmp) = create_empty_vector_search_context().await;
let identifier = Identifier::new("default", "vindex_build_query_e2e");
catalog
.create_table(&identifier, build_vindex_table_schema(), false)
.await
.expect("Failed to create table");
let table = catalog
.get_table(&identifier)
.await
.expect("Failed to load table");
let write_builder = table
.new_write_builder()
.with_commit_user("test-user")
.expect("Failed to configure write builder");
let mut table_write = write_builder
.new_write()
.expect("Failed to create table write");
table_write
.write_arrow_batch(&build_vector_batch(
vec![0, 1, 2, 3, 4, 5],
vec![
vec![1.0, 0.0],
vec![0.9, 0.1],
vec![0.0, 1.0],
vec![-1.0, 0.0],
vec![0.0, -1.0],
vec![0.7, 0.3],
],
))
.await
.expect("Failed to write vector batch");
let messages = table_write
.prepare_commit()
.await
.expect("Failed to prepare commit");
write_builder
.new_commit()
.commit(messages)
.await
.expect("Failed to commit vector data");
ctx.sql(
"CALL sys.create_global_index( \
table => 'default.vindex_build_query_e2e', \
index_column => 'embedding', \
index_type => 'ivf-flat', \
options => 'ivf-flat.dimension=2,ivf-flat.nlist=1,ivf-flat.distance.metric=l2')",
)
.await
.expect("vindex index build SQL should parse")
.collect()
.await
.expect("vindex index build SQL should execute");
let index_batches = ctx
.sql("SELECT index_type, row_count, row_range_start, row_range_end, index_field_name FROM paimon.default.`vindex_build_query_e2e$table_indexes` WHERE index_type = 'ivf-flat'")
.await
.expect("index metadata SQL should parse")
.collect()
.await
.expect("index metadata query should execute");
let index_rows = extract_index_rows(&index_batches);
assert_eq!(
index_rows,
vec![
("ivf-flat".to_string(), 3, 0, 2, "embedding".to_string()),
("ivf-flat".to_string(), 3, 3, 5, "embedding".to_string()),
]
);
let search_batches = ctx
.sql("SELECT id FROM vector_search('paimon.default.vindex_build_query_e2e', 'embedding', '[1.0, 0.0]', 2)")
.await
.expect("vector_search SQL should parse")
.collect()
.await
.expect("vector_search query should execute");
let ids = extract_ids(&search_batches);
assert_eq!(ids, vec![0, 1]);
}
}
mod hybrid_search_tests {
use std::sync::Arc;
#[cfg(feature = "fulltext")]
use datafusion::arrow::array::{Array, Int64Array};
use datafusion::arrow::array::{Float32Array, Int32Array};
use paimon::catalog::Identifier;
use paimon::table::BranchManager;
use paimon::{Catalog, CatalogOptions, FileSystemCatalog, Options};
use paimon_datafusion::SQLContext;
fn extract_test_warehouse(archive_name: &str) -> (tempfile::TempDir, String) {
let archive_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("testdata")
.join(archive_name);
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_hybrid_search_context(
) -> (SQLContext, Arc<FileSystemCatalog>, tempfile::TempDir) {
let (tmp, warehouse) = extract_test_warehouse("test_java_vindex_vector.tar.gz");
let mut options = Options::new();
options.set(CatalogOptions::WAREHOUSE, warehouse);
let catalog = Arc::new(FileSystemCatalog::new(options).expect("Failed to create catalog"));
let mut ctx = SQLContext::new();
ctx.register_catalog("paimon", catalog.clone())
.await
.expect("Failed to register catalog");
(ctx, catalog, tmp)
}
fn extract_ids(batches: &[datafusion::arrow::record_batch::RecordBatch]) -> Vec<i32> {
let mut ids = Vec::new();
for batch in batches {
let id_array = batch
.column_by_name("id")
.and_then(|c| c.as_any().downcast_ref::<Int32Array>())
.expect("Expected Int32Array for id");
for i in 0..batch.num_rows() {
ids.push(id_array.value(i));
}
}
ids.sort();
ids
}
fn extract_scored_ids(
batches: &[datafusion::arrow::record_batch::RecordBatch],
) -> Vec<(i32, f32)> {
let mut rows = Vec::new();
for batch in batches {
let id_array = batch
.column_by_name("id")
.and_then(|column| column.as_any().downcast_ref::<Int32Array>())
.expect("Expected Int32Array for id");
let score_array = batch
.column_by_name("__paimon_search_score")
.and_then(|column| column.as_any().downcast_ref::<Float32Array>())
.expect("Expected Float32Array for __paimon_search_score");
for row in 0..batch.num_rows() {
rows.push((id_array.value(row), score_array.value(row)));
}
}
rows
}
fn extract_scores(batches: &[datafusion::arrow::record_batch::RecordBatch]) -> Vec<f32> {
let mut scores = Vec::new();
for batch in batches {
assert_eq!(batch.num_columns(), 1, "only the score column was selected");
let score_array = batch
.column_by_name("__paimon_search_score")
.and_then(|column| column.as_any().downcast_ref::<Float32Array>())
.expect("Expected Float32Array for __paimon_search_score");
scores.extend((0..batch.num_rows()).map(|row| score_array.value(row)));
}
scores
}
#[tokio::test]
async fn test_hybrid_search_exposes_ranked_scores() {
let (ctx, _catalog, _tmp) = create_hybrid_search_context().await;
let batches = ctx
.sql(
"SELECT __paimon_search_score, id FROM hybrid_search( \
'paimon.default.test_java_vindex_vector', \
array(named_struct( \
'field', 'embedding', \
'query_vector', array(1.0, 0.0, 0.0, 0.0), \
'limit', 3, \
'weight', 1.0)), \
array(), \
3, \
'rrf') \
ORDER BY __paimon_search_score DESC",
)
.await
.expect("hybrid_search score SQL should parse")
.collect()
.await
.expect("hybrid_search score query should execute");
let rows = extract_scored_ids(&batches);
assert_eq!(rows.len(), 3);
for ((id, score), (expected_id, expected_score)) in
rows.into_iter()
.zip([(0, 1.0 / 61.0), (1, 1.0 / 62.0), (2, 1.0 / 63.0)])
{
assert_eq!(id, expected_id);
assert!(
(score - expected_score).abs() < 1e-6,
"score for id {id}: expected {expected_score}, got {score}"
);
}
}
#[tokio::test]
async fn test_hybrid_search_score_only_and_empty_results() {
let (ctx, _catalog, _tmp) = create_hybrid_search_context().await;
let score_batches = ctx
.sql(
"SELECT __paimon_search_score FROM hybrid_search( \
'paimon.default.test_java_vindex_vector', \
array(named_struct( \
'field', 'embedding', \
'query_vector', array(1.0, 0.0, 0.0, 0.0), \
'limit', 3, \
'weight', 1.0)), \
array(), \
3, \
'rrf') \
ORDER BY __paimon_search_score DESC",
)
.await
.expect("score-only hybrid_search SQL should parse")
.collect()
.await
.expect("score-only hybrid_search query should execute");
let scores = extract_scores(&score_batches);
assert_eq!(scores.len(), 3);
for (score, expected) in scores.into_iter().zip([1.0 / 61.0, 1.0 / 62.0, 1.0 / 63.0]) {
assert!((score - expected).abs() < 1e-6);
}
let empty_batches = ctx
.sql(
"SELECT __paimon_search_score FROM hybrid_search( \
'paimon.default.test_java_vindex_vector', \
array(named_struct( \
'field', 'missing_embedding', \
'query_vector', array(1.0), \
'limit', 3, \
'weight', 1.0)), \
array(), \
3, \
'rrf')",
)
.await
.expect("empty hybrid_search score SQL should parse")
.collect()
.await
.expect("empty hybrid_search score query should execute");
assert_eq!(
empty_batches
.iter()
.map(|batch| batch.num_rows())
.sum::<usize>(),
0
);
}
#[cfg(feature = "fulltext")]
#[tokio::test]
async fn test_hybrid_search_score_with_row_tracking_only_fulltext() {
let (_tmp, ctx) = super::common::setup_sql_context().await;
super::common::exec(
&ctx,
"CREATE TABLE paimon.test_db.hybrid_raw_fulltext (id INT, content STRING) \
WITH ( \
'row-tracking.enabled' = 'true', \
'global-index.search-mode' = 'full')",
)
.await;
super::common::exec(
&ctx,
"INSERT INTO paimon.test_db.hybrid_raw_fulltext VALUES \
(1, 'paimon search'), (2, 'other'), (3, 'paimon table')",
)
.await;
const SEARCH: &str = "hybrid_search( \
'paimon.test_db.hybrid_raw_fulltext', \
array(), \
array(named_struct( \
'column', 'content', \
'query', 'paimon', \
'limit', 10, \
'weight', 1.0)), \
10, \
'rrf')";
let explicit_sql = format!("SELECT id, __paimon_search_score FROM {SEARCH} ORDER BY id");
let explicit_batches = ctx
.sql(&explicit_sql)
.await
.expect("row-tracking-only score SQL should parse")
.collect()
.await
.expect("row-tracking-only score query should execute");
let mut ids = Vec::new();
for batch in &explicit_batches {
let id_array = batch
.column_by_name("id")
.and_then(|column| column.as_any().downcast_ref::<Int32Array>())
.expect("id column");
let score_array = batch
.column_by_name("__paimon_search_score")
.and_then(|column| column.as_any().downcast_ref::<Float32Array>())
.expect("score column");
assert_eq!(score_array.null_count(), 0);
for row in 0..batch.num_rows() {
ids.push(id_array.value(row));
assert!(score_array.value(row) > 0.0);
}
}
assert_eq!(ids, vec![1, 3]);
let limited_sql = format!("SELECT id, __paimon_search_score FROM {SEARCH} LIMIT 2");
let limited_batches = ctx
.sql(&limited_sql)
.await
.expect("row-tracking-only limited SQL should parse")
.collect()
.await
.expect("row-tracking-only limited query should execute");
assert_eq!(extract_scored_ids(&limited_batches).len(), 2);
let projected_sql = format!("SELECT id FROM {SEARCH} ORDER BY id");
let projected_batches = ctx
.sql(&projected_sql)
.await
.expect("row-tracking-only projected SQL should parse")
.collect()
.await
.expect("row-tracking-only projected query should execute");
assert_eq!(extract_ids(&projected_batches), vec![1, 3]);
let count_sql = format!("SELECT COUNT(*) AS matches FROM {SEARCH}");
let count_batches = ctx
.sql(&count_sql)
.await
.expect("row-tracking-only aggregate SQL should parse")
.collect()
.await
.expect("row-tracking-only aggregate query should execute");
let matches = count_batches
.first()
.and_then(|batch| batch.column_by_name("matches"))
.and_then(|column| column.as_any().downcast_ref::<Int64Array>())
.expect("matches column");
assert_eq!(matches.value(0), 2);
let star_sql = format!("SELECT * FROM {SEARCH}");
let star_batches = ctx
.sql(&star_sql)
.await
.expect("row-tracking-only SELECT * SQL should parse")
.collect()
.await
.expect("row-tracking-only SELECT * query should execute");
assert_eq!(
star_batches
.iter()
.map(|batch| batch.num_rows())
.sum::<usize>(),
2
);
for batch in &star_batches {
assert_eq!(batch.num_columns(), 3);
assert!(batch.column_by_name("__paimon_search_score").is_some());
assert!(batch.column_by_name("_ROW_ID").is_none());
}
}
#[tokio::test]
async fn test_hybrid_search_multiple_vector_routes_spark_shape() {
let (ctx, _catalog, _tmp) = create_hybrid_search_context().await;
let batches = ctx
.sql(
"SELECT id FROM hybrid_search( \
'paimon.default.test_java_vindex_vector', \
array(named_struct( \
'field', 'embedding', \
'query_vector', array(1.0, 0.0, 0.0, 0.0), \
'limit', 3, \
'weight', 1.0), \
named_struct( \
'field', 'embedding', \
'query_vector', array(0.9, 0.1, 0.0, 0.0), \
'limit', 3, \
'weight', 1.0)), \
array(), \
3, \
'rrf')",
)
.await
.expect("hybrid_search SQL should parse")
.collect()
.await
.expect("hybrid_search query should execute");
assert_eq!(extract_ids(&batches), vec![0, 1, 2]);
}
#[tokio::test]
async fn test_hybrid_search_branch() {
let (ctx, catalog, _tmp) = create_hybrid_search_context().await;
let identifier = Identifier::new("default", "test_java_vindex_vector");
let table = catalog.get_table(&identifier).await.expect("load table");
let snapshot = table
.snapshot_manager()
.get_latest_snapshot()
.await
.expect("load latest snapshot")
.expect("latest snapshot");
table
.tag_manager()
.create("branch-source", &snapshot)
.await
.expect("create tag");
BranchManager::new(table.file_io().clone(), table.location().to_string())
.create_branch_from_tag("b1", "branch-source")
.await
.expect("create branch");
let batches = ctx
.sql(
"SELECT id FROM hybrid_search( \
'paimon.default.test_java_vindex_vector$branch_b1', \
array(named_struct( \
'field', 'embedding', \
'query_vector', array(1.0, 0.0, 0.0, 0.0), \
'limit', 3, \
'weight', 1.0)), \
array(), \
3, \
'rrf')",
)
.await
.expect("hybrid_search SQL should parse")
.collect()
.await
.expect("branch query should execute");
assert_eq!(extract_ids(&batches), vec![0, 1, 2]);
}
}