From 871b81eb2754347258dfdd1f8d98ef5a031d071a Mon Sep 17 00:00:00 2001 From: Paul Masurel Date: Tue, 18 Aug 2026 14:37:39 +0200 Subject: [PATCH] Add AND function --- jitexpr/docs/FUNCTIONS.md | 4 +- jitexpr/src/ast/serialize.rs | 2 + jitexpr/src/functions/and.rs | 187 +++++++++++++++++++++++++++++++++++ jitexpr/src/functions/mod.rs | 9 ++ 4 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 jitexpr/src/functions/and.rs diff --git a/jitexpr/docs/FUNCTIONS.md b/jitexpr/docs/FUNCTIONS.md index a55709d1b..265c7dee2 100644 --- a/jitexpr/docs/FUNCTIONS.md +++ b/jitexpr/docs/FUNCTIONS.md @@ -20,7 +20,7 @@ excluded from this pass. | Tag | Function | Status | |---:|---|---| -| 1 | `AND` | pending | +| 1 | `AND` | done | | 2 | `OR` | pending | | 3 | `NOT` | done | | 4 | `ADD` | done | @@ -76,4 +76,4 @@ excluded from this pass. | 79 | `SUBSTRING_COUNT` | pending | | 80 | `REGEXP_LIKE` | pending | -Progress: **7 / 48 in-scope** functions implemented; **7** functions are out-of-scope. +Progress: **8 / 48 in-scope** functions implemented; **7** functions are out-of-scope. diff --git a/jitexpr/src/ast/serialize.rs b/jitexpr/src/ast/serialize.rs index bc961a88f..5e07e27b4 100644 --- a/jitexpr/src/ast/serialize.rs +++ b/jitexpr/src/ast/serialize.rs @@ -118,6 +118,7 @@ fn format_string(value: &str, formatter: &mut fmt::Formatter<'_>) -> fmt::Result fn function_name(function: Function) -> &'static str { match function { + Function::And => "AND", Function::Add => "ADD", Function::Eq => "EQ", Function::IsNull => "IS_NULL", @@ -130,6 +131,7 @@ fn function_name(function: Function) -> &'static str { fn parse_function(name: &str, offset: usize) -> Result { match name { + "AND" => Ok(Function::And), "ADD" => Ok(Function::Add), "EQ" => Ok(Function::Eq), "IS_NULL" => Ok(Function::IsNull), diff --git a/jitexpr/src/functions/and.rs b/jitexpr/src/functions/and.rs new file mode 100644 index 000000000..f304fff7d --- /dev/null +++ b/jitexpr/src/functions/and.rs @@ -0,0 +1,187 @@ +//! `AND` combines one or more nullable boolean expressions. +//! +//! All arguments must be boolean. With present inputs it is ordinary conjunction. Null handling is +//! deliberately stricter than SQL: if any argument is absent, the result is absent even when +//! another argument is already `false`. Thus `FALSE AND NULL = NULL`. Null result rows carry a +//! false payload, matching dd-go's bitset representation. +//! +//! dd-go implements this by unioning the operands' null sets and clearing result bits at every null +//! row (`interpreter/vector.go`, `propagateNullsBoolean`). The production type checker accepts one +//! or more boolean operands. Evaluation order and short-circuiting are query-engine concerns; this +//! scalar implementation evaluates every child expression. + +use std::collections::HashMap; + +use cranelift::prelude::{FunctionBuilder, InstBuilder, types}; + +use crate::ast::{Function, InferredTypeSet, TypeError, UntypedExpr}; +use crate::compile::{ + CompileError, CompileFnBuilder, LoweredValue, LoweringContext, TypedExpr, TypedExprAst, +}; +use crate::functions::{FnCall, FnCallEnum}; +use crate::types::VarType; + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct AndFnCall { + pub(crate) args: Box<[TypedExpr]>, +} + +impl FnCall for AndFnCall { + fn infer_types<'a>( + args: &'a [UntypedExpr], + target_type: InferredTypeSet, + inferred_types: &mut HashMap<&'a str, InferredTypeSet>, + ) -> Result { + if target_type.intersect(InferredTypeSet::BOOLEAN).is_none() { + return Err(TypeError::WrongFunctionReturnType { + function: Function::And, + expected: target_type, + got: InferredTypeSet::BOOLEAN, + }); + } + if args.is_empty() { + return Err(TypeError::InvalidNumberOfArguments { + function: Function::And, + expected: 1, + got: 0, + }); + } + + for arg in args { + crate::ast::infer_types_aux(arg, InferredTypeSet::BOOLEAN, inferred_types)?; + } + Ok(InferredTypeSet::BOOLEAN) + } + + fn call_with_types( + args: &[UntypedExpr], + target_type_set: InferredTypeSet, + context: &mut CompileFnBuilder<'_, '_>, + ) -> Result { + assert!(!args.is_empty(), "expected at least 1 arg for AND"); + debug_assert!(target_type_set.contains(VarType::Bool)); + + let args = args + .iter() + .map(|arg| context.apply_types(arg, InferredTypeSet::BOOLEAN)) + .collect::, _>>()?; + Ok(TypedExpr { + return_type: VarType::Bool, + ast: TypedExprAst::from_call(AndFnCall { + args: args.into_boxed_slice(), + }), + }) + } + + fn args_mut(&mut self) -> &mut [TypedExpr] { + &mut self.args + } + + fn emit_cranelift_ir( + &self, + return_type: VarType, + context: &mut LoweringContext<'_>, + builder: &mut FunctionBuilder<'_>, + ) -> Result { + debug_assert_eq!(return_type, VarType::Bool); + let mut value = builder.ins().iconst(types::I8, 1); + let mut is_present = value; + + for arg in &self.args { + let lowered = context.compile_expr(arg, builder)?; + is_present = builder.ins().band(is_present, lowered.is_present); + if arg.return_type == VarType::None { + value = builder.ins().iconst(types::I8, 0); + } else { + value = builder.ins().band(value, lowered.value); + } + } + value = builder.ins().band(value, is_present); + + Ok(LoweredValue { + value, + is_present, + string_len: builder.ins().iconst(types::I64, 0), + }) + } +} + +impl From for FnCallEnum { + fn from(call: AndFnCall) -> Self { + FnCallEnum::And(call) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ast::{deserialize, infer_types}; + use crate::compile::compile; + use crate::types::VariableValue; + + fn eval(expression: &str) -> Option { + let expression = deserialize(expression).unwrap(); + let mut compiled = compile(&expression, &HashMap::new()).unwrap(); + // SAFETY: These expressions have no runtime inputs and return nullable booleans. + unsafe { compiled.call(&[]).as_bool() } + } + + #[test] + fn test_infer_types_requires_boolean_arguments() { + let expression = deserialize("(AND left right)").unwrap(); + let inferred_types = infer_types(&expression).unwrap(); + assert_eq!(inferred_types.get("left"), Some(&InferredTypeSet::BOOLEAN)); + assert_eq!(inferred_types.get("right"), Some(&InferredTypeSet::BOOLEAN)); + + let expression = deserialize("(AND)").unwrap(); + assert!(matches!( + infer_types(&expression), + Err(TypeError::InvalidNumberOfArguments { + function: Function::And, + expected: 1, + got: 0, + }) + )); + } + + #[test] + fn test_present_truth_table_and_variadic_inputs() { + assert_eq!(eval("(AND true)"), Some(true)); + assert_eq!(eval("(AND true true true)"), Some(true)); + assert_eq!(eval("(AND true false true)"), Some(false)); + assert_eq!(eval("(AND false false)"), Some(false)); + } + + #[test] + fn test_any_absent_argument_makes_result_absent() { + assert_eq!(eval("(AND true none)"), None); + assert_eq!(eval("(AND false none)"), None); + assert_eq!(eval("(AND none none)"), None); + } + + #[test] + fn test_runtime_null_propagation() { + let expression = deserialize("(AND left right)").unwrap(); + let variable_types = HashMap::from([("left", VarType::Bool), ("right", VarType::Bool)]); + let mut compiled = compile(&expression, &variable_types).unwrap(); + + // SAFETY: The input and output types match the compiled signature. + assert_eq!( + unsafe { + compiled + .call(&[VariableValue::some(false), VariableValue::none()]) + .as_bool() + }, + None + ); + // SAFETY: The input and output types match the compiled signature. + assert_eq!( + unsafe { + compiled + .call(&[VariableValue::some(true), VariableValue::some(false)]) + .as_bool() + }, + Some(false) + ); + } +} diff --git a/jitexpr/src/functions/mod.rs b/jitexpr/src/functions/mod.rs index aaf1b0516..ad4818986 100644 --- a/jitexpr/src/functions/mod.rs +++ b/jitexpr/src/functions/mod.rs @@ -1,4 +1,5 @@ mod add; +mod and; mod eq; mod is_not_null; mod is_null; @@ -12,6 +13,7 @@ use std::collections::HashMap; use cranelift::frontend::FunctionBuilder; pub(crate) use self::add::AddFnCall; +pub(crate) use self::and::AndFnCall; pub(crate) use self::eq::EqFnCall; pub(crate) use self::is_not_null::IsNotNullFnCall; pub(crate) use self::is_null::IsNullFnCall; @@ -28,6 +30,8 @@ use crate::types::VarType; /// A function supported by the first expression-language milestone. #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] pub enum Function { + /// Conjoins one or more booleans with strict null propagation. + And, /// Adds zero or more numerical expressions. Add, /// Compares two expressions for value equality. @@ -52,6 +56,7 @@ impl Function { context: &mut CompileFnBuilder<'_, '_>, ) -> Result { match self { + Function::And => ::call_with_types(args, target_type_set, context), Function::Add => ::call_with_types(args, target_type_set, context), Function::Eq => ::call_with_types(args, target_type_set, context), Function::IsNotNull => { @@ -77,6 +82,7 @@ impl Function { inferred_types: &mut HashMap<&'a str, InferredTypeSet>, ) -> Result { match self { + Function::And => ::infer_types(args, target_type, inferred_types), Function::Add => ::infer_types(args, target_type, inferred_types), Function::Eq => ::infer_types(args, target_type, inferred_types), Function::IsNotNull => { @@ -105,6 +111,7 @@ impl Function { #[derive(Clone, Debug, PartialEq)] pub(crate) enum FnCallEnum { + And(AndFnCall), Add(AddFnCall), Eq(EqFnCall), IsNull(IsNullFnCall), @@ -117,6 +124,7 @@ pub(crate) enum FnCallEnum { impl FnCallEnum { pub(crate) fn args_mut(&mut self) -> &mut [TypedExpr] { match self { + FnCallEnum::And(call) => call.args_mut(), FnCallEnum::Add(call) => call.args_mut(), FnCallEnum::Eq(call) => call.args_mut(), FnCallEnum::IsNull(call) => call.args_mut(), @@ -135,6 +143,7 @@ impl FnCallEnum { builder: &mut FunctionBuilder<'_>, ) -> Result { match self { + FnCallEnum::And(call) => call.emit_cranelift_ir(return_type, context, builder), FnCallEnum::Add(call) => call.emit_cranelift_ir(return_type, context, builder), FnCallEnum::Eq(call) => call.emit_cranelift_ir(return_type, context, builder), FnCallEnum::IsNull(call) => call.emit_cranelift_ir(return_type, context, builder),