mirror of
https://github.com/GreptimeTeam/greptimedb.git
synced 2026-09-23 21:55:38 +00:00
* feat!: upgrade DataFusion dependencies to 55 Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * refactor: migrate DataFusion 55 APIs Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix: preserve table function planning behavior Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix: preserve PostgreSQL query compatibility Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix: preserve distributed execution plan behavior Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: cover DataFusion 55 behavior regressions Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: update DataFusion 55 SQLness expectations Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix: complete DataFusion 55 test API migration Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix: address DataFusion 55 CI regressions Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix: address remaining DataFusion 55 regressions Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix: adapt latest base code to DataFusion 55 Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: normalize environment-specific DataFusion 55 plans Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: align final DataFusion 55 expectations Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: isolate DataFusion 55 regression cases Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: preserve empty result schema in timestamp widening Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: preserve JSON source column order Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * chore: use released DataFusion 55 integrations Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: adapt latest execution plan mock to DataFusion 55 Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix: pin DataFusion recursive schema and date repairs Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix(promql): align dictionary temporality match keys Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix: retain Greptime DataFusion fork behaviors on version 55 Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: restore ordinary function error expectations Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: refresh distributed count compatibility plan Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix(query): adapt last-row cast hint to DataFusion 55 Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: refresh instant last-row empty results for Arrow 59 Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * style: simplify DataFusion expression visitor imports Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: restore sorting and PostgreSQL column-order assertions Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix(function): restore primitive numeric coercion signatures Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * refactor(function): share geo integer signature types Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: cover timestamp widening overflow boundaries Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: fix decimal coercion regression imports Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix(function): preserve scalar count_hash NULL state semantics Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: simplify decimal clamp case type inference Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: retain historical count_hash wrapper result Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix: restore timestamp widening equality and IN pruning Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix: carry upstream aggregate dynamic filter correctness fix Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix: carry upstream null and predicate simplification fixes Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: restore baseline JSON ordering expectations Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: restore histogram JSON ordering expectations Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: refresh empty PromQL range result schemas Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: align native timestamp plan with DF55 decimal display Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: refresh native timestamp SQLness results for DF55 Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: regenerate NULL sample empty result headers for DF55 Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: use DF55 child replacement API in timestamp regressions Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix: expose pushed scan dynamic filters to DF55 producers Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix: encode string-backed PostgreSQL OID aliases in binary results Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: verify REGPROC binary and text over PostgreSQL protocol Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: register real PostgreSQL catalogs in server fixtures Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix: complete DF55 expression inventories for custom query plans Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: correct RangeSelect expression fixture and column identities Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * ci: wait for Kafka WAL helper deployment rollout Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * test: update custom storage empty result headers Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix: require exact row counts in scan statistics Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> * fix: suppress deprecated partition_statistics warning in test Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> --------- Signed-off-by: discord9 <55937128+discord9@users.noreply.github.com> Co-authored-by: Ning Sun <sunng@protonmail.com>
1552 lines
61 KiB
Rust
1552 lines
61 KiB
Rust
// Copyright 2023 Greptime Team
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
use arrow_schema::{DataType, Schema as ArrowSchema};
|
|
use catalog::table_source::DfTableSourceProvider;
|
|
use common_function::utils::escape_like_pattern;
|
|
use datafusion::datasource::DefaultTableSource;
|
|
use datafusion::execution::SessionState;
|
|
use datafusion_common::{DFSchema, ScalarValue, TableReference};
|
|
use datafusion_expr::utils::{conjunction, disjunction};
|
|
use datafusion_expr::{
|
|
BinaryExpr, Expr, ExprSchemable, LogicalPlan, LogicalPlanBuilder, Operator, col, lit, not,
|
|
};
|
|
use datatypes::schema::Schema;
|
|
use log_query::{AggFunc, BinaryOperator, EqualValue, LogExpr, LogQuery, TimeFilter};
|
|
use snafu::{OptionExt, ResultExt};
|
|
use table::table::adapter::DfTableProviderAdapter;
|
|
|
|
use crate::log_query::error::{
|
|
CatalogSnafu, DataFusionPlanningSnafu, Result, TimeIndexNotFoundSnafu, UnexpectedLogExprSnafu,
|
|
UnimplementedSnafu, UnknownAggregateFunctionSnafu, UnknownScalarFunctionSnafu,
|
|
UnknownTableSnafu,
|
|
};
|
|
|
|
pub struct LogQueryPlanner {
|
|
table_provider: DfTableSourceProvider,
|
|
session_state: SessionState,
|
|
}
|
|
|
|
impl LogQueryPlanner {
|
|
pub fn new(table_provider: DfTableSourceProvider, session_state: SessionState) -> Self {
|
|
Self {
|
|
table_provider,
|
|
session_state,
|
|
}
|
|
}
|
|
|
|
pub async fn query_to_plan(&mut self, query: LogQuery) -> Result<LogicalPlan> {
|
|
// Resolve table
|
|
let table_ref: TableReference = query.table.table_ref().into();
|
|
let table_source = self
|
|
.table_provider
|
|
.resolve_table(table_ref.clone())
|
|
.await
|
|
.context(CatalogSnafu)?;
|
|
let schema = table_source
|
|
.downcast_ref::<DefaultTableSource>()
|
|
.context(UnknownTableSnafu)?
|
|
.table_provider
|
|
.downcast_ref::<DfTableProviderAdapter>()
|
|
.context(UnknownTableSnafu)?
|
|
.table()
|
|
.schema();
|
|
|
|
// Build the initial scan plan
|
|
let mut plan_builder = LogicalPlanBuilder::scan(table_ref, table_source, None)
|
|
.context(DataFusionPlanningSnafu)?;
|
|
let df_schema = plan_builder.schema().clone();
|
|
|
|
// Collect filter expressions
|
|
let mut filters = Vec::new();
|
|
|
|
// Time filter
|
|
filters.push(self.build_time_filter(&query.time_filter, &schema)?);
|
|
|
|
if let Some(filters_expr) = self.build_filters(&query.filters, df_schema.as_arrow())? {
|
|
filters.push(filters_expr);
|
|
}
|
|
|
|
// Apply filters
|
|
if !filters.is_empty() {
|
|
let filter_expr = filters.into_iter().reduce(|a, b| a.and(b)).unwrap();
|
|
plan_builder = plan_builder
|
|
.filter(filter_expr)
|
|
.context(DataFusionPlanningSnafu)?;
|
|
}
|
|
|
|
// Apply projections
|
|
if !query.columns.is_empty() {
|
|
let projected_columns = query.columns.iter().map(col).collect::<Vec<_>>();
|
|
plan_builder = plan_builder
|
|
.project(projected_columns)
|
|
.context(DataFusionPlanningSnafu)?;
|
|
}
|
|
|
|
// Apply log expressions
|
|
for expr in &query.exprs {
|
|
plan_builder = self.process_log_expr(plan_builder, expr)?;
|
|
}
|
|
|
|
// Apply pagination to the final result after all log expressions.
|
|
if query.limit.skip.is_some() || query.limit.fetch.is_some() {
|
|
plan_builder = plan_builder
|
|
.limit(query.limit.skip.unwrap_or(0), query.limit.fetch)
|
|
.context(DataFusionPlanningSnafu)?;
|
|
}
|
|
|
|
// Build the final plan
|
|
let plan = plan_builder.build().context(DataFusionPlanningSnafu)?;
|
|
|
|
Ok(plan)
|
|
}
|
|
|
|
fn build_time_filter(&self, time_filter: &TimeFilter, schema: &Schema) -> Result<Expr> {
|
|
let timestamp_col = schema
|
|
.timestamp_column()
|
|
.with_context(|| TimeIndexNotFoundSnafu {})?
|
|
.name
|
|
.clone();
|
|
|
|
let start_time = ScalarValue::Utf8(time_filter.start.clone());
|
|
let expr = col(timestamp_col.clone())
|
|
.gt_eq(lit(start_time))
|
|
.and(match &time_filter.end {
|
|
Some(end) => col(timestamp_col).lt(lit(ScalarValue::Utf8(Some(end.clone())))),
|
|
None => col(timestamp_col).lt_eq(lit(ScalarValue::Utf8(Some(
|
|
"9999-12-31T23:59:59Z".to_string(),
|
|
)))),
|
|
});
|
|
|
|
Ok(expr)
|
|
}
|
|
//disjunction
|
|
fn build_filters(
|
|
&self,
|
|
filters: &log_query::Filters,
|
|
schema: &ArrowSchema,
|
|
) -> Result<Option<Expr>> {
|
|
match filters {
|
|
log_query::Filters::And(filters) => {
|
|
let exprs = filters
|
|
.iter()
|
|
.filter_map(|filter| self.build_filters(filter, schema).transpose())
|
|
.try_collect::<Vec<_>>()?;
|
|
if exprs.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(conjunction(exprs))
|
|
}
|
|
}
|
|
log_query::Filters::Or(filters) => {
|
|
let exprs = filters
|
|
.iter()
|
|
.filter_map(|filter| self.build_filters(filter, schema).transpose())
|
|
.try_collect::<Vec<_>>()?;
|
|
if exprs.is_empty() {
|
|
Ok(None)
|
|
} else {
|
|
Ok(disjunction(exprs))
|
|
}
|
|
}
|
|
log_query::Filters::Not(filter) => {
|
|
if let Some(expr) = self.build_filters(filter, schema)? {
|
|
Ok(Some(not(expr)))
|
|
} else {
|
|
Ok(None)
|
|
}
|
|
}
|
|
log_query::Filters::Single(column_filters) => {
|
|
// Build a single column filter
|
|
self.build_column_filter(column_filters, schema)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Builds filter expression from ColumnFilters (new structure with expr + filters)
|
|
fn build_column_filter(
|
|
&self,
|
|
column_filter: &log_query::ColumnFilters,
|
|
schema: &ArrowSchema,
|
|
) -> Result<Option<Expr>> {
|
|
// Convert ArrowSchema to DFSchema for the more generic function
|
|
let df_schema = DFSchema::try_from(schema.clone()).context(DataFusionPlanningSnafu)?;
|
|
let col_expr = self.log_expr_to_df_expr(&column_filter.expr, &df_schema)?;
|
|
|
|
let filter_exprs = column_filter
|
|
.filters
|
|
.iter()
|
|
.filter_map(|filter| {
|
|
self.build_content_filter_with_expr(col_expr.clone(), filter, &df_schema)
|
|
.transpose()
|
|
})
|
|
.try_collect::<Vec<_>>()?;
|
|
|
|
if filter_exprs.is_empty() {
|
|
return Ok(Some(col_expr.is_true()));
|
|
}
|
|
|
|
// Combine all filters with AND logic
|
|
Ok(conjunction(filter_exprs))
|
|
}
|
|
|
|
/// Builds filter expression from a single ContentFilter using a provided column expression
|
|
#[allow(clippy::only_used_in_recursion)]
|
|
fn build_content_filter_with_expr(
|
|
&self,
|
|
col_expr: Expr,
|
|
filter: &log_query::ContentFilter,
|
|
schema: &DFSchema,
|
|
) -> Result<Option<Expr>> {
|
|
match filter {
|
|
log_query::ContentFilter::Exact(value) => Ok(Some(
|
|
col_expr.like(lit(ScalarValue::Utf8(Some(escape_like_pattern(value))))),
|
|
)),
|
|
log_query::ContentFilter::Prefix(value) => Ok(Some(col_expr.like(lit(
|
|
ScalarValue::Utf8(Some(format!("{}%", escape_like_pattern(value)))),
|
|
)))),
|
|
log_query::ContentFilter::Postfix(value) => Ok(Some(col_expr.like(lit(
|
|
ScalarValue::Utf8(Some(format!("%{}", escape_like_pattern(value)))),
|
|
)))),
|
|
log_query::ContentFilter::Contains(value) => Ok(Some(col_expr.like(lit(
|
|
ScalarValue::Utf8(Some(format!("%{}%", escape_like_pattern(value)))),
|
|
)))),
|
|
log_query::ContentFilter::Regex(_pattern) => Err(UnimplementedSnafu {
|
|
feature: "regex filter",
|
|
}
|
|
.build()),
|
|
log_query::ContentFilter::Exist => Ok(Some(col_expr.is_not_null())),
|
|
log_query::ContentFilter::Between {
|
|
start,
|
|
end,
|
|
start_inclusive,
|
|
end_inclusive,
|
|
} => {
|
|
let start_literal = self.create_inferred_literal(start, &col_expr, schema);
|
|
let end_literal = self.create_inferred_literal(end, &col_expr, schema);
|
|
|
|
let left = if *start_inclusive {
|
|
col_expr.clone().gt_eq(start_literal)
|
|
} else {
|
|
col_expr.clone().gt(start_literal)
|
|
};
|
|
let right = if *end_inclusive {
|
|
col_expr.lt_eq(end_literal)
|
|
} else {
|
|
col_expr.lt(end_literal)
|
|
};
|
|
Ok(Some(left.and(right)))
|
|
}
|
|
log_query::ContentFilter::GreatThan { value, inclusive } => {
|
|
let value_literal = self.create_inferred_literal(value, &col_expr, schema);
|
|
let comparison_expr = if *inclusive {
|
|
col_expr.gt_eq(value_literal)
|
|
} else {
|
|
col_expr.gt(value_literal)
|
|
};
|
|
Ok(Some(comparison_expr))
|
|
}
|
|
log_query::ContentFilter::LessThan { value, inclusive } => {
|
|
let value_literal = self.create_inferred_literal(value, &col_expr, schema);
|
|
if *inclusive {
|
|
Ok(Some(col_expr.lt_eq(value_literal)))
|
|
} else {
|
|
Ok(Some(col_expr.lt(value_literal)))
|
|
}
|
|
}
|
|
log_query::ContentFilter::In(values) => {
|
|
let inferred_values: Vec<_> = values
|
|
.iter()
|
|
.map(|v| self.create_inferred_literal(v, &col_expr, schema))
|
|
.collect();
|
|
Ok(Some(col_expr.in_list(inferred_values, false)))
|
|
}
|
|
log_query::ContentFilter::IsTrue => Ok(Some(col_expr.is_true())),
|
|
log_query::ContentFilter::IsFalse => Ok(Some(col_expr.is_false())),
|
|
log_query::ContentFilter::Equal(value) => {
|
|
let value_literal = Self::create_eq_literal(value.clone());
|
|
Ok(Some(col_expr.eq(value_literal)))
|
|
}
|
|
log_query::ContentFilter::Compound(filters, op) => {
|
|
let exprs = filters
|
|
.iter()
|
|
.filter_map(|filter| {
|
|
self.build_content_filter_with_expr(col_expr.clone(), filter, schema)
|
|
.transpose()
|
|
})
|
|
.try_collect::<Vec<_>>()?;
|
|
|
|
if exprs.is_empty() {
|
|
return Ok(None);
|
|
}
|
|
|
|
match op {
|
|
log_query::ConjunctionOperator::And => Ok(conjunction(exprs)),
|
|
log_query::ConjunctionOperator::Or => {
|
|
// Build a disjunction (OR) of expressions
|
|
Ok(exprs.into_iter().reduce(|a, b| a.or(b)))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn build_aggr_func(
|
|
&self,
|
|
schema: &DFSchema,
|
|
expr: &[AggFunc],
|
|
by: &[LogExpr],
|
|
) -> Result<(Vec<Expr>, Vec<Expr>)> {
|
|
let aggr_expr = expr
|
|
.iter()
|
|
.map(|agg_func| {
|
|
let AggFunc {
|
|
name: fn_name,
|
|
args,
|
|
alias,
|
|
} = agg_func;
|
|
let aggr_fn = self
|
|
.session_state
|
|
.aggregate_functions()
|
|
.get(fn_name)
|
|
.with_context(|| UnknownAggregateFunctionSnafu {
|
|
name: fn_name.clone(),
|
|
})?;
|
|
let args = args
|
|
.iter()
|
|
.map(|expr| self.log_expr_to_df_expr(expr, schema))
|
|
.try_collect::<Vec<_>>()?;
|
|
if let Some(alias) = alias {
|
|
Ok(aggr_fn.call(args).alias(alias))
|
|
} else {
|
|
Ok(aggr_fn.call(args))
|
|
}
|
|
})
|
|
.try_collect::<Vec<_>>()?;
|
|
|
|
let group_exprs = by
|
|
.iter()
|
|
.map(|expr| self.log_expr_to_df_expr(expr, schema))
|
|
.try_collect::<Vec<_>>()?;
|
|
|
|
Ok((aggr_expr, group_exprs))
|
|
}
|
|
|
|
/// Converts a LogExpr to a DataFusion Expr, handling all expression types.
|
|
fn log_expr_to_df_expr(&self, expr: &LogExpr, schema: &DFSchema) -> Result<Expr> {
|
|
match expr {
|
|
LogExpr::NamedIdent(name) => Ok(col(name)),
|
|
LogExpr::PositionalIdent(index) => Ok(col(schema.field(*index).name())),
|
|
LogExpr::Literal(literal) => Ok(lit(ScalarValue::Utf8(Some(literal.clone())))),
|
|
LogExpr::BinaryOp { left, op, right } => {
|
|
// For binary operations, always use type inference (matches original behavior)
|
|
self.build_binary_expr(left, op, right, schema)
|
|
}
|
|
LogExpr::ScalarFunc { name, args, alias } => {
|
|
self.build_scalar_func(schema, name, args, alias)
|
|
}
|
|
LogExpr::Alias { expr, alias } => {
|
|
let df_expr = self.log_expr_to_df_expr(expr, schema)?;
|
|
Ok(df_expr.alias(alias))
|
|
}
|
|
LogExpr::AggrFunc { .. } | LogExpr::Filter { .. } | LogExpr::Decompose { .. } => {
|
|
UnexpectedLogExprSnafu {
|
|
expr: expr.clone(),
|
|
expected: "not a typical expression",
|
|
}
|
|
.fail()
|
|
}
|
|
}
|
|
}
|
|
|
|
fn build_scalar_func(
|
|
&self,
|
|
schema: &DFSchema,
|
|
name: &str,
|
|
args: &[LogExpr],
|
|
alias: &Option<String>,
|
|
) -> Result<Expr> {
|
|
let args = args
|
|
.iter()
|
|
.map(|expr| self.log_expr_to_df_expr(expr, schema))
|
|
.try_collect::<Vec<_>>()?;
|
|
let func = self.session_state.scalar_functions().get(name).context(
|
|
UnknownScalarFunctionSnafu {
|
|
name: name.to_string(),
|
|
},
|
|
)?;
|
|
let expr = func.call(args);
|
|
|
|
if let Some(alias) = alias {
|
|
Ok(expr.alias(alias))
|
|
} else {
|
|
Ok(expr)
|
|
}
|
|
}
|
|
|
|
/// Convert BinaryOperator to DataFusion's Operator.
|
|
fn binary_operator_to_df_operator(op: &BinaryOperator) -> Operator {
|
|
match op {
|
|
BinaryOperator::Eq => Operator::Eq,
|
|
BinaryOperator::Ne => Operator::NotEq,
|
|
BinaryOperator::Lt => Operator::Lt,
|
|
BinaryOperator::Le => Operator::LtEq,
|
|
BinaryOperator::Gt => Operator::Gt,
|
|
BinaryOperator::Ge => Operator::GtEq,
|
|
BinaryOperator::Plus => Operator::Plus,
|
|
BinaryOperator::Minus => Operator::Minus,
|
|
BinaryOperator::Multiply => Operator::Multiply,
|
|
BinaryOperator::Divide => Operator::Divide,
|
|
BinaryOperator::Modulo => Operator::Modulo,
|
|
BinaryOperator::And => Operator::And,
|
|
BinaryOperator::Or => Operator::Or,
|
|
}
|
|
}
|
|
|
|
/// Parse a string literal to the appropriate ScalarValue based on target DataType.
|
|
/// Falls back to UTF8 if parsing fails or type is not supported.
|
|
fn infer_literal_scalar_value(&self, literal: &str, target_type: &DataType) -> ScalarValue {
|
|
let utf8_literal = ScalarValue::Utf8(Some(literal.to_string()));
|
|
utf8_literal.cast_to(target_type).unwrap_or(utf8_literal)
|
|
}
|
|
|
|
/// Build binary expression with type inference for literals.
|
|
/// Attempts to infer literal types from the non-literal operand's type.
|
|
fn build_binary_expr(
|
|
&self,
|
|
left: &LogExpr,
|
|
op: &BinaryOperator,
|
|
right: &LogExpr,
|
|
schema: &DFSchema,
|
|
) -> Result<Expr> {
|
|
// Convert both sides to DataFusion expressions first
|
|
let mut left_expr = self.log_expr_to_df_expr(left, schema)?;
|
|
let mut right_expr = self.log_expr_to_df_expr(right, schema)?;
|
|
|
|
// Try to infer literal types based on the other operand
|
|
match (left, right) {
|
|
(LogExpr::Literal(_), LogExpr::Literal(_)) => {
|
|
// both are literal, do nothing
|
|
}
|
|
(LogExpr::Literal(literal), _) => {
|
|
// Left is literal, try to infer from right
|
|
if let Ok(right_type) = right_expr.get_type(schema) {
|
|
let inferred_scalar = self.infer_literal_scalar_value(literal, &right_type);
|
|
left_expr = lit(inferred_scalar);
|
|
}
|
|
}
|
|
(_, LogExpr::Literal(literal)) => {
|
|
// Right is literal, try to infer from left
|
|
if let Ok(left_type) = left_expr.get_type(schema) {
|
|
let inferred_scalar = self.infer_literal_scalar_value(literal, &left_type);
|
|
right_expr = lit(inferred_scalar);
|
|
}
|
|
}
|
|
_ => {
|
|
// Neither is a simple literal, no type inference needed
|
|
}
|
|
}
|
|
|
|
let df_op = Self::binary_operator_to_df_operator(op);
|
|
Ok(Expr::BinaryExpr(BinaryExpr {
|
|
left: Box::new(left_expr),
|
|
op: df_op,
|
|
right: Box::new(right_expr),
|
|
}))
|
|
}
|
|
|
|
/// Create a type-inferred literal based on the provided expression's type.
|
|
/// Falls back to UTF8 if type inference fails.
|
|
fn create_inferred_literal(&self, value: &str, expr: &Expr, schema: &DFSchema) -> Expr {
|
|
if let Ok(expr_type) = expr.get_type(schema) {
|
|
lit(self.infer_literal_scalar_value(value, &expr_type))
|
|
} else {
|
|
lit(ScalarValue::Utf8(Some(value.to_string())))
|
|
}
|
|
}
|
|
|
|
fn create_eq_literal(value: EqualValue) -> Expr {
|
|
match value {
|
|
EqualValue::String(s) => lit(ScalarValue::Utf8(Some(s))),
|
|
EqualValue::Float(n) => lit(ScalarValue::Float64(Some(n))),
|
|
EqualValue::Int(n) => lit(ScalarValue::Int64(Some(n))),
|
|
EqualValue::Boolean(b) => lit(ScalarValue::Boolean(Some(b))),
|
|
EqualValue::UInt(n) => lit(ScalarValue::UInt64(Some(n))),
|
|
}
|
|
}
|
|
|
|
/// Process LogExpr recursively.
|
|
///
|
|
/// Return the [`LogicalPlanBuilder`] after modification and the resulting expression's names.
|
|
fn process_log_expr(
|
|
&self,
|
|
plan_builder: LogicalPlanBuilder,
|
|
expr: &LogExpr,
|
|
) -> Result<LogicalPlanBuilder> {
|
|
let mut plan_builder = plan_builder;
|
|
|
|
match expr {
|
|
LogExpr::AggrFunc { expr, by } => {
|
|
let schema = plan_builder.schema();
|
|
let (aggr_expr, group_exprs) = self.build_aggr_func(schema, expr, by)?;
|
|
|
|
plan_builder = plan_builder
|
|
.aggregate(group_exprs, aggr_expr)
|
|
.context(DataFusionPlanningSnafu)?;
|
|
}
|
|
LogExpr::Filter { filter } => {
|
|
let schema = plan_builder.schema();
|
|
if let Some(filter_expr) = self.build_column_filter(filter, schema.as_arrow())? {
|
|
plan_builder = plan_builder
|
|
.filter(filter_expr)
|
|
.context(DataFusionPlanningSnafu)?;
|
|
}
|
|
}
|
|
LogExpr::ScalarFunc { name, args, alias } => {
|
|
let schema = plan_builder.schema();
|
|
let expr = self.build_scalar_func(schema, name, args, alias)?;
|
|
plan_builder = plan_builder
|
|
.project([expr])
|
|
.context(DataFusionPlanningSnafu)?;
|
|
}
|
|
LogExpr::NamedIdent(_) | LogExpr::PositionalIdent(_) => {
|
|
// nothing to do, return empty vec.
|
|
}
|
|
LogExpr::Alias { expr, alias } => {
|
|
let schema = plan_builder.schema();
|
|
let df_expr = self.log_expr_to_df_expr(expr, schema)?;
|
|
let aliased_expr = df_expr.alias(alias);
|
|
plan_builder = plan_builder
|
|
.project([aliased_expr.clone()])
|
|
.context(DataFusionPlanningSnafu)?;
|
|
}
|
|
LogExpr::BinaryOp { .. } => {
|
|
let schema = plan_builder.schema();
|
|
let binary_expr = self.log_expr_to_df_expr(expr, schema)?;
|
|
|
|
plan_builder = plan_builder
|
|
.project([binary_expr])
|
|
.context(DataFusionPlanningSnafu)?;
|
|
}
|
|
_ => {
|
|
UnimplementedSnafu {
|
|
feature: "log expression",
|
|
}
|
|
.fail()?;
|
|
}
|
|
}
|
|
Ok(plan_builder)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use catalog::RegisterTableRequest;
|
|
use catalog::memory::MemoryCatalogManager;
|
|
use common_catalog::consts::DEFAULT_CATALOG_NAME;
|
|
use common_query::test_util::DummyDecoder;
|
|
use datafusion::execution::SessionStateBuilder;
|
|
use datatypes::prelude::ConcreteDataType;
|
|
use datatypes::schema::{ColumnSchema, SchemaRef};
|
|
use log_query::{
|
|
ColumnFilters, ConjunctionOperator, ContentFilter, Context, Filters, Limit, LogExpr,
|
|
};
|
|
use session::context::QueryContext;
|
|
use table::metadata::{TableInfoBuilder, TableMetaBuilder};
|
|
use table::table_name::TableName;
|
|
use table::test_util::EmptyTable;
|
|
|
|
use super::*;
|
|
|
|
fn mock_schema() -> SchemaRef {
|
|
let columns = vec![
|
|
ColumnSchema::new(
|
|
"message".to_string(),
|
|
ConcreteDataType::string_datatype(),
|
|
false,
|
|
),
|
|
ColumnSchema::new(
|
|
"timestamp".to_string(),
|
|
ConcreteDataType::timestamp_millisecond_datatype(),
|
|
false,
|
|
)
|
|
.with_time_index(true),
|
|
ColumnSchema::new(
|
|
"host".to_string(),
|
|
ConcreteDataType::string_datatype(),
|
|
true,
|
|
),
|
|
ColumnSchema::new(
|
|
"is_active".to_string(),
|
|
ConcreteDataType::boolean_datatype(),
|
|
true,
|
|
),
|
|
];
|
|
|
|
Arc::new(Schema::new(columns))
|
|
}
|
|
|
|
fn mock_schema_with_typed_columns() -> SchemaRef {
|
|
let columns = vec![
|
|
ColumnSchema::new(
|
|
"message".to_string(),
|
|
ConcreteDataType::string_datatype(),
|
|
false,
|
|
),
|
|
ColumnSchema::new(
|
|
"timestamp".to_string(),
|
|
ConcreteDataType::timestamp_millisecond_datatype(),
|
|
false,
|
|
)
|
|
.with_time_index(true),
|
|
ColumnSchema::new(
|
|
"host".to_string(),
|
|
ConcreteDataType::string_datatype(),
|
|
true,
|
|
),
|
|
ColumnSchema::new(
|
|
"is_active".to_string(),
|
|
ConcreteDataType::boolean_datatype(),
|
|
true,
|
|
),
|
|
// Add more typed columns for comprehensive testing
|
|
ColumnSchema::new("age".to_string(), ConcreteDataType::int32_datatype(), true),
|
|
ColumnSchema::new(
|
|
"score".to_string(),
|
|
ConcreteDataType::float64_datatype(),
|
|
true,
|
|
),
|
|
ColumnSchema::new(
|
|
"count".to_string(),
|
|
ConcreteDataType::uint64_datatype(),
|
|
true,
|
|
),
|
|
];
|
|
|
|
Arc::new(Schema::new(columns))
|
|
}
|
|
|
|
/// Registers table under `greptime`, with `message`, `timestamp`, `host`, and `is_active` columns.
|
|
async fn build_test_table_provider(
|
|
table_name_tuples: &[(String, String)],
|
|
) -> DfTableSourceProvider {
|
|
build_test_table_provider_with_schema(table_name_tuples, mock_schema()).await
|
|
}
|
|
|
|
/// Registers table under `greptime`, with typed columns for type inference tests.
|
|
async fn build_test_table_provider_with_typed_columns(
|
|
table_name_tuples: &[(String, String)],
|
|
) -> DfTableSourceProvider {
|
|
build_test_table_provider_with_schema(table_name_tuples, mock_schema_with_typed_columns())
|
|
.await
|
|
}
|
|
|
|
async fn build_test_table_provider_with_schema(
|
|
table_name_tuples: &[(String, String)],
|
|
schema: SchemaRef,
|
|
) -> DfTableSourceProvider {
|
|
let catalog_list = MemoryCatalogManager::with_default_setup();
|
|
for (schema_name, table_name) in table_name_tuples {
|
|
let table_meta = TableMetaBuilder::empty()
|
|
.schema(schema.clone())
|
|
.primary_key_indices(vec![2])
|
|
.value_indices(vec![0])
|
|
.next_column_id(1024)
|
|
.build()
|
|
.unwrap();
|
|
let table_info = TableInfoBuilder::default()
|
|
.name(table_name.clone())
|
|
.meta(table_meta)
|
|
.build()
|
|
.unwrap();
|
|
let table = EmptyTable::from_table_info(&table_info);
|
|
|
|
catalog_list
|
|
.register_table_sync(RegisterTableRequest {
|
|
catalog: DEFAULT_CATALOG_NAME.to_string(),
|
|
schema: schema_name.clone(),
|
|
table_name: table_name.clone(),
|
|
table_id: 1024,
|
|
table,
|
|
})
|
|
.unwrap();
|
|
}
|
|
|
|
DfTableSourceProvider::new(
|
|
catalog_list,
|
|
false,
|
|
QueryContext::arc(),
|
|
DummyDecoder::arc(),
|
|
false,
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_query_to_plan() {
|
|
let table_provider =
|
|
build_test_table_provider(&[("public".to_string(), "test_table".to_string())]).await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let mut planner = LogQueryPlanner::new(table_provider, session_state);
|
|
|
|
let log_query = LogQuery {
|
|
table: TableName::new(DEFAULT_CATALOG_NAME, "public", "test_table"),
|
|
time_filter: TimeFilter {
|
|
start: Some("2021-01-01T00:00:00Z".to_string()),
|
|
end: Some("2021-01-02T00:00:00Z".to_string()),
|
|
span: None,
|
|
},
|
|
filters: Filters::Single(ColumnFilters {
|
|
expr: Box::new(LogExpr::NamedIdent("message".to_string())),
|
|
filters: vec![ContentFilter::Contains("error".to_string())],
|
|
}),
|
|
limit: Limit {
|
|
skip: None,
|
|
fetch: Some(100),
|
|
},
|
|
context: Context::None,
|
|
columns: vec![],
|
|
exprs: vec![],
|
|
};
|
|
|
|
let plan = planner.query_to_plan(log_query).await.unwrap();
|
|
let expected = "Limit: skip=0, fetch=100 [message:Utf8, timestamp:Timestamp(ms), host:Utf8;N, is_active:Boolean;N]\
|
|
\n Filter: greptime.public.test_table.timestamp >= Utf8(\"2021-01-01T00:00:00Z\") AND greptime.public.test_table.timestamp < Utf8(\"2021-01-02T00:00:00Z\") AND greptime.public.test_table.message LIKE Utf8(\"%error%\") [message:Utf8, timestamp:Timestamp(ms), host:Utf8;N, is_active:Boolean;N]\
|
|
\n TableScan: greptime.public.test_table [message:Utf8, timestamp:Timestamp(ms), host:Utf8;N, is_active:Boolean;N]";
|
|
|
|
assert_eq!(plan.display_indent_schema().to_string(), expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_build_time_filter() {
|
|
let table_provider =
|
|
build_test_table_provider(&[("public".to_string(), "test_table".to_string())]).await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let planner = LogQueryPlanner::new(table_provider, session_state);
|
|
|
|
let time_filter = TimeFilter {
|
|
start: Some("2021-01-01T00:00:00Z".to_string()),
|
|
end: Some("2021-01-02T00:00:00Z".to_string()),
|
|
span: None,
|
|
};
|
|
|
|
let expr = planner
|
|
.build_time_filter(&time_filter, &mock_schema())
|
|
.unwrap();
|
|
|
|
let expected_expr = col("timestamp")
|
|
.gt_eq(lit(ScalarValue::Utf8(Some(
|
|
"2021-01-01T00:00:00Z".to_string(),
|
|
))))
|
|
.and(col("timestamp").lt(lit(ScalarValue::Utf8(Some(
|
|
"2021-01-02T00:00:00Z".to_string(),
|
|
)))));
|
|
|
|
assert_eq!(format!("{:?}", expr), format!("{:?}", expected_expr));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_build_time_filter_without_end() {
|
|
let table_provider =
|
|
build_test_table_provider(&[("public".to_string(), "test_table".to_string())]).await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let planner = LogQueryPlanner::new(table_provider, session_state);
|
|
|
|
let time_filter = TimeFilter {
|
|
start: Some("2021-01-01T00:00:00Z".to_string()),
|
|
end: None,
|
|
span: None,
|
|
};
|
|
|
|
let expr = planner
|
|
.build_time_filter(&time_filter, &mock_schema())
|
|
.unwrap();
|
|
|
|
let expected_expr = col("timestamp")
|
|
.gt_eq(lit(ScalarValue::Utf8(Some(
|
|
"2021-01-01T00:00:00Z".to_string(),
|
|
))))
|
|
.and(col("timestamp").lt_eq(lit(ScalarValue::Utf8(Some(
|
|
"9999-12-31T23:59:59Z".to_string(),
|
|
)))));
|
|
|
|
assert_eq!(format!("{:?}", expr), format!("{:?}", expected_expr));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_build_content_filter() {
|
|
let table_provider =
|
|
build_test_table_provider(&[("public".to_string(), "test_table".to_string())]).await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let planner = LogQueryPlanner::new(table_provider, session_state);
|
|
let schema = mock_schema();
|
|
|
|
let column_filter = ColumnFilters {
|
|
expr: Box::new(LogExpr::NamedIdent("message".to_string())),
|
|
filters: vec![
|
|
ContentFilter::Contains("error".to_string()),
|
|
ContentFilter::Prefix("WARN".to_string()),
|
|
],
|
|
};
|
|
|
|
let expr_option = planner
|
|
.build_column_filter(&column_filter, schema.arrow_schema())
|
|
.unwrap();
|
|
assert!(expr_option.is_some());
|
|
|
|
let expr = expr_option.unwrap();
|
|
|
|
let expected_expr = col("message")
|
|
.like(lit(ScalarValue::Utf8(Some("%error%".to_string()))))
|
|
.and(col("message").like(lit(ScalarValue::Utf8(Some("WARN%".to_string())))));
|
|
|
|
assert_eq!(format!("{:?}", expr), format!("{:?}", expected_expr));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_query_to_plan_with_only_skip() {
|
|
let table_provider =
|
|
build_test_table_provider(&[("public".to_string(), "test_table".to_string())]).await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let mut planner = LogQueryPlanner::new(table_provider, session_state);
|
|
|
|
let log_query = LogQuery {
|
|
table: TableName::new(DEFAULT_CATALOG_NAME, "public", "test_table"),
|
|
time_filter: TimeFilter {
|
|
start: Some("2021-01-01T00:00:00Z".to_string()),
|
|
end: Some("2021-01-02T00:00:00Z".to_string()),
|
|
span: None,
|
|
},
|
|
filters: Filters::Single(ColumnFilters {
|
|
expr: Box::new(LogExpr::NamedIdent("message".to_string())),
|
|
filters: vec![ContentFilter::Contains("error".to_string())],
|
|
}),
|
|
limit: Limit {
|
|
skip: Some(10),
|
|
fetch: None,
|
|
},
|
|
context: Context::None,
|
|
columns: vec![],
|
|
exprs: vec![],
|
|
};
|
|
|
|
let plan = planner.query_to_plan(log_query).await.unwrap();
|
|
let expected = "Limit: skip=10, fetch=None [message:Utf8, timestamp:Timestamp(ms), host:Utf8;N, is_active:Boolean;N]\
|
|
\n Filter: greptime.public.test_table.timestamp >= Utf8(\"2021-01-01T00:00:00Z\") AND greptime.public.test_table.timestamp < Utf8(\"2021-01-02T00:00:00Z\") AND greptime.public.test_table.message LIKE Utf8(\"%error%\") [message:Utf8, timestamp:Timestamp(ms), host:Utf8;N, is_active:Boolean;N]\
|
|
\n TableScan: greptime.public.test_table [message:Utf8, timestamp:Timestamp(ms), host:Utf8;N, is_active:Boolean;N]";
|
|
|
|
assert_eq!(plan.display_indent_schema().to_string(), expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_query_to_plan_without_limit() {
|
|
let table_provider =
|
|
build_test_table_provider(&[("public".to_string(), "test_table".to_string())]).await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let mut planner = LogQueryPlanner::new(table_provider, session_state);
|
|
|
|
let log_query = LogQuery {
|
|
table: TableName::new(DEFAULT_CATALOG_NAME, "public", "test_table"),
|
|
time_filter: TimeFilter {
|
|
start: Some("2021-01-01T00:00:00Z".to_string()),
|
|
end: Some("2021-01-02T00:00:00Z".to_string()),
|
|
span: None,
|
|
},
|
|
filters: Filters::Single(ColumnFilters {
|
|
expr: Box::new(LogExpr::NamedIdent("message".to_string())),
|
|
filters: vec![ContentFilter::Contains("error".to_string())],
|
|
}),
|
|
limit: Limit {
|
|
skip: None,
|
|
fetch: None,
|
|
},
|
|
context: Context::None,
|
|
columns: vec![],
|
|
exprs: vec![],
|
|
};
|
|
|
|
let plan = planner.query_to_plan(log_query).await.unwrap();
|
|
let expected = "Filter: greptime.public.test_table.timestamp >= Utf8(\"2021-01-01T00:00:00Z\") AND greptime.public.test_table.timestamp < Utf8(\"2021-01-02T00:00:00Z\") AND greptime.public.test_table.message LIKE Utf8(\"%error%\") [message:Utf8, timestamp:Timestamp(ms), host:Utf8;N, is_active:Boolean;N]\
|
|
\n TableScan: greptime.public.test_table [message:Utf8, timestamp:Timestamp(ms), host:Utf8;N, is_active:Boolean;N]";
|
|
|
|
assert_eq!(plan.display_indent_schema().to_string(), expected);
|
|
}
|
|
|
|
#[test]
|
|
fn test_escape_pattern() {
|
|
assert_eq!(escape_like_pattern("test"), "test");
|
|
assert_eq!(escape_like_pattern("te%st"), "te\\%st");
|
|
assert_eq!(escape_like_pattern("te_st"), "te\\_st");
|
|
assert_eq!(escape_like_pattern("te\\st"), "te\\\\st");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_query_to_plan_with_aggr_func() {
|
|
let table_provider =
|
|
build_test_table_provider(&[("public".to_string(), "test_table".to_string())]).await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let mut planner = LogQueryPlanner::new(table_provider, session_state);
|
|
|
|
let log_query = LogQuery {
|
|
table: TableName::new(DEFAULT_CATALOG_NAME, "public", "test_table"),
|
|
time_filter: TimeFilter {
|
|
start: Some("2021-01-01T00:00:00Z".to_string()),
|
|
end: Some("2021-01-02T00:00:00Z".to_string()),
|
|
span: None,
|
|
},
|
|
filters: Default::default(),
|
|
limit: Limit {
|
|
skip: None,
|
|
fetch: Some(100),
|
|
},
|
|
context: Context::None,
|
|
columns: vec![],
|
|
exprs: vec![LogExpr::AggrFunc {
|
|
expr: vec![AggFunc::new(
|
|
"count".to_string(),
|
|
vec![LogExpr::NamedIdent("message".to_string())],
|
|
Some("count_result".to_string()),
|
|
)],
|
|
by: vec![LogExpr::NamedIdent("host".to_string())],
|
|
}],
|
|
};
|
|
|
|
let plan = planner.query_to_plan(log_query).await.unwrap();
|
|
let expected = "Limit: skip=0, fetch=100 [host:Utf8;N, count_result:Int64]\
|
|
\n Aggregate: groupBy=[[greptime.public.test_table.host]], aggr=[[count(greptime.public.test_table.message) AS count_result]] [host:Utf8;N, count_result:Int64]\
|
|
\n Filter: greptime.public.test_table.timestamp >= Utf8(\"2021-01-01T00:00:00Z\") AND greptime.public.test_table.timestamp < Utf8(\"2021-01-02T00:00:00Z\") [message:Utf8, timestamp:Timestamp(ms), host:Utf8;N, is_active:Boolean;N]\
|
|
\n TableScan: greptime.public.test_table [message:Utf8, timestamp:Timestamp(ms), host:Utf8;N, is_active:Boolean;N]";
|
|
|
|
assert_eq!(plan.display_indent_schema().to_string(), expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_query_to_plan_with_scalar_func() {
|
|
let table_provider =
|
|
build_test_table_provider(&[("public".to_string(), "test_table".to_string())]).await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let mut planner = LogQueryPlanner::new(table_provider, session_state);
|
|
|
|
let log_query = LogQuery {
|
|
table: TableName::new(DEFAULT_CATALOG_NAME, "public", "test_table"),
|
|
time_filter: TimeFilter {
|
|
start: Some("2021-01-01T00:00:00Z".to_string()),
|
|
end: Some("2021-01-02T00:00:00Z".to_string()),
|
|
span: None,
|
|
},
|
|
filters: Default::default(),
|
|
limit: Limit {
|
|
skip: None,
|
|
fetch: Some(100),
|
|
},
|
|
context: Context::None,
|
|
columns: vec![],
|
|
exprs: vec![LogExpr::ScalarFunc {
|
|
name: "date_trunc".to_string(),
|
|
args: vec![
|
|
LogExpr::Literal("day".to_string()),
|
|
LogExpr::NamedIdent("timestamp".to_string()),
|
|
],
|
|
alias: Some("time_bucket".to_string()),
|
|
}],
|
|
};
|
|
|
|
let plan = planner.query_to_plan(log_query).await.unwrap();
|
|
let expected = "Limit: skip=0, fetch=100 [time_bucket:Timestamp(ms)]\
|
|
\n Projection: date_trunc(Utf8(\"day\"), greptime.public.test_table.timestamp) AS time_bucket [time_bucket:Timestamp(ms)]\
|
|
\n Filter: greptime.public.test_table.timestamp >= Utf8(\"2021-01-01T00:00:00Z\") AND greptime.public.test_table.timestamp < Utf8(\"2021-01-02T00:00:00Z\") [message:Utf8, timestamp:Timestamp(ms), host:Utf8;N, is_active:Boolean;N]\
|
|
\n TableScan: greptime.public.test_table [message:Utf8, timestamp:Timestamp(ms), host:Utf8;N, is_active:Boolean;N]";
|
|
|
|
assert_eq!(plan.display_indent_schema().to_string(), expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_build_content_filter_between() {
|
|
let table_provider =
|
|
build_test_table_provider(&[("public".to_string(), "test_table".to_string())]).await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let planner = LogQueryPlanner::new(table_provider, session_state);
|
|
let schema = mock_schema();
|
|
|
|
let column_filter = ColumnFilters {
|
|
expr: Box::new(LogExpr::NamedIdent("message".to_string())),
|
|
filters: vec![ContentFilter::Between {
|
|
start: "a".to_string(),
|
|
end: "z".to_string(),
|
|
start_inclusive: true,
|
|
end_inclusive: false,
|
|
}],
|
|
};
|
|
|
|
let expr_option = planner
|
|
.build_column_filter(&column_filter, schema.arrow_schema())
|
|
.unwrap();
|
|
assert!(expr_option.is_some());
|
|
|
|
let expr = expr_option.unwrap();
|
|
let expected_expr = col("message")
|
|
.gt_eq(lit(ScalarValue::Utf8(Some("a".to_string()))))
|
|
.and(col("message").lt(lit(ScalarValue::Utf8(Some("z".to_string())))));
|
|
|
|
assert_eq!(format!("{:?}", expr), format!("{:?}", expected_expr));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_query_to_plan_with_date_histogram() {
|
|
let table_provider =
|
|
build_test_table_provider(&[("public".to_string(), "test_table".to_string())]).await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let mut planner = LogQueryPlanner::new(table_provider, session_state);
|
|
|
|
let log_query = LogQuery {
|
|
table: TableName::new(DEFAULT_CATALOG_NAME, "public", "test_table"),
|
|
time_filter: TimeFilter {
|
|
start: Some("2021-01-01T00:00:00Z".to_string()),
|
|
end: Some("2021-01-02T00:00:00Z".to_string()),
|
|
span: None,
|
|
},
|
|
filters: Default::default(),
|
|
limit: Limit {
|
|
skip: Some(0),
|
|
fetch: None,
|
|
},
|
|
context: Context::None,
|
|
columns: vec![],
|
|
exprs: vec![
|
|
LogExpr::ScalarFunc {
|
|
name: "date_bin".to_string(),
|
|
args: vec![
|
|
LogExpr::Literal("30 seconds".to_string()),
|
|
LogExpr::NamedIdent("timestamp".to_string()),
|
|
],
|
|
alias: Some("2__date_histogram__time_bucket".to_string()),
|
|
},
|
|
LogExpr::AggrFunc {
|
|
expr: vec![AggFunc::new(
|
|
"count".to_string(),
|
|
vec![LogExpr::PositionalIdent(0)],
|
|
Some("count_result".to_string()),
|
|
)],
|
|
by: vec![LogExpr::NamedIdent(
|
|
"2__date_histogram__time_bucket".to_string(),
|
|
)],
|
|
},
|
|
],
|
|
};
|
|
|
|
let plan = planner.query_to_plan(log_query).await.unwrap();
|
|
let expected = "Limit: skip=0, fetch=None [2__date_histogram__time_bucket:Timestamp(ns);N, count_result:Int64]\
|
|
\n Aggregate: groupBy=[[2__date_histogram__time_bucket]], aggr=[[count(2__date_histogram__time_bucket) AS count_result]] [2__date_histogram__time_bucket:Timestamp(ns);N, count_result:Int64]\
|
|
\n Projection: date_bin(Utf8(\"30 seconds\"), greptime.public.test_table.timestamp) AS 2__date_histogram__time_bucket [2__date_histogram__time_bucket:Timestamp(ns);N]\
|
|
\n Filter: greptime.public.test_table.timestamp >= Utf8(\"2021-01-01T00:00:00Z\") AND greptime.public.test_table.timestamp < Utf8(\"2021-01-02T00:00:00Z\") [message:Utf8, timestamp:Timestamp(ms), host:Utf8;N, is_active:Boolean;N]\
|
|
\n TableScan: greptime.public.test_table [message:Utf8, timestamp:Timestamp(ms), host:Utf8;N, is_active:Boolean;N]";
|
|
|
|
assert_eq!(plan.display_indent_schema().to_string(), expected);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_build_compound_filter() {
|
|
let table_provider =
|
|
build_test_table_provider(&[("public".to_string(), "test_table".to_string())]).await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let planner = LogQueryPlanner::new(table_provider, session_state);
|
|
let schema = mock_schema();
|
|
|
|
// Test AND compound
|
|
let column_filter = ColumnFilters {
|
|
expr: Box::new(LogExpr::NamedIdent("message".to_string())),
|
|
filters: vec![
|
|
ContentFilter::Contains("error".to_string()),
|
|
ContentFilter::Prefix("WARN".to_string()),
|
|
],
|
|
};
|
|
let expr = planner
|
|
.build_column_filter(&column_filter, schema.arrow_schema())
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
let expected_expr = col("message")
|
|
.like(lit(ScalarValue::Utf8(Some("%error%".to_string()))))
|
|
.and(col("message").like(lit(ScalarValue::Utf8(Some("WARN%".to_string())))));
|
|
|
|
assert_eq!(format!("{:?}", expr), format!("{:?}", expected_expr));
|
|
|
|
// Test OR compound - use Compound filter for OR logic
|
|
let column_filter = ColumnFilters {
|
|
expr: Box::new(LogExpr::NamedIdent("message".to_string())),
|
|
filters: vec![ContentFilter::Compound(
|
|
vec![
|
|
ContentFilter::Contains("error".to_string()),
|
|
ContentFilter::Prefix("WARN".to_string()),
|
|
],
|
|
ConjunctionOperator::Or,
|
|
)],
|
|
};
|
|
let expr = planner
|
|
.build_column_filter(&column_filter, schema.arrow_schema())
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
let expected_expr = col("message")
|
|
.like(lit(ScalarValue::Utf8(Some("%error%".to_string()))))
|
|
.or(col("message").like(lit(ScalarValue::Utf8(Some("WARN%".to_string())))));
|
|
|
|
assert_eq!(format!("{:?}", expr), format!("{:?}", expected_expr));
|
|
|
|
// Test nested compound
|
|
let column_filter = ColumnFilters {
|
|
expr: Box::new(LogExpr::NamedIdent("message".to_string())),
|
|
filters: vec![ContentFilter::Compound(
|
|
vec![
|
|
ContentFilter::Contains("error".to_string()),
|
|
ContentFilter::Compound(
|
|
vec![
|
|
ContentFilter::Prefix("WARN".to_string()),
|
|
ContentFilter::Exact("DEBUG".to_string()),
|
|
],
|
|
ConjunctionOperator::Or,
|
|
),
|
|
],
|
|
ConjunctionOperator::And,
|
|
)],
|
|
};
|
|
let expr = planner
|
|
.build_column_filter(&column_filter, schema.arrow_schema())
|
|
.unwrap()
|
|
.unwrap();
|
|
|
|
let expected_nested = col("message")
|
|
.like(lit(ScalarValue::Utf8(Some("WARN%".to_string()))))
|
|
.or(col("message").like(lit(ScalarValue::Utf8(Some("DEBUG".to_string())))));
|
|
let expected_expr = col("message")
|
|
.like(lit(ScalarValue::Utf8(Some("%error%".to_string()))))
|
|
.and(expected_nested);
|
|
|
|
assert_eq!(format!("{:?}", expr), format!("{:?}", expected_expr));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_build_great_than_filter() {
|
|
let table_provider =
|
|
build_test_table_provider(&[("public".to_string(), "test_table".to_string())]).await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let planner = LogQueryPlanner::new(table_provider, session_state);
|
|
let schema = mock_schema();
|
|
|
|
// Test GreatThan with inclusive=true
|
|
let column_filter = ColumnFilters {
|
|
expr: Box::new(LogExpr::NamedIdent("message".to_string())),
|
|
filters: vec![ContentFilter::GreatThan {
|
|
value: "error".to_string(),
|
|
inclusive: true,
|
|
}],
|
|
};
|
|
|
|
let expr_option = planner
|
|
.build_column_filter(&column_filter, schema.arrow_schema())
|
|
.unwrap();
|
|
assert!(expr_option.is_some());
|
|
|
|
let expr = expr_option.unwrap();
|
|
let expected_expr = col("message").gt_eq(lit(ScalarValue::Utf8(Some("error".to_string()))));
|
|
|
|
assert_eq!(format!("{:?}", expr), format!("{:?}", expected_expr));
|
|
|
|
// Test GreatThan with inclusive=false
|
|
let column_filter = ColumnFilters {
|
|
expr: Box::new(LogExpr::NamedIdent("message".to_string())),
|
|
filters: vec![ContentFilter::GreatThan {
|
|
value: "error".to_string(),
|
|
inclusive: false,
|
|
}],
|
|
};
|
|
|
|
let expr_option = planner
|
|
.build_column_filter(&column_filter, schema.arrow_schema())
|
|
.unwrap();
|
|
assert!(expr_option.is_some());
|
|
|
|
let expr = expr_option.unwrap();
|
|
let expected_expr = col("message").gt(lit(ScalarValue::Utf8(Some("error".to_string()))));
|
|
|
|
assert_eq!(format!("{:?}", expr), format!("{:?}", expected_expr));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_build_less_than_filter() {
|
|
let table_provider =
|
|
build_test_table_provider(&[("public".to_string(), "test_table".to_string())]).await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let planner = LogQueryPlanner::new(table_provider, session_state);
|
|
let schema = mock_schema();
|
|
|
|
// Test LessThan with inclusive=true
|
|
let column_filter = ColumnFilters {
|
|
expr: Box::new(LogExpr::NamedIdent("message".to_string())),
|
|
filters: vec![ContentFilter::LessThan {
|
|
value: "error".to_string(),
|
|
inclusive: true,
|
|
}],
|
|
};
|
|
|
|
let expr_option = planner
|
|
.build_column_filter(&column_filter, schema.arrow_schema())
|
|
.unwrap();
|
|
assert!(expr_option.is_some());
|
|
|
|
let expr = expr_option.unwrap();
|
|
let expected_expr = col("message").lt_eq(lit(ScalarValue::Utf8(Some("error".to_string()))));
|
|
|
|
assert_eq!(format!("{:?}", expr), format!("{:?}", expected_expr));
|
|
|
|
// Test LessThan with inclusive=false
|
|
let column_filter = ColumnFilters {
|
|
expr: Box::new(LogExpr::NamedIdent("message".to_string())),
|
|
filters: vec![ContentFilter::LessThan {
|
|
value: "error".to_string(),
|
|
inclusive: false,
|
|
}],
|
|
};
|
|
|
|
let expr_option = planner
|
|
.build_column_filter(&column_filter, schema.arrow_schema())
|
|
.unwrap();
|
|
assert!(expr_option.is_some());
|
|
|
|
let expr = expr_option.unwrap();
|
|
let expected_expr = col("message").lt(lit(ScalarValue::Utf8(Some("error".to_string()))));
|
|
|
|
assert_eq!(format!("{:?}", expr), format!("{:?}", expected_expr));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_build_in_filter() {
|
|
let table_provider =
|
|
build_test_table_provider(&[("public".to_string(), "test_table".to_string())]).await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let planner = LogQueryPlanner::new(table_provider, session_state);
|
|
let schema = mock_schema();
|
|
|
|
// Test In filter with multiple values
|
|
let column_filter = ColumnFilters {
|
|
expr: Box::new(LogExpr::NamedIdent("message".to_string())),
|
|
filters: vec![ContentFilter::In(vec![
|
|
"error".to_string(),
|
|
"warning".to_string(),
|
|
"info".to_string(),
|
|
])],
|
|
};
|
|
|
|
let expr_option = planner
|
|
.build_column_filter(&column_filter, schema.arrow_schema())
|
|
.unwrap();
|
|
assert!(expr_option.is_some());
|
|
|
|
let expr = expr_option.unwrap();
|
|
let expected_expr = col("message").in_list(
|
|
vec![
|
|
lit(ScalarValue::Utf8(Some("error".to_string()))),
|
|
lit(ScalarValue::Utf8(Some("warning".to_string()))),
|
|
lit(ScalarValue::Utf8(Some("info".to_string()))),
|
|
],
|
|
false,
|
|
);
|
|
|
|
assert_eq!(format!("{:?}", expr), format!("{:?}", expected_expr));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_build_is_true_filter() {
|
|
let table_provider =
|
|
build_test_table_provider(&[("public".to_string(), "test_table".to_string())]).await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let planner = LogQueryPlanner::new(table_provider, session_state);
|
|
let schema = mock_schema();
|
|
|
|
// Test IsTrue filter
|
|
let column_filter = ColumnFilters {
|
|
expr: Box::new(LogExpr::NamedIdent("is_active".to_string())),
|
|
filters: vec![ContentFilter::IsTrue],
|
|
};
|
|
|
|
let expr_option = planner
|
|
.build_column_filter(&column_filter, schema.arrow_schema())
|
|
.unwrap();
|
|
assert!(expr_option.is_some());
|
|
|
|
let expr = expr_option.unwrap();
|
|
let expected_expr_string =
|
|
"IsTrue(Column(Column { relation: None, name: \"is_active\" }))".to_string();
|
|
|
|
assert_eq!(format!("{:?}", expr), expected_expr_string);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_build_filter_with_scalar_fn() {
|
|
let table_provider =
|
|
build_test_table_provider(&[("public".to_string(), "test_table".to_string())]).await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let planner = LogQueryPlanner::new(table_provider, session_state);
|
|
let schema = mock_schema();
|
|
|
|
let column_filter = ColumnFilters {
|
|
expr: Box::new(LogExpr::BinaryOp {
|
|
left: Box::new(LogExpr::ScalarFunc {
|
|
name: "character_length".to_string(),
|
|
args: vec![LogExpr::NamedIdent("message".to_string())],
|
|
alias: None,
|
|
}),
|
|
op: BinaryOperator::Gt,
|
|
right: Box::new(LogExpr::Literal("100".to_string())),
|
|
}),
|
|
filters: vec![ContentFilter::IsTrue],
|
|
};
|
|
|
|
let expr_option = planner
|
|
.build_column_filter(&column_filter, schema.arrow_schema())
|
|
.unwrap();
|
|
assert!(expr_option.is_some());
|
|
|
|
let expr = expr_option.unwrap();
|
|
let expected_expr_string = "character_length(message) > Int32(100) IS TRUE";
|
|
|
|
assert_eq!(format!("{}", expr), expected_expr_string);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_type_inference_float_comparison() {
|
|
let table_provider = build_test_table_provider_with_typed_columns(&[(
|
|
"public".to_string(),
|
|
"test_table".to_string(),
|
|
)])
|
|
.await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let planner = LogQueryPlanner::new(table_provider, session_state);
|
|
let schema = mock_schema_with_typed_columns();
|
|
|
|
// Test Between with float column and string literals
|
|
let column_filter = ColumnFilters {
|
|
expr: Box::new(LogExpr::NamedIdent("score".to_string())),
|
|
filters: vec![ContentFilter::Between {
|
|
start: "75.5".to_string(),
|
|
end: "100.0".to_string(),
|
|
start_inclusive: true,
|
|
end_inclusive: false,
|
|
}],
|
|
};
|
|
|
|
let expr_option = planner
|
|
.build_column_filter(&column_filter, schema.arrow_schema())
|
|
.unwrap();
|
|
assert!(expr_option.is_some());
|
|
|
|
let expr = expr_option.unwrap();
|
|
// Should infer literals as Float64 since score is a float64 column
|
|
let expected_expr = col("score")
|
|
.gt_eq(lit(ScalarValue::Float64(Some(75.5))))
|
|
.and(col("score").lt(lit(ScalarValue::Float64(Some(100.0)))));
|
|
|
|
assert_eq!(format!("{:?}", expr), format!("{:?}", expected_expr));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_type_inference_boolean_comparison() {
|
|
let table_provider = build_test_table_provider_with_typed_columns(&[(
|
|
"public".to_string(),
|
|
"test_table".to_string(),
|
|
)])
|
|
.await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let planner = LogQueryPlanner::new(table_provider, session_state);
|
|
let schema = mock_schema_with_typed_columns();
|
|
|
|
// Test In filter with boolean column and string literals
|
|
let column_filter = ColumnFilters {
|
|
expr: Box::new(LogExpr::NamedIdent("is_active".to_string())),
|
|
filters: vec![ContentFilter::In(vec![
|
|
"true".to_string(),
|
|
"1".to_string(),
|
|
"false".to_string(),
|
|
])],
|
|
};
|
|
|
|
let expr_option = planner
|
|
.build_column_filter(&column_filter, schema.arrow_schema())
|
|
.unwrap();
|
|
assert!(expr_option.is_some());
|
|
|
|
let expr = expr_option.unwrap();
|
|
// Should infer string literals as boolean values
|
|
let expected_expr = col("is_active").in_list(
|
|
vec![
|
|
lit(ScalarValue::Boolean(Some(true))),
|
|
lit(ScalarValue::Boolean(Some(true))),
|
|
lit(ScalarValue::Boolean(Some(false))),
|
|
],
|
|
false,
|
|
);
|
|
|
|
assert_eq!(format!("{:?}", expr), format!("{:?}", expected_expr));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_fallback_to_utf8_on_parse_failure() {
|
|
let table_provider = build_test_table_provider_with_typed_columns(&[(
|
|
"public".to_string(),
|
|
"test_table".to_string(),
|
|
)])
|
|
.await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let planner = LogQueryPlanner::new(table_provider, session_state);
|
|
let schema = mock_schema_with_typed_columns();
|
|
|
|
// Test with invalid number format - should fallback to UTF8
|
|
let column_filter = ColumnFilters {
|
|
expr: Box::new(LogExpr::NamedIdent("age".to_string())),
|
|
filters: vec![ContentFilter::GreatThan {
|
|
value: "not_a_number".to_string(),
|
|
inclusive: false,
|
|
}],
|
|
};
|
|
|
|
let expr_option = planner
|
|
.build_column_filter(&column_filter, schema.arrow_schema())
|
|
.unwrap();
|
|
assert!(expr_option.is_some());
|
|
|
|
let expr = expr_option.unwrap();
|
|
// Should fallback to UTF8 since "not_a_number" can't be parsed as int32
|
|
let expected_expr = col("age").gt(lit(ScalarValue::Utf8(Some("not_a_number".to_string()))));
|
|
|
|
assert_eq!(format!("{:?}", expr), format!("{:?}", expected_expr));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_string_column_remains_utf8() {
|
|
let table_provider = build_test_table_provider_with_typed_columns(&[(
|
|
"public".to_string(),
|
|
"test_table".to_string(),
|
|
)])
|
|
.await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let planner = LogQueryPlanner::new(table_provider, session_state);
|
|
let schema = mock_schema_with_typed_columns();
|
|
|
|
// Test with string column - should remain UTF8 even if value looks like a number
|
|
let column_filter = ColumnFilters {
|
|
expr: Box::new(LogExpr::NamedIdent("message".to_string())),
|
|
filters: vec![ContentFilter::GreatThan {
|
|
value: "123".to_string(),
|
|
inclusive: false,
|
|
}],
|
|
};
|
|
|
|
let expr_option = planner
|
|
.build_column_filter(&column_filter, schema.arrow_schema())
|
|
.unwrap();
|
|
assert!(expr_option.is_some());
|
|
|
|
let expr = expr_option.unwrap();
|
|
// Should remain UTF8 since message is a string column
|
|
let expected_expr = col("message").gt(lit(ScalarValue::Utf8(Some("123".to_string()))));
|
|
|
|
assert_eq!(format!("{:?}", expr), format!("{:?}", expected_expr));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_all_binary_operators() {
|
|
let table_provider = build_test_table_provider_with_typed_columns(&[(
|
|
"public".to_string(),
|
|
"test_table".to_string(),
|
|
)])
|
|
.await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let planner = LogQueryPlanner::new(table_provider, session_state);
|
|
let schema = mock_schema_with_typed_columns();
|
|
|
|
let df_schema = DFSchema::try_from(schema.arrow_schema().clone()).unwrap();
|
|
|
|
// Test all comparison operators
|
|
let test_cases = vec![
|
|
(BinaryOperator::Eq, Operator::Eq),
|
|
(BinaryOperator::Ne, Operator::NotEq),
|
|
(BinaryOperator::Lt, Operator::Lt),
|
|
(BinaryOperator::Le, Operator::LtEq),
|
|
(BinaryOperator::Gt, Operator::Gt),
|
|
(BinaryOperator::Ge, Operator::GtEq),
|
|
(BinaryOperator::Plus, Operator::Plus),
|
|
(BinaryOperator::Minus, Operator::Minus),
|
|
(BinaryOperator::Multiply, Operator::Multiply),
|
|
(BinaryOperator::Divide, Operator::Divide),
|
|
(BinaryOperator::Modulo, Operator::Modulo),
|
|
(BinaryOperator::And, Operator::And),
|
|
(BinaryOperator::Or, Operator::Or),
|
|
];
|
|
|
|
for (binary_op, expected_df_op) in test_cases {
|
|
let binary_expr = LogExpr::BinaryOp {
|
|
left: Box::new(LogExpr::NamedIdent("age".to_string())),
|
|
op: binary_op,
|
|
right: Box::new(LogExpr::Literal("25".to_string())),
|
|
};
|
|
|
|
let expr = planner
|
|
.log_expr_to_df_expr(&binary_expr, &df_schema)
|
|
.unwrap();
|
|
|
|
let expected_expr = Expr::BinaryExpr(BinaryExpr {
|
|
left: Box::new(col("age")),
|
|
op: expected_df_op,
|
|
right: Box::new(lit(ScalarValue::Int32(Some(25)))),
|
|
});
|
|
|
|
assert_eq!(format!("{:?}", expr), format!("{:?}", expected_expr));
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_nested_binary_operations() {
|
|
let table_provider = build_test_table_provider_with_typed_columns(&[(
|
|
"public".to_string(),
|
|
"test_table".to_string(),
|
|
)])
|
|
.await;
|
|
let session_state = SessionStateBuilder::new().with_default_features().build();
|
|
let planner = LogQueryPlanner::new(table_provider, session_state);
|
|
let schema = mock_schema_with_typed_columns();
|
|
|
|
let df_schema = DFSchema::try_from(schema.arrow_schema().clone()).unwrap();
|
|
|
|
// Test nested binary operations: (age + 5) > 30
|
|
let nested_binary_expr = LogExpr::BinaryOp {
|
|
left: Box::new(LogExpr::BinaryOp {
|
|
left: Box::new(LogExpr::NamedIdent("age".to_string())),
|
|
op: BinaryOperator::Plus,
|
|
right: Box::new(LogExpr::Literal("5".to_string())),
|
|
}),
|
|
op: BinaryOperator::Gt,
|
|
right: Box::new(LogExpr::Literal("30".to_string())),
|
|
};
|
|
|
|
let expr = planner
|
|
.log_expr_to_df_expr(&nested_binary_expr, &df_schema)
|
|
.unwrap();
|
|
|
|
// Verify the nested structure is properly created
|
|
let expected_expr_debug = r#"BinaryExpr(BinaryExpr { left: BinaryExpr(BinaryExpr { left: Column(Column { relation: None, name: "age" }), op: Plus, right: Literal(Int32(5), None) }), op: Gt, right: Literal(Int32(30), None) })"#;
|
|
assert_eq!(format!("{:?}", expr), expected_expr_debug);
|
|
}
|
|
}
|