use std::fmt;
use std::sync::Arc;
use arrow::datatypes::{DataType, Field, Schema, SchemaRef, TimeUnit};
use async_trait::async_trait;
use datafusion::catalog::Session;
use datafusion::common::DataFusionError;
use datafusion::datasource::TableProvider;
use datafusion::logical_expr::{Expr, TableProviderFilterPushDown, TableType};
use datafusion::physical_plan::ExecutionPlan;
use chronix_core::{ColumnRole, ColumnType, MeasurementSchema};
use super::exec::ChronixExec;
use crate::Chronix;
pub(crate) struct ChronixTableProvider {
db: Arc<Chronix>,
measurement: String,
schema: SchemaRef,
namespace: Option<String>,
}
impl fmt::Debug for ChronixTableProvider {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ChronixTableProvider")
.field("measurement", &self.measurement)
.finish()
}
}
impl ChronixTableProvider {
pub(crate) fn try_new_scoped(
db: Arc<Chronix>,
measurement: &str,
namespace: Option<String>,
) -> Result<Self, DataFusionError> {
let ms = db.schema(measurement).ok_or_else(|| {
DataFusionError::Plan(format!("measurement '{measurement}' not found"))
})?;
let schema = measurement_schema_to_arrow(&ms);
Ok(Self {
db,
measurement: measurement.to_string(),
schema,
namespace,
})
}
}
#[must_use]
pub fn measurement_schema_to_arrow(ms: &MeasurementSchema) -> SchemaRef {
schema_to_arrow(ms, false)
}
#[cfg(feature = "object-store")]
#[must_use]
pub(crate) fn measurement_schema_to_archive_arrow(ms: &MeasurementSchema) -> SchemaRef {
schema_to_arrow(ms, true)
}
fn schema_to_arrow(ms: &MeasurementSchema, keep_namespace: bool) -> SchemaRef {
let mut columns: Vec<_> = ms
.columns()
.iter()
.filter(|col| keep_namespace || col.name != chronix_core::NAMESPACE_TAG)
.collect();
columns.sort_by_key(|col| {
let group = match col.role {
ColumnRole::Timestamp => 0,
ColumnRole::Tag => 1,
ColumnRole::Field => 2,
};
(group, col.name.as_str())
});
let fields: Vec<Field> = columns
.into_iter()
.map(|col| match col.role {
ColumnRole::Timestamp => Field::new(
chronix_core::TIME_COLUMN,
DataType::Timestamp(TimeUnit::Nanosecond, None),
false,
),
ColumnRole::Tag => Field::new(&col.name, DataType::Utf8, true),
ColumnRole::Field => {
let dt = if col.column_type == ColumnType::Timestamp {
DataType::Timestamp(TimeUnit::Nanosecond, None)
} else {
chronix_query::column_type_to_arrow(col.column_type)
};
Field::new(&col.name, dt, true)
}
})
.collect();
Arc::new(Schema::new(fields))
}
struct PushdownPredicates {
time_start: i64,
time_end: i64,
tag_filters: Vec<(String, String)>,
field_predicates: Vec<chronix_engine::segment::FieldPredicate>,
}
fn extract_pushdown(filters: &[Expr], db: &Chronix, measurement: &str) -> PushdownPredicates {
let mut result = PushdownPredicates {
time_start: i64::MIN,
time_end: i64::MAX,
tag_filters: Vec::new(),
field_predicates: Vec::new(),
};
let ms = db.schema(measurement);
for filter in filters {
extract_from_expr(filter, &mut result, ms.as_deref());
}
result
}
fn extract_from_expr(expr: &Expr, result: &mut PushdownPredicates, ms: Option<&MeasurementSchema>) {
use datafusion::logical_expr::Operator;
if let Expr::BinaryExpr(be) = expr {
if be.op == Operator::And {
extract_from_expr(&be.left, result, ms);
extract_from_expr(&be.right, result, ms);
return;
}
if let (Some(col_name), Some(scalar)) = (column_name(&be.left), scalar_value(&be.right)) {
apply_predicate(&col_name, be.op, &scalar, result, ms);
}
else if let (Some(scalar), Some(col_name)) =
(scalar_value(&be.left), column_name(&be.right))
&& let Some(rop) = reverse_op(be.op)
{
apply_predicate(&col_name, rop, &scalar, result, ms);
}
}
}
fn column_name(expr: &Expr) -> Option<String> {
if let Expr::Column(col) = expr {
Some(col.name().to_string())
} else {
None
}
}
fn scalar_value(expr: &Expr) -> Option<datafusion::common::ScalarValue> {
if let Expr::Literal(sv, _metadata) = expr {
Some(sv.clone())
} else {
None
}
}
fn reverse_op(
op: datafusion::logical_expr::Operator,
) -> Option<datafusion::logical_expr::Operator> {
use datafusion::logical_expr::Operator;
match op {
Operator::Gt => Some(Operator::Lt),
Operator::GtEq => Some(Operator::LtEq),
Operator::Lt => Some(Operator::Gt),
Operator::LtEq => Some(Operator::GtEq),
Operator::Eq => Some(Operator::Eq),
_ => None,
}
}
enum Pushdown {
Exact(PushdownTerm),
Hint(chronix_engine::segment::FieldPredicate),
None,
}
enum PushdownTerm {
Time {
start: Option<i64>,
end: Option<i64>,
},
Tag(String, String),
}
fn plan_predicate(
col_name: &str,
op: datafusion::logical_expr::Operator,
scalar: &datafusion::common::ScalarValue,
ms: Option<&MeasurementSchema>,
) -> Pushdown {
use datafusion::common::ScalarValue;
use datafusion::logical_expr::Operator;
if col_name == chronix_core::TIME_COLUMN {
let ts = match scalar {
ScalarValue::Int64(Some(v)) => *v,
ScalarValue::TimestampNanosecond(Some(v), _) => *v,
_ => return Pushdown::None,
};
return match op {
Operator::GtEq => Pushdown::Exact(PushdownTerm::Time {
start: Some(ts),
end: None,
}),
Operator::Gt => Pushdown::Exact(PushdownTerm::Time {
start: Some(ts.saturating_add(1)),
end: None,
}),
Operator::LtEq => Pushdown::Exact(PushdownTerm::Time {
start: None,
end: Some(ts),
}),
Operator::Lt => Pushdown::Exact(PushdownTerm::Time {
start: None,
end: Some(ts.saturating_sub(1)),
}),
Operator::Eq => Pushdown::Exact(PushdownTerm::Time {
start: Some(ts),
end: Some(ts),
}),
_ => Pushdown::None,
};
}
let Some(ms) = ms else {
return Pushdown::None;
};
let Some(column) = ms.column(col_name) else {
return Pushdown::None;
};
if column.role == ColumnRole::Tag {
if op == Operator::Eq
&& let ScalarValue::Utf8(Some(val)) = scalar
{
return Pushdown::Exact(PushdownTerm::Tag(col_name.to_string(), val.clone()));
}
return Pushdown::None;
}
if column.role == ColumnRole::Field {
#[allow(clippy::cast_precision_loss)] let value = match scalar {
ScalarValue::Float64(Some(v)) => *v,
ScalarValue::Int64(Some(v)) => *v as f64,
ScalarValue::UInt64(Some(v)) => *v as f64,
_ => return Pushdown::None,
};
let zm_op = match op {
Operator::Eq => chronix_engine::segment::StatsOp::Eq,
Operator::Gt => chronix_engine::segment::StatsOp::Gt,
Operator::GtEq => chronix_engine::segment::StatsOp::GtEq,
Operator::Lt => chronix_engine::segment::StatsOp::Lt,
Operator::LtEq => chronix_engine::segment::StatsOp::LtEq,
_ => return Pushdown::None,
};
return Pushdown::Hint(chronix_engine::segment::FieldPredicate {
column: col_name.to_string(),
op: zm_op,
value,
});
}
Pushdown::None
}
fn apply_predicate(
col_name: &str,
op: datafusion::logical_expr::Operator,
scalar: &datafusion::common::ScalarValue,
result: &mut PushdownPredicates,
ms: Option<&MeasurementSchema>,
) {
match plan_predicate(col_name, op, scalar, ms) {
Pushdown::Exact(PushdownTerm::Time { start, end }) => {
if let Some(s) = start {
result.time_start = result.time_start.max(s);
}
if let Some(e) = end {
result.time_end = result.time_end.min(e);
}
}
Pushdown::Exact(PushdownTerm::Tag(name, value)) => {
result.tag_filters.push((name, value));
}
Pushdown::Hint(pred) => result.field_predicates.push(pred),
Pushdown::None => {}
}
}
fn classify_filter(expr: &Expr, ms: Option<&MeasurementSchema>) -> TableProviderFilterPushDown {
use datafusion::logical_expr::Operator;
match expr {
Expr::BinaryExpr(be) => {
if be.op == Operator::And {
let left = classify_filter(&be.left, ms);
let right = classify_filter(&be.right, ms);
return match (left, right) {
(TableProviderFilterPushDown::Exact, TableProviderFilterPushDown::Exact) => {
TableProviderFilterPushDown::Exact
}
(TableProviderFilterPushDown::Unsupported, _)
| (_, TableProviderFilterPushDown::Unsupported) => {
TableProviderFilterPushDown::Inexact
}
_ => TableProviderFilterPushDown::Inexact,
};
}
let (name, op, scalar) = match (
column_name(&be.left),
scalar_value(&be.right),
column_name(&be.right),
scalar_value(&be.left),
) {
(Some(name), Some(scalar), _, _) => (name, be.op, scalar),
(_, _, Some(name), Some(scalar)) => match reverse_op(be.op) {
Some(op) => (name, op, scalar),
None => return TableProviderFilterPushDown::Unsupported,
},
_ => return TableProviderFilterPushDown::Unsupported,
};
match plan_predicate(&name, op, &scalar, ms) {
Pushdown::Exact(_) => TableProviderFilterPushDown::Exact,
Pushdown::Hint(_) => TableProviderFilterPushDown::Inexact,
Pushdown::None => TableProviderFilterPushDown::Unsupported,
}
}
_ => TableProviderFilterPushDown::Unsupported,
}
}
#[async_trait]
impl TableProvider for ChronixTableProvider {
fn schema(&self) -> SchemaRef {
self.schema.clone()
}
fn table_type(&self) -> TableType {
TableType::Base
}
async fn scan(
&self,
_state: &dyn Session,
projection: Option<&Vec<usize>>,
filters: &[Expr],
limit: Option<usize>,
) -> Result<Arc<dyn ExecutionPlan>, DataFusionError> {
let mut pushdown = extract_pushdown(filters, &self.db, &self.measurement);
if let Some(ref ns) = self.namespace {
pushdown
.tag_filters
.push((chronix_core::NAMESPACE_TAG.to_string(), ns.clone()));
}
Ok(Arc::new(ChronixExec::new(
self.db.clone(),
self.measurement.clone(),
self.schema.clone(),
projection.map(Vec::as_slice),
pushdown.time_start,
pushdown.time_end,
pushdown.tag_filters,
pushdown.field_predicates,
limit,
)?))
}
fn supports_filters_pushdown(
&self,
filters: &[&Expr],
) -> Result<Vec<TableProviderFilterPushDown>, DataFusionError> {
let ms = self.db.schema(&self.measurement);
Ok(filters
.iter()
.map(|f| classify_filter(f, ms.as_deref()))
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn schema_conversion_produces_correct_arrow_types() {
let mut ms = MeasurementSchema::new("cpu");
let _ = ms.add_tag("host");
ms.add_field("usage_idle", &chronix_core::FieldValue::F64(0.0))
.unwrap();
ms.add_field("count", &chronix_core::FieldValue::I64(0))
.unwrap();
let schema = measurement_schema_to_arrow(&ms);
assert_eq!(schema.fields().len(), 4);
let time_field = schema.field(0);
assert_eq!(time_field.name(), "_time");
assert_eq!(
*time_field.data_type(),
DataType::Timestamp(TimeUnit::Nanosecond, None)
);
let host = schema.field(1);
assert_eq!(host.name(), "host");
assert_eq!(*host.data_type(), DataType::Utf8);
let count = schema.field(2);
assert_eq!(count.name(), "count");
assert_eq!(*count.data_type(), DataType::Int64);
let usage = schema.field(3);
assert_eq!(usage.name(), "usage_idle");
assert_eq!(*usage.data_type(), DataType::Float64);
}
#[test]
fn schema_conversion_is_canonically_ordered() {
let mut ms = MeasurementSchema::new("m");
let _ = ms.add_tag("zone");
let _ = ms.add_tag("host");
ms.add_field("zeta", &chronix_core::FieldValue::F64(0.0))
.unwrap();
ms.add_field("alpha", &chronix_core::FieldValue::F64(0.0))
.unwrap();
let schema = measurement_schema_to_arrow(&ms);
let names: Vec<&str> = schema.fields().iter().map(|f| f.name().as_str()).collect();
assert_eq!(names, vec!["_time", "host", "zone", "alpha", "zeta"]);
}
}