use std::sync::Arc;
use crate::config::HudiConfigs;
use crate::config::table::HudiTableConfig;
use crate::error::Result;
use crate::file_group::reader_v2::MAX_INSTANT_TIME;
use crate::file_group::reader_v2::engine::HoodieFileGroupReader;
use crate::file_group::reader_v2::input_split::InputSplit;
use crate::file_group::reader_v2::read_stats::HoodieReadStats;
use crate::file_group::reader_v2::reader_context::ReaderContext;
use crate::file_group::reader_v2::reader_parameters::ReaderParameters;
use crate::file_group::reader_v2::schema_handler::FileGroupReaderSchemaHandler;
use crate::storage::{RowFilterBuilder, Storage};
use crate::table::builder::OptionResolver;
use crate::timeline::selector::InstantRange;
use arrow_array::cast::AsArray;
use arrow_array::types::{
Date32Type, Decimal128Type, Float32Type, Float64Type, Int32Type, Int64Type,
TimestampMicrosecondType, TimestampMillisecondType, TimestampNanosecondType,
TimestampSecondType,
};
use arrow_array::{Array, BooleanArray, RecordBatch};
use arrow_schema::{ArrowError, DataType, SchemaRef};
use hudi_test::QuickstartTripsTable;
use parquet::arrow::ProjectionMask;
use parquet::arrow::arrow_reader::{ArrowPredicateFn, RowFilter};
pub enum SchemaSpec {
None,
Projection(SchemaRef),
Explicit {
data: SchemaRef,
requested: SchemaRef,
},
BuilderProjection(SchemaRef),
ExplicitJson {
data_json: &'static str,
requested_json: &'static str,
},
}
pub enum Expected {
Rows {
sort_key: &'static str,
columns: &'static [&'static str],
rows: &'static [&'static [&'static str]],
},
GoldParquet,
Custom {
rows: usize,
validate: fn(&RecordBatch) -> std::result::Result<(), String>,
},
ErrContains(&'static str),
}
#[derive(Clone, Copy)]
pub enum FilterPredicate {
Eq(&'static str),
Gt(&'static str),
Lt(&'static str),
In(&'static [&'static str]),
}
pub struct RowFilterSpec {
pub column: &'static str,
pub predicate: FilterPredicate,
pub mor_pk_safe: bool,
}
pub type StatsCheck = fn(&HoodieReadStats) -> std::result::Result<(), String>;
pub struct FgReaderCase {
pub name: &'static str,
pub fixture: QuickstartTripsTable,
pub partition: &'static str,
pub base_file: &'static str,
pub log_files: &'static [&'static str],
pub schema: SchemaSpec,
pub expect_output_columns: Option<&'static [&'static str]>,
pub merge_mode: Option<&'static str>,
pub latest_commit_time: Option<&'static str>,
pub instant_range: Option<fn() -> InstantRange>,
pub reader_parameters: Option<ReaderParameters>,
pub reader_config: &'static [(&'static str, &'static str)],
pub row_filter: Option<RowFilterSpec>,
pub expect_stats: Option<StatsCheck>,
pub expected: Expected,
}
impl Default for FgReaderCase {
fn default() -> Self {
Self {
name: "UNNAMED_CASE",
fixture: QuickstartTripsTable::V9Mor8I4UCommitTime,
partition: "",
base_file: "",
log_files: &[],
schema: SchemaSpec::None,
expect_output_columns: None,
merge_mode: None,
latest_commit_time: None,
instant_range: None,
reader_parameters: None,
reader_config: &[],
row_filter: None,
expect_stats: None,
expected: Expected::Rows {
sort_key: "id",
columns: &[],
rows: &[],
},
}
}
}
pub async fn create_configs_and_storage(
table_path: &str,
) -> Result<(Arc<HudiConfigs>, Arc<Storage>)> {
let empty_opts: Vec<(&str, &str)> = vec![];
let mut resolver = OptionResolver::new_with_options(table_path, empty_opts);
resolver.resolve_options().await?;
let hudi_configs = Arc::new(HudiConfigs::new(resolver.hudi_options));
let storage = Storage::new(Arc::new(resolver.storage_options), hudi_configs.clone())?;
Ok((hudi_configs, storage))
}
fn join_partition(partition: &str, file: &str) -> String {
if partition.is_empty() {
file.to_string()
} else {
format!("{partition}/{file}")
}
}
fn build_row_filter_builder(spec: &RowFilterSpec) -> RowFilterBuilder {
let column = spec.column.to_string();
let pred = spec.predicate;
Arc::new(move |parquet_schema, _projected| {
let root = parquet_schema.root_schema();
let root_idx = root.get_fields().iter().position(|f| f.name() == column)?;
let mask = ProjectionMask::roots(parquet_schema, [root_idx]);
let column = column.clone();
let predicate = ArrowPredicateFn::new(mask, move |batch: RecordBatch| {
let col = batch.column_by_name(&column).ok_or_else(|| {
ArrowError::ComputeError(format!(
"row_filter predicate column '{column}' not in predicate batch"
))
})?;
compare_column_predicate(col, &pred)
});
Some(RowFilter::new(vec![Box::new(predicate)]))
})
}
fn parse_literal<T: std::str::FromStr>(s: &str, ty: &str) -> std::result::Result<T, ArrowError> {
s.parse::<T>()
.map_err(|_| ArrowError::ComputeError(format!("cannot parse '{s}' as {ty}")))
}
fn parse_decimal_literal(s: &str, scale: i8) -> std::result::Result<i128, ArrowError> {
let err = |m: &str| ArrowError::ComputeError(format!("decimal literal '{s}': {m}"));
let (neg, digits) = match s.strip_prefix('-') {
Some(rest) => (true, rest),
None => (false, s),
};
let (int_part, frac_part) = match digits.split_once('.') {
Some((i, f)) => (i, f),
None => (digits, ""),
};
let scale = usize::try_from(scale).map_err(|_| err("negative scale unsupported"))?;
if frac_part.len() > scale {
return Err(err("more fraction digits than the column scale"));
}
let mut unscaled = String::with_capacity(int_part.len() + scale);
unscaled.push_str(int_part);
unscaled.push_str(frac_part);
for _ in frac_part.len()..scale {
unscaled.push('0');
}
let v: i128 = parse_literal(&unscaled, "i128")?;
Ok(if neg { -v } else { v })
}
fn eval_predicate<T: PartialOrd + Copy>(
len: usize,
is_null: impl Fn(usize) -> bool,
value: impl Fn(usize) -> T,
predicate: &FilterPredicate,
parse: impl Fn(&str) -> std::result::Result<T, ArrowError>,
) -> std::result::Result<BooleanArray, ArrowError> {
let matches: Box<dyn Fn(T) -> bool> = match predicate {
FilterPredicate::Eq(lit) => {
let rhs = parse(lit)?;
Box::new(move |lhs| lhs == rhs)
}
FilterPredicate::Gt(lit) => {
let rhs = parse(lit)?;
Box::new(move |lhs| lhs > rhs)
}
FilterPredicate::Lt(lit) => {
let rhs = parse(lit)?;
Box::new(move |lhs| lhs < rhs)
}
FilterPredicate::In(lits) => {
let set: Vec<T> = lits
.iter()
.map(|lit| parse(lit))
.collect::<std::result::Result<_, _>>()?;
Box::new(move |lhs| set.contains(&lhs))
}
};
Ok((0..len)
.map(|i| (!is_null(i)).then(|| matches(value(i))))
.collect())
}
fn compare_column_predicate(
col: &Arc<dyn Array>,
predicate: &FilterPredicate,
) -> std::result::Result<BooleanArray, ArrowError> {
match col.data_type() {
DataType::Utf8 => {
let arr = col.as_string::<i32>();
eval_str_predicate(arr.len(), |i| arr.is_null(i), |i| arr.value(i), predicate)
}
DataType::LargeUtf8 => {
let arr = col.as_string::<i64>();
eval_str_predicate(arr.len(), |i| arr.is_null(i), |i| arr.value(i), predicate)
}
DataType::Int32 => {
let arr = col.as_primitive::<Int32Type>();
eval_predicate(
arr.len(),
|i| arr.is_null(i),
|i| arr.value(i),
predicate,
|s| parse_literal::<i32>(s, "i32"),
)
}
DataType::Int64 => {
let arr = col.as_primitive::<Int64Type>();
eval_predicate(
arr.len(),
|i| arr.is_null(i),
|i| arr.value(i),
predicate,
|s| parse_literal::<i64>(s, "i64"),
)
}
DataType::Float32 => {
let arr = col.as_primitive::<Float32Type>();
eval_predicate(
arr.len(),
|i| arr.is_null(i),
|i| arr.value(i),
predicate,
|s| parse_literal::<f32>(s, "f32"),
)
}
DataType::Float64 => {
let arr = col.as_primitive::<Float64Type>();
eval_predicate(
arr.len(),
|i| arr.is_null(i),
|i| arr.value(i),
predicate,
|s| parse_literal::<f64>(s, "f64"),
)
}
DataType::Boolean => {
let arr = col.as_boolean();
eval_predicate(
arr.len(),
|i| arr.is_null(i),
|i| arr.value(i),
predicate,
|s| parse_literal::<bool>(s, "bool"),
)
}
DataType::Date32 => {
let arr = col.as_primitive::<Date32Type>();
eval_predicate(
arr.len(),
|i| arr.is_null(i),
|i| arr.value(i),
predicate,
|s| parse_literal::<i32>(s, "date32 days"),
)
}
DataType::Timestamp(unit, _tz) => {
let values: Vec<i64> = match unit {
arrow_schema::TimeUnit::Second => {
let a = col.as_primitive::<TimestampSecondType>();
(0..a.len()).map(|i| a.value(i)).collect()
}
arrow_schema::TimeUnit::Millisecond => {
let a = col.as_primitive::<TimestampMillisecondType>();
(0..a.len()).map(|i| a.value(i)).collect()
}
arrow_schema::TimeUnit::Microsecond => {
let a = col.as_primitive::<TimestampMicrosecondType>();
(0..a.len()).map(|i| a.value(i)).collect()
}
arrow_schema::TimeUnit::Nanosecond => {
let a = col.as_primitive::<TimestampNanosecondType>();
(0..a.len()).map(|i| a.value(i)).collect()
}
};
eval_predicate(
col.len(),
|i| col.is_null(i),
|i| values[i],
predicate,
|s| parse_literal::<i64>(s, "timestamp i64"),
)
}
DataType::Decimal128(_, scale) => {
let arr = col.as_primitive::<Decimal128Type>();
let scale = *scale;
eval_predicate(
arr.len(),
|i| arr.is_null(i),
|i| arr.value(i),
predicate,
move |s| parse_decimal_literal(s, scale),
)
}
other => Err(ArrowError::ComputeError(format!(
"row_filter unsupported column type {other} (add an arm in fg_harness)"
))),
}
}
fn eval_str_predicate<'a>(
len: usize,
is_null: impl Fn(usize) -> bool,
value: impl Fn(usize) -> &'a str,
predicate: &FilterPredicate,
) -> std::result::Result<BooleanArray, ArrowError> {
let predicate = *predicate;
Ok((0..len)
.map(|i| {
(!is_null(i)).then(|| {
let lhs = value(i);
match predicate {
FilterPredicate::Eq(rhs) => lhs == rhs,
FilterPredicate::Gt(rhs) => lhs > rhs,
FilterPredicate::Lt(rhs) => lhs < rhs,
FilterPredicate::In(set) => set.contains(&lhs),
}
})
})
.collect())
}
fn base_reader_context(case: &FgReaderCase, has_log_files: bool) -> ReaderContext {
let mut reader_context = ReaderContext::empty();
reader_context.latest_commit_time = case
.latest_commit_time
.unwrap_or(MAX_INSTANT_TIME)
.to_string();
reader_context.merge_mode = case
.merge_mode
.unwrap_or("COMMIT_TIME_ORDERING")
.to_string();
reader_context.instant_range = case.instant_range.map(|f| f());
reader_context.table_config.insert(
HudiTableConfig::OrderingFields.as_ref().to_string(),
"ts".to_string(),
);
reader_context.rebuild_record_context(case.partition.to_string());
reader_context.has_log_files = has_log_files;
for (key, value) in case.reader_config {
reader_context
.hoodie_reader_config
.insert((*key).to_string(), (*value).to_string());
}
reader_context
}
fn reader_parameters(case: &FgReaderCase) -> ReaderParameters {
case.reader_parameters.clone().unwrap_or_default()
}
async fn read_case(
case: &FgReaderCase,
table_path: &str,
) -> Result<(RecordBatch, HoodieReadStats)> {
let (_hudi_configs, storage) = create_configs_and_storage(table_path).await?;
let base_path = if case.base_file.is_empty() {
None
} else {
Some(join_partition(case.partition, case.base_file))
};
let log_paths: Vec<String> = case
.log_files
.iter()
.map(|lf| join_partition(case.partition, lf))
.collect();
let base_file_commit_time = case
.base_file
.parse::<crate::file_group::base_file::BaseFile>()
.ok()
.map(|base_file| base_file.commit_timestamp);
let input_split = InputSplit::new(
base_path.clone(),
base_file_commit_time,
log_paths,
case.partition.to_string(),
);
let has_log_files = !case.log_files.is_empty();
if let Some(spec) = &case.row_filter {
return read_case_with_filter(case, storage, input_split, base_path, has_log_files, spec)
.await;
}
match &case.schema {
SchemaSpec::None => {
let reader_context = base_reader_context(case, has_log_files);
let mut reader = HoodieFileGroupReader::new(
Arc::new(reader_context),
storage,
input_split,
reader_parameters(case),
None,
None,
)?;
let batch = reader.read().await?;
Ok((batch, reader.read_stats().clone()))
}
SchemaSpec::Projection(requested) => {
let reader_context = base_reader_context(case, has_log_files);
let data_schema: Option<SchemaRef> = if let Some(ref bp) = base_path {
crate::file_group::base_file::parquet::ParquetBaseFileReader::new(storage.clone())
.get_schema(bp)
.await
.ok()
.map(|s| Arc::new(s) as SchemaRef)
} else {
None
};
let mut reader = HoodieFileGroupReader::new(
Arc::new(reader_context),
storage,
input_split,
reader_parameters(case),
data_schema,
Some(requested.clone()),
)?;
let batch = reader.read().await?;
Ok((batch, reader.read_stats().clone()))
}
SchemaSpec::Explicit { data, requested } => {
let reader_context = base_reader_context(case, has_log_files);
let mut reader = HoodieFileGroupReader::new(
Arc::new(reader_context),
storage,
input_split,
reader_parameters(case),
Some(data.clone()),
Some(requested.clone()),
)?;
let batch = reader.read().await?;
Ok((batch, reader.read_stats().clone()))
}
SchemaSpec::BuilderProjection(requested) => {
let table_schema: Option<SchemaRef> = if let Some(ref bp) = base_path {
crate::file_group::base_file::parquet::ParquetBaseFileReader::new(storage.clone())
.get_schema(bp)
.await
.ok()
.map(|s| Arc::new(s) as SchemaRef)
} else {
None
};
let schema_handler = {
let mut handler = FileGroupReaderSchemaHandler::new();
if let Some(ts) = table_schema {
handler = handler.with_table_schema(ts.clone()).with_data_schema(ts);
}
handler = handler.with_requested_schema(requested.clone());
handler
};
let mut reader_context = base_reader_context(case, has_log_files);
reader_context.schema_handler = schema_handler;
let mut reader = HoodieFileGroupReader::builder()
.with_reader_context(Arc::new(reader_context))
.with_storage(storage)
.with_input_split(input_split)
.with_reader_parameters(reader_parameters(case))
.build()?;
let batch = reader.read().await?;
Ok((batch, reader.read_stats().clone()))
}
SchemaSpec::ExplicitJson {
data_json,
requested_json,
} => {
let data_arrow: SchemaRef = Arc::new(
crate::schema::resolver::avro_json_to_arrow_schema(data_json)?,
);
let requested_arrow: SchemaRef = Arc::new(
crate::schema::resolver::avro_json_to_arrow_schema(requested_json)?,
);
let schema_handler = FileGroupReaderSchemaHandler::new()
.with_table_schema(data_arrow.clone())
.with_data_schema(data_arrow)
.with_data_schema_json((*data_json).to_string())
.with_requested_schema(requested_arrow)
.with_requested_schema_json((*requested_json).to_string());
let mut reader_context = base_reader_context(case, has_log_files);
reader_context.schema_handler = schema_handler;
let mut reader = HoodieFileGroupReader::builder()
.with_reader_context(Arc::new(reader_context))
.with_storage(storage)
.with_input_split(input_split)
.with_reader_parameters(reader_parameters(case))
.build()?;
let batch = reader.read().await?;
Ok((batch, reader.read_stats().clone()))
}
}
}
async fn read_case_with_filter(
case: &FgReaderCase,
storage: Arc<Storage>,
input_split: InputSplit,
base_path: Option<String>,
has_log_files: bool,
spec: &RowFilterSpec,
) -> Result<(RecordBatch, HoodieReadStats)> {
use crate::error::CoreError;
let out_cols = case.expect_output_columns.ok_or_else(|| {
CoreError::ReadFileSliceError(
"row_filter cases must set expect_output_columns (drives the requested schema)".into(),
)
})?;
let bp = base_path.as_ref().ok_or_else(|| {
CoreError::ReadFileSliceError("row_filter cases require a base file".into())
})?;
let table_schema: SchemaRef = Arc::new(
crate::file_group::base_file::parquet::ParquetBaseFileReader::new(storage.clone())
.get_schema(bp)
.await?,
);
let requested_fields: Vec<arrow_schema::FieldRef> = out_cols
.iter()
.map(|name| {
table_schema
.column_with_name(name)
.map(|(_, f)| Arc::new(f.clone()))
.ok_or_else(|| {
CoreError::ReadFileSliceError(format!(
"expect_output_columns names '{name}' not in base parquet schema"
))
})
})
.collect::<Result<_>>()?;
let requested: SchemaRef = Arc::new(arrow_schema::Schema::new(requested_fields));
let schema_handler = FileGroupReaderSchemaHandler::new()
.with_table_schema(table_schema.clone())
.with_data_schema(table_schema)
.with_requested_schema(requested);
let mut reader_context = base_reader_context(case, has_log_files);
reader_context.schema_handler = schema_handler;
reader_context.table_config.insert(
HudiTableConfig::TableType.as_ref().to_string(),
"MERGE_ON_READ".to_string(),
);
let mut reader = HoodieFileGroupReader::builder()
.with_reader_context(Arc::new(reader_context))
.with_storage(storage)
.with_input_split(input_split)
.with_reader_parameters(reader_parameters(case))
.with_row_filter_builder(build_row_filter_builder(spec))
.with_mor_pk_safe(spec.mor_pk_safe)
.build()?;
let batch = reader.read().await?;
Ok((batch, reader.read_stats().clone()))
}
pub const NULL_SENTINEL: &str = "<null>";
fn sort_batch(batch: &RecordBatch, sort_key: &str) -> std::result::Result<RecordBatch, String> {
let idx = batch
.schema()
.index_of(sort_key)
.map_err(|e| format!("sort key column '{sort_key}' not found: {e}"))?;
let key_col = batch.column(idx).clone();
let indices = arrow_ord::sort::sort_to_indices(&key_col, None, None)
.map_err(|e| format!("sort_to_indices on '{sort_key}' failed: {e}"))?;
let columns: std::result::Result<Vec<_>, String> = batch
.columns()
.iter()
.map(|col| {
arrow_select::take::take(col, &indices, None).map_err(|e| format!("take failed: {e}"))
})
.collect();
let sorted = RecordBatch::try_new(batch.schema(), columns?)
.map_err(|e| format!("rebuild sorted batch failed: {e}"))?;
ensure_unique_sort_key(&sorted, sort_key)?;
Ok(sorted)
}
fn ensure_unique_sort_key(batch: &RecordBatch, sort_key: &str) -> std::result::Result<(), String> {
for row in 1..batch.num_rows() {
let prev = render_cell_raw(batch, sort_key, row - 1)?;
let cur = render_cell_raw(batch, sort_key, row)?;
let prev_null = column_is_null(batch, sort_key, row - 1)?;
let cur_null = column_is_null(batch, sort_key, row)?;
if prev_null == cur_null && prev == cur {
return Err(format!(
"duplicate sort key '{sort_key}' value ('{cur}'); row comparison \
requires a unique sort key"
));
}
}
Ok(())
}
fn column_is_null(batch: &RecordBatch, col: &str, row: usize) -> std::result::Result<bool, String> {
let idx = batch
.schema()
.index_of(col)
.map_err(|e| format!("column '{col}' not found: {e}"))?;
Ok(batch.column(idx).is_null(row))
}
fn render_cell(batch: &RecordBatch, col: &str, row: usize) -> std::result::Result<String, String> {
if column_is_null(batch, col, row)? {
return Ok(NULL_SENTINEL.to_string());
}
render_cell_raw(batch, col, row)
}
fn render_cell_raw(
batch: &RecordBatch,
col: &str,
row: usize,
) -> std::result::Result<String, String> {
let idx = batch
.schema()
.index_of(col)
.map_err(|e| format!("column '{col}' not found: {e}"))?;
arrow_cast::display::array_value_to_string(batch.column(idx), row)
.map_err(|e| format!("render cell col='{col}' row={row} failed: {e}"))
}
fn validate_rows(
batch: &RecordBatch,
sort_key: &str,
columns: &[&str],
rows: &[&[&str]],
) -> std::result::Result<(), String> {
let sorted = sort_batch(batch, sort_key)?;
if sorted.num_rows() != rows.len() {
return Err(format!(
"row count mismatch: actual={} expected={}",
sorted.num_rows(),
rows.len()
));
}
for (row_idx, expected_row) in rows.iter().enumerate() {
if expected_row.len() != columns.len() {
return Err(format!(
"expected row {row_idx} has {} cells but {} columns were named",
expected_row.len(),
columns.len()
));
}
for (col_idx, col) in columns.iter().enumerate() {
let actual = render_cell(&sorted, col, row_idx)?;
let expected = expected_row[col_idx];
if actual != expected {
return Err(format!(
"mismatch at row={row_idx} col='{col}': actual='{actual}' expected='{expected}'"
));
}
}
}
Ok(())
}
fn validate_gold(batch: &RecordBatch, gold_dir: &str) -> std::result::Result<(), String> {
let gold = hudi_test::gold::read_gold_parquet(gold_dir)?;
hudi_test::gold::compare_against_gold(batch, &gold)
}
pub async fn try_run_case(case: &FgReaderCase) -> std::result::Result<(), String> {
let table_path = case.fixture.path_to_mor_avro();
if let Expected::ErrContains(pattern) = case.expected {
return match read_case(case, &table_path).await {
Ok(_) => Err(format!(
"expected read to fail containing '{pattern}', but it succeeded"
)),
Err(e) => {
let dbg = format!("{e:?}");
if dbg.contains(pattern) {
Ok(())
} else {
Err(format!(
"error did not contain '{pattern}'; actual error: {dbg}"
))
}
}
};
}
let (batch, stats) = read_case(case, &table_path)
.await
.map_err(|e| format!("read failed: {e:?}"))?;
if let Some(expected_cols) = case.expect_output_columns {
let schema = batch.schema();
let actual_cols: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect();
if actual_cols != expected_cols {
return Err(format!(
"output columns mismatch: actual={actual_cols:?} expected={expected_cols:?}"
));
}
}
match &case.expected {
Expected::Rows {
sort_key,
columns,
rows,
} => validate_rows(&batch, sort_key, columns, rows),
Expected::GoldParquet => {
let gold_dir = case.fixture.gold_dir(hudi_test::TableFormat::MorAvro);
validate_gold(&batch, &gold_dir)
}
Expected::Custom { rows, validate } => {
if batch.num_rows() != *rows {
Err(format!(
"row count mismatch: actual={} expected={rows}",
batch.num_rows()
))
} else {
validate(&batch)
}
}
Expected::ErrContains(_) => unreachable!("handled above"),
}?;
if let Some(check) = case.expect_stats {
check(&stats).map_err(|m| format!("read_stats assertion failed: {m}"))?;
}
Ok(())
}
pub async fn run_case(case: FgReaderCase) {
if let Err(msg) = try_run_case(&case).await {
panic!("[{}] {msg}", case.name);
}
}
#[macro_export]
macro_rules! fg_case_test {
($name:ident, $case:expr) => {
#[tokio::test]
async fn $name() {
$crate::file_group::reader_v2::harness::run_case($case).await;
}
};
($name:ident, $case:expr, ignore = $reason:literal) => {
#[tokio::test]
#[ignore = $reason]
async fn $name() {
$crate::file_group::reader_v2::harness::run_case($case).await;
}
};
}