use std::collections::HashMap;
use std::str::FromStr;
use strum::IntoEnumIterator;
use crate::config::HudiConfigs;
use crate::config::error::ConfigError;
use crate::config::read::HudiReadConfig;
pub use crate::config::read::QueryType;
use crate::expr::filter::{Filter, from_str_tuples};
#[derive(Clone, Debug, Default)]
pub struct ReadOptions {
pub filters: Vec<Filter>,
pub projection: Option<Vec<String>>,
pub hudi_options: HashMap<String, String>,
}
impl ReadOptions {
pub fn new() -> Self {
Self::default()
}
pub fn with_query_type(mut self, query_type: QueryType) -> Self {
self.hudi_options.insert(
HudiReadConfig::QueryType.as_ref().to_string(),
query_type.as_ref().to_string(),
);
self
}
pub fn with_as_of_timestamp<S: AsRef<str>>(mut self, timestamp: S) -> Self {
self.hudi_options.insert(
HudiReadConfig::AsOfTimestamp.as_ref().to_string(),
timestamp.as_ref().to_string(),
);
self
}
pub fn with_start_timestamp<S: AsRef<str>>(mut self, timestamp: S) -> Self {
self.hudi_options.insert(
HudiReadConfig::StartTimestamp.as_ref().to_string(),
timestamp.as_ref().to_string(),
);
self
}
pub fn with_end_timestamp<S: AsRef<str>>(mut self, timestamp: S) -> Self {
self.hudi_options.insert(
HudiReadConfig::EndTimestamp.as_ref().to_string(),
timestamp.as_ref().to_string(),
);
self
}
pub fn with_batch_size(mut self, size: usize) -> crate::Result<Self> {
if size == 0 {
let key = HudiReadConfig::StreamBatchSize.as_ref();
return Err(ConfigError::InvalidValue(format!("{key} must be > 0, got 0")).into());
}
self.hudi_options.insert(
HudiReadConfig::StreamBatchSize.as_ref().to_string(),
size.to_string(),
);
Ok(self)
}
pub fn with_filters<I, S1, S2, S3>(mut self, filters: I) -> crate::Result<Self>
where
I: IntoIterator<Item = (S1, S2, S3)>,
S1: AsRef<str>,
S2: AsRef<str>,
S3: AsRef<str>,
{
self.filters = from_str_tuples(filters.into_iter().map(|(f, o, v)| {
(
f.as_ref().to_string(),
o.as_ref().to_string(),
v.as_ref().to_string(),
)
}))?;
Ok(self)
}
pub fn with_projection<I, S>(mut self, columns: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.projection = Some(columns.into_iter().map(|s| s.into()).collect());
self
}
pub fn with_hudi_option<K, V>(mut self, key: K, value: V) -> Self
where
K: Into<String>,
V: Into<String>,
{
self.hudi_options.insert(key.into(), value.into());
self
}
pub fn with_hudi_options<I, K, V>(mut self, opts: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
for (k, v) in opts {
self.hudi_options.insert(k.into(), v.into());
}
self
}
pub fn query_type(&self) -> crate::Result<QueryType> {
match self.hudi_options.get(HudiReadConfig::QueryType.as_ref()) {
Some(s) => Ok(QueryType::from_str(s)?),
None => Ok(QueryType::default()),
}
}
pub fn as_of_timestamp(&self) -> Option<&str> {
self.hudi_options
.get(HudiReadConfig::AsOfTimestamp.as_ref())
.map(|s| s.as_str())
}
pub fn start_timestamp(&self) -> Option<&str> {
self.hudi_options
.get(HudiReadConfig::StartTimestamp.as_ref())
.map(|s| s.as_str())
}
pub fn end_timestamp(&self) -> Option<&str> {
self.hudi_options
.get(HudiReadConfig::EndTimestamp.as_ref())
.map(|s| s.as_str())
}
pub(crate) fn with_sanitized_timestamps(&self) -> Self {
let mut opts = self.clone();
match opts.query_type().unwrap_or_default() {
QueryType::Snapshot => {
opts.hudi_options
.remove(HudiReadConfig::StartTimestamp.as_ref());
opts.hudi_options
.remove(HudiReadConfig::EndTimestamp.as_ref());
}
QueryType::Incremental => {
opts.hudi_options
.remove(HudiReadConfig::AsOfTimestamp.as_ref());
}
}
opts
}
pub fn is_read_optimized(&self) -> crate::Result<bool> {
let key = HudiReadConfig::UseReadOptimizedMode.as_ref();
match self.hudi_options.get(key) {
Some(s) => {
let parsed = s
.parse::<bool>()
.map_err(|e| ConfigError::ParseBool(key.to_string(), s.clone(), e))?;
Ok(parsed)
}
None => Ok(false),
}
}
pub fn with_defaults_from(&self, configs: &HudiConfigs) -> crate::Result<Self> {
let mut resolved = self.clone();
for key in HudiReadConfig::iter() {
let key_str = key.key_str();
if !resolved.hudi_options.contains_key(key_str)
&& let Some(val) = configs.try_get(key)?
{
let s: String = val.into();
resolved.hudi_options.insert(key_str.to_string(), s);
}
}
Ok(resolved)
}
pub fn batch_size(&self) -> crate::Result<Option<usize>> {
let key = HudiReadConfig::StreamBatchSize.as_ref();
match self.hudi_options.get(key) {
Some(s) => {
let parsed = s
.parse::<usize>()
.map_err(|e| ConfigError::ParseInt(key.to_string(), s.clone(), e))?;
if parsed == 0 {
return Err(
ConfigError::InvalidValue(format!("{key} must be > 0, got 0")).into(),
);
}
Ok(Some(parsed))
}
None => Ok(None),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_with_projection() {
let options = ReadOptions::new().with_projection(["col1", "col2", "col3"]);
assert_eq!(
options.projection,
Some(vec![
"col1".to_string(),
"col2".to_string(),
"col3".to_string()
])
);
}
#[test]
fn test_with_query_type_round_trip() -> crate::Result<()> {
let snapshot = ReadOptions::new();
assert_eq!(snapshot.query_type()?, QueryType::Snapshot);
let incr = ReadOptions::new().with_query_type(QueryType::Incremental);
assert_eq!(incr.query_type()?, QueryType::Incremental);
assert_eq!(
incr.hudi_options
.get(HudiReadConfig::QueryType.as_ref())
.map(String::as_str),
Some("incremental")
);
Ok(())
}
#[test]
fn test_with_timestamps_round_trip() -> crate::Result<()> {
let opts = ReadOptions::new()
.with_as_of_timestamp("20240101120000000")
.with_start_timestamp("20240101000000000")
.with_end_timestamp("20240201000000000");
assert_eq!(opts.as_of_timestamp(), Some("20240101120000000"));
assert_eq!(opts.start_timestamp(), Some("20240101000000000"));
assert_eq!(opts.end_timestamp(), Some("20240201000000000"));
Ok(())
}
#[test]
fn test_with_batch_size_round_trip() -> crate::Result<()> {
let opts = ReadOptions::new();
assert_eq!(opts.batch_size()?, None);
let opts = ReadOptions::new().with_batch_size(2048)?;
assert_eq!(opts.batch_size()?, Some(2048));
assert_eq!(
opts.hudi_options
.get(HudiReadConfig::StreamBatchSize.as_ref())
.map(String::as_str),
Some("2048")
);
let err = ReadOptions::new().with_batch_size(0).unwrap_err();
assert!(err.to_string().contains("must be > 0"));
let opts = ReadOptions::new()
.with_hudi_option(HudiReadConfig::StreamBatchSize.as_ref(), "not_a_number");
let err = opts.batch_size().unwrap_err();
assert!(err.to_string().contains("not_a_number"));
Ok(())
}
#[test]
fn test_with_filters_validates_at_build_time() {
let err = ReadOptions::new()
.with_filters([("col", "BAD_OP", "x")])
.unwrap_err();
assert!(err.to_string().contains("BAD_OP"));
let err = ReadOptions::new()
.with_filters([("col", "IN", "")])
.unwrap_err();
assert!(err.to_string().contains("at least one value"));
}
#[test]
fn test_with_hudi_options() {
let options = ReadOptions::new()
.with_hudi_option("hoodie.read.use.read_optimized.mode", "true")
.with_hudi_options([("a", "1"), ("b", "2")]);
assert_eq!(
options
.hudi_options
.get("hoodie.read.use.read_optimized.mode"),
Some(&"true".to_string())
);
assert_eq!(options.hudi_options.get("a"), Some(&"1".to_string()));
assert_eq!(options.hudi_options.get("b"), Some(&"2".to_string()));
}
#[test]
fn test_is_read_optimized_round_trip() -> crate::Result<()> {
assert!(!ReadOptions::new().is_read_optimized()?);
let opts = ReadOptions::new()
.with_hudi_option(HudiReadConfig::UseReadOptimizedMode.as_ref(), "true");
assert!(opts.is_read_optimized()?);
let opts = ReadOptions::new()
.with_hudi_option(HudiReadConfig::UseReadOptimizedMode.as_ref(), "false");
assert!(!opts.is_read_optimized()?);
for invalid in ["1", "yes", "on", "TRUE_ISH"] {
let opts = ReadOptions::new()
.with_hudi_option(HudiReadConfig::UseReadOptimizedMode.as_ref(), invalid);
let err = opts.is_read_optimized().unwrap_err();
assert!(
err.to_string().contains(invalid),
"expected error to mention '{invalid}'"
);
}
Ok(())
}
#[test]
fn test_query_type_from_str_invalid_errors() {
let opts =
ReadOptions::new().with_hudi_option(HudiReadConfig::QueryType.as_ref(), "garbage");
let err = opts.query_type().unwrap_err();
assert!(err.to_string().contains("garbage"));
}
#[test]
fn test_debug_format() -> crate::Result<()> {
let options = ReadOptions::new()
.with_filters([("city", "=", "sf")])?
.with_projection(["id"])
.with_batch_size(1000)?;
let debug_str = format!("{options:?}");
assert!(debug_str.contains("ReadOptions"));
assert!(debug_str.contains("filters"));
assert!(debug_str.contains("projection"));
assert!(debug_str.contains("hudi_options"));
Ok(())
}
}