use std::sync::Arc;
use datafusion::arrow::datatypes::DataType;
use datafusion::common::config::ConfigOptions;
use datafusion::common::tree_node::{Transformed, TreeNode, TreeNodeRecursion};
use datafusion::common::DFSchema;
use datafusion::error::{DataFusionError, Result};
use datafusion::logical_expr::utils::{find_out_reference_exprs, merge_schema};
use datafusion::logical_expr::{Expr, ExprSchemable, LogicalPlan, ScalarUDF};
use datafusion::optimizer::analyzer::type_coercion::TypeCoercion;
use datafusion::optimizer::AnalyzerRule;
use datafusion::sql::unparser::Unparser;
use ddx_core::sqlparser::ast as sql_ast;
use ddx_core::{ColRef, Ddx};
use crate::error::to_df_err;
use crate::markers::{marker_kind, GRAD, JVP};
use crate::replan::{functions_in, replan, ExprContext};
pub struct DdxAnalyzer {
ddx: Ddx,
exprs: ExprContext,
}
impl std::fmt::Debug for DdxAnalyzer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DdxAnalyzer")
.field("rule", &"ddx_markers")
.finish_non_exhaustive()
}
}
impl Default for DdxAnalyzer {
fn default() -> Self {
Self::new()
}
}
impl DdxAnalyzer {
pub fn new() -> Self {
Self::with_engine(Ddx::for_datafusion())
}
pub fn with_engine(ddx: Ddx) -> Self {
Self::with_engine_and_functions(ddx, [])
}
pub fn with_engine_and_functions(
ddx: Ddx,
functions: impl IntoIterator<Item = Arc<ScalarUDF>>,
) -> Self {
DdxAnalyzer {
ddx,
exprs: ExprContext::new(functions),
}
}
fn rewrite_expr(
&self,
expr: Expr,
schema: &DFSchema,
options: &ConfigOptions,
) -> Result<Transformed<Expr>> {
expr.transform_up(|e| {
let Expr::ScalarFunction(call) = &e else {
return Ok(Transformed::no(e));
};
let Some(kind) = marker_kind(call.func.name()) else {
return Ok(Transformed::no(e));
};
let derivative = self.differentiate_call(kind, &call.args, schema, options)?;
Ok(Transformed::yes(derivative))
})
}
fn differentiate_call(
&self,
kind: &'static str,
args: &[Expr],
schema: &DFSchema,
options: &ConfigOptions,
) -> Result<Expr> {
let (body_arg, wrt_arg, tangent_arg) = match (kind, args) {
(GRAD, [body, wrt]) => (body, wrt, None),
(JVP, [body, wrt, tangent]) => (body, wrt, Some(tangent)),
_ => {
return Err(DataFusionError::Plan(format!(
"ddx: `{kind}` was called with {} arguments. \
Write `grad(expr, column)` or `jvp(expr, column, tangent)`.",
args.len()
)))
}
};
if let Some(outer) = outer_reference_in(args) {
return Err(DataFusionError::Plan(format!(
"ddx: this `{kind}` marker is inside a correlated subquery and references \
the outer column `{outer}`. The derivative is re-planned against the \
subquery's own inputs, where an outer column is not in scope, so a \
reference to one cannot be carried through.\n\n\
Use `ddx_datafusion::ddx_sql(&ctx, sql)` instead: it rewrites the SQL text \
before the query is planned, so it has no such limit."
)));
}
let body = to_sql_ast(body_arg)?;
let wrt = wrt_colref(kind, wrt_arg)?;
let derivative: sql_ast::Expr = match tangent_arg {
None => self.ddx.differentiate(&body, &wrt).map_err(to_df_err)?,
Some(tangent) => {
let tangent = to_sql_ast(tangent)?;
self.ddx.jvp(&body, &[(wrt, tangent)]).map_err(to_df_err)?
}
};
let local = functions_in(args);
let replanned = replan(&self.exprs, options, local, derivative, schema)?;
replanned.cast_to(&DataType::Float64, schema).map_err(|e| {
DataFusionError::Plan(format!(
"ddx: the derivative of a `{kind}` argument could not be represented as \
DOUBLE. Every derivative is emitted DOUBLE-typed so that integer \
division cannot silently truncate it: {e}"
))
})
}
}
impl AnalyzerRule for DdxAnalyzer {
fn name(&self) -> &str {
"ddx_markers"
}
fn analyze(&self, plan: LogicalPlan, config: &ConfigOptions) -> Result<LogicalPlan> {
if !plan_has_marker(&plan)? {
return Ok(plan);
}
let plan = self.rewrite_plan(plan, config)?;
TypeCoercion::new().analyze(plan, config)
}
}
impl DdxAnalyzer {
fn rewrite_plan(&self, plan: LogicalPlan, options: &ConfigOptions) -> Result<LogicalPlan> {
plan.transform_up_with_subqueries(|node| {
let schema = merged_input_schema(&node);
let out_schema = Arc::clone(node.schema());
let node = node.map_expressions(|expr| {
let original_name = expr.schema_name().to_string();
let out = self.rewrite_expr(expr, &schema, options)?;
if !out.transformed || !out_schema.has_column_with_unqualified_name(&original_name)
{
return Ok(out);
}
out.map_data(|e| e.alias_if_changed(original_name))
})?;
if node.transformed {
node.map_data(LogicalPlan::recompute_schema)
} else {
Ok(node)
}
})
.map(|t| t.data)
}
}
fn merged_input_schema(plan: &LogicalPlan) -> DFSchema {
let inputs = plan.inputs();
if inputs.is_empty() {
plan.schema().as_ref().clone()
} else {
merge_schema(&inputs)
}
}
fn plan_has_marker(plan: &LogicalPlan) -> Result<bool> {
let mut found = false;
plan.apply_with_subqueries(|node| {
node.apply_expressions(|expr| {
expr.apply(|e| {
if let Expr::ScalarFunction(call) = e {
if marker_kind(call.func.name()).is_some() {
found = true;
return Ok(TreeNodeRecursion::Stop);
}
}
Ok(TreeNodeRecursion::Continue)
})
})?;
Ok(if found {
TreeNodeRecursion::Stop
} else {
TreeNodeRecursion::Continue
})
})?;
Ok(found)
}
fn outer_reference_in(args: &[Expr]) -> Option<String> {
args.iter()
.flat_map(find_out_reference_exprs)
.find_map(|e| match e {
Expr::OuterReferenceColumn(_, col) => Some(col.flat_name()),
_ => None,
})
}
fn to_sql_ast(expr: &Expr) -> Result<sql_ast::Expr> {
Unparser::default().expr_to_sql(expr)
}
fn wrt_colref(kind: &str, arg: &Expr) -> Result<ColRef> {
let unparsed = to_sql_ast(arg)?;
ColRef::from_wrt_arg(kind, &unparsed).map_err(to_df_err)
}