Skip to main content

formualizer_eval/builtins/math/
reduction.rs

1use super::super::utils::{ARG_RANGE_NUM_LENIENT_ONE, coerce_num};
2use crate::args::ArgSchema;
3use crate::function::Function;
4use crate::function_contract::FunctionDependencyContract;
5use crate::traits::{ArgumentHandle, FunctionContext};
6use arrow_array::Array;
7use formualizer_common::{ExcelError, LiteralValue};
8use formualizer_macros::func_caps;
9
10#[derive(Debug)]
11pub struct MinFn; // MIN(...)
12/// Returns the smallest numeric value from one or more arguments.
13///
14/// `MIN` scans scalar values and ranges, considering only values that can be treated as numbers.
15///
16/// # Remarks
17/// - Errors in any scalar argument or range cell propagate immediately.
18/// - In ranges, non-numeric cells are ignored.
19/// - Scalar text is included only when it can be coerced to a number.
20/// - If no numeric value is found, `MIN` returns `0`.
21///
22/// # Examples
23///
24/// ```yaml,sandbox
25/// title: "Minimum in a numeric range"
26/// grid:
27///   A1: 8
28///   A2: -2
29///   A3: 5
30/// formula: "=MIN(A1:A3)"
31/// expected: -2
32/// ```
33///
34/// ```yaml,sandbox
35/// title: "Coercible scalar text participates"
36/// formula: "=MIN(10, \"3\", 7)"
37/// expected: 3
38/// ```
39///
40/// ```yaml,sandbox
41/// title: "No numeric values returns zero"
42/// formula: "=MIN(\"x\")"
43/// expected: 0
44/// ```
45///
46/// ```yaml,docs
47/// related:
48///   - MAX
49///   - SMALL
50///   - LARGE
51///   - MINIFS
52/// faq:
53///   - q: "Why can MIN return 0 even when no numbers are present?"
54///     a: "If nothing numeric is found after coercion/scan, MIN falls back to 0."
55///   - q: "Do errors in referenced ranges get ignored?"
56///     a: "No. Any encountered range or scalar error is propagated."
57/// ```
58///
59/// [formualizer-docgen:schema:start]
60/// Name: MIN
61/// Type: MinFn
62/// Min args: 1
63/// Max args: variadic
64/// Variadic: true
65/// Signature: MIN(arg1...: number@range)
66/// Arg schema: arg1{kinds=number,required=true,shape=range,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}
67/// Caps: PURE, REDUCTION, NUMERIC_ONLY
68/// [formualizer-docgen:schema:end]
69impl Function for MinFn {
70    func_caps!(PURE, REDUCTION, NUMERIC_ONLY);
71    fn name(&self) -> &'static str {
72        "MIN"
73    }
74    fn min_args(&self) -> usize {
75        1
76    }
77    fn variadic(&self) -> bool {
78        true
79    }
80    fn dependency_contract(&self, arity: usize) -> Option<FunctionDependencyContract> {
81        FunctionDependencyContract::static_reduction(arity, self.min_args())
82    }
83    fn arg_schema(&self) -> &'static [ArgSchema] {
84        &ARG_RANGE_NUM_LENIENT_ONE[..]
85    }
86    fn eval<'a, 'b, 'c>(
87        &self,
88        args: &'c [ArgumentHandle<'a, 'b>],
89        _ctx: &dyn FunctionContext<'b>,
90    ) -> Result<crate::traits::CalcValue<'b>, ExcelError> {
91        let mut mv: Option<f64> = None;
92        for a in args {
93            if let Ok(view) = a.range_view() {
94                // Propagate errors from range first
95                for res in view.errors_slices() {
96                    let (_, _, err_cols) = res?;
97                    for col in err_cols {
98                        if col.null_count() < col.len() {
99                            for i in 0..col.len() {
100                                if !col.is_null(i) {
101                                    return Ok(crate::traits::CalcValue::Scalar(
102                                        LiteralValue::Error(ExcelError::new(
103                                            crate::arrow_store::unmap_error_code(col.value(i)),
104                                        )),
105                                    ));
106                                }
107                            }
108                        }
109                    }
110                }
111
112                for res in view.numbers_slices() {
113                    let (_, _, num_cols) = res?;
114                    for col in num_cols {
115                        if let Some(n) = arrow::compute::kernels::aggregate::min(col.as_ref()) {
116                            mv = Some(mv.map(|m| m.min(n)).unwrap_or(n));
117                        }
118                    }
119                }
120            } else {
121                let v = a.value()?.into_literal();
122                match v {
123                    LiteralValue::Error(e) => {
124                        return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(e)));
125                    }
126                    other => {
127                        if let Ok(n) = coerce_num(&other) {
128                            mv = Some(mv.map(|m| m.min(n)).unwrap_or(n));
129                        }
130                    }
131                }
132            }
133        }
134        Ok(crate::traits::CalcValue::Scalar(
135            super::super::utils::aggregate_result(mv.unwrap_or(0.0)),
136        ))
137    }
138}
139
140#[derive(Debug)]
141pub struct MaxFn; // MAX(...)
142/// Returns the largest numeric value from one or more arguments.
143///
144/// `MAX` scans scalar values and ranges, considering only values that can be treated as numbers.
145///
146/// # Remarks
147/// - Errors in any scalar argument or range cell propagate immediately.
148/// - In ranges, non-numeric cells are ignored.
149/// - Scalar text is included only when it can be coerced to a number.
150/// - If no numeric value is found, `MAX` returns `0`.
151///
152/// # Examples
153///
154/// ```yaml,sandbox
155/// title: "Maximum in a numeric range"
156/// grid:
157///   A1: 5
158///   A2: 9
159///   A3: 1
160/// formula: "=MAX(A1:A3)"
161/// expected: 9
162/// ```
163///
164/// ```yaml,sandbox
165/// title: "Scalar text can be coerced"
166/// formula: "=MAX(2, \"11\", 4)"
167/// expected: 11
168/// ```
169///
170/// ```yaml,sandbox
171/// title: "No numeric values returns zero"
172/// formula: "=MAX(\"x\")"
173/// expected: 0
174/// ```
175///
176/// ```yaml,docs
177/// related:
178///   - MIN
179///   - LARGE
180///   - SMALL
181///   - MAXIFS
182/// faq:
183///   - q: "Why can MAX return 0 for non-numeric input sets?"
184///     a: "When no numeric values are found, MAX returns 0 by design."
185///   - q: "Does MAX evaluate scalar text arguments?"
186///     a: "Yes, but only when scalar text can be coerced to a numeric value."
187/// ```
188///
189/// [formualizer-docgen:schema:start]
190/// Name: MAX
191/// Type: MaxFn
192/// Min args: 1
193/// Max args: variadic
194/// Variadic: true
195/// Signature: MAX(arg1...: number@range)
196/// Arg schema: arg1{kinds=number,required=true,shape=range,by_ref=false,coercion=NumberLenientText,max=None,repeating=None,default=false}
197/// Caps: PURE, REDUCTION, NUMERIC_ONLY
198/// [formualizer-docgen:schema:end]
199impl Function for MaxFn {
200    func_caps!(PURE, REDUCTION, NUMERIC_ONLY);
201    fn name(&self) -> &'static str {
202        "MAX"
203    }
204    fn min_args(&self) -> usize {
205        1
206    }
207    fn variadic(&self) -> bool {
208        true
209    }
210    fn dependency_contract(&self, arity: usize) -> Option<FunctionDependencyContract> {
211        FunctionDependencyContract::static_reduction(arity, self.min_args())
212    }
213    fn arg_schema(&self) -> &'static [ArgSchema] {
214        &ARG_RANGE_NUM_LENIENT_ONE[..]
215    }
216    fn eval<'a, 'b, 'c>(
217        &self,
218        args: &'c [ArgumentHandle<'a, 'b>],
219        _ctx: &dyn FunctionContext<'b>,
220    ) -> Result<crate::traits::CalcValue<'b>, ExcelError> {
221        let mut mv: Option<f64> = None;
222        for a in args {
223            if let Ok(view) = a.range_view() {
224                // Propagate errors from range first
225                for res in view.errors_slices() {
226                    let (_, _, err_cols) = res?;
227                    for col in err_cols {
228                        if col.null_count() < col.len() {
229                            for i in 0..col.len() {
230                                if !col.is_null(i) {
231                                    return Ok(crate::traits::CalcValue::Scalar(
232                                        LiteralValue::Error(ExcelError::new(
233                                            crate::arrow_store::unmap_error_code(col.value(i)),
234                                        )),
235                                    ));
236                                }
237                            }
238                        }
239                    }
240                }
241
242                for res in view.numbers_slices() {
243                    let (_, _, num_cols) = res?;
244                    for col in num_cols {
245                        if let Some(n) = arrow::compute::kernels::aggregate::max(col.as_ref()) {
246                            mv = Some(mv.map(|m| m.max(n)).unwrap_or(n));
247                        }
248                    }
249                }
250            } else {
251                let v = a.value()?.into_literal();
252                match v {
253                    LiteralValue::Error(e) => {
254                        return Ok(crate::traits::CalcValue::Scalar(LiteralValue::Error(e)));
255                    }
256                    other => {
257                        if let Ok(n) = coerce_num(&other) {
258                            mv = Some(mv.map(|m| m.max(n)).unwrap_or(n));
259                        }
260                    }
261                }
262            }
263        }
264        Ok(crate::traits::CalcValue::Scalar(
265            super::super::utils::aggregate_result(mv.unwrap_or(0.0)),
266        ))
267    }
268}
269
270pub fn register_builtins() {
271    use std::sync::Arc;
272    crate::function_registry::register_function(Arc::new(MinFn));
273    crate::function_registry::register_function(Arc::new(MaxFn));
274}
275
276#[cfg(test)]
277mod tests_min_max {
278    use super::*;
279    use crate::test_workbook::TestWorkbook;
280    use crate::traits::ArgumentHandle;
281    use formualizer_common::LiteralValue;
282    use formualizer_parse::parser::{ASTNode, ASTNodeType};
283    fn interp(wb: &TestWorkbook) -> crate::interpreter::Interpreter<'_> {
284        wb.interpreter()
285    }
286
287    #[test]
288    fn min_basic_array_and_scalar() {
289        let wb = TestWorkbook::new().with_function(std::sync::Arc::new(MinFn));
290        let ctx = interp(&wb);
291        let arr = ASTNode::new(
292            ASTNodeType::Literal(LiteralValue::Array(vec![vec![
293                LiteralValue::Int(5),
294                LiteralValue::Int(2),
295                LiteralValue::Int(9),
296            ]])),
297            None,
298        );
299        let extra = ASTNode::new(ASTNodeType::Literal(LiteralValue::Int(1)), None);
300        let f = ctx.context.get_function("", "MIN").unwrap();
301        let out = f
302            .dispatch(
303                &[
304                    ArgumentHandle::new(&arr, &ctx),
305                    ArgumentHandle::new(&extra, &ctx),
306                ],
307                &ctx.function_context(None),
308            )
309            .unwrap()
310            .into_literal();
311        assert_eq!(out, LiteralValue::Number(1.0));
312    }
313
314    #[test]
315    fn max_basic_with_text_ignored() {
316        let wb = TestWorkbook::new().with_function(std::sync::Arc::new(MaxFn));
317        let ctx = interp(&wb);
318        let arr = ASTNode::new(
319            ASTNodeType::Literal(LiteralValue::Array(vec![vec![
320                LiteralValue::Int(5),
321                LiteralValue::Text("x".into()),
322                LiteralValue::Int(9),
323            ]])),
324            None,
325        );
326        let f = ctx.context.get_function("", "MAX").unwrap();
327        let out = f
328            .dispatch(
329                &[ArgumentHandle::new(&arr, &ctx)],
330                &ctx.function_context(None),
331            )
332            .unwrap()
333            .into_literal();
334        assert_eq!(out, LiteralValue::Number(9.0));
335    }
336
337    #[test]
338    fn min_error_propagates() {
339        let wb = TestWorkbook::new().with_function(std::sync::Arc::new(MinFn));
340        let ctx = interp(&wb);
341        let err = ASTNode::new(
342            ASTNodeType::Literal(LiteralValue::Error(ExcelError::new_na())),
343            None,
344        );
345        let one = ASTNode::new(ASTNodeType::Literal(LiteralValue::Int(1)), None);
346        let f = ctx.context.get_function("", "MIN").unwrap();
347        let out = f
348            .dispatch(
349                &[
350                    ArgumentHandle::new(&err, &ctx),
351                    ArgumentHandle::new(&one, &ctx),
352                ],
353                &ctx.function_context(None),
354            )
355            .unwrap()
356            .into_literal();
357        match out {
358            LiteralValue::Error(e) => assert_eq!(e, "#N/A"),
359            v => panic!("expected error got {v:?}"),
360        }
361    }
362
363    #[test]
364    fn max_error_propagates() {
365        let wb = TestWorkbook::new().with_function(std::sync::Arc::new(MaxFn));
366        let ctx = interp(&wb);
367        let err = ASTNode::new(
368            ASTNodeType::Literal(LiteralValue::Error(ExcelError::from_error_string(
369                "#DIV/0!",
370            ))),
371            None,
372        );
373        let one = ASTNode::new(ASTNodeType::Literal(LiteralValue::Int(1)), None);
374        let f = ctx.context.get_function("", "MAX").unwrap();
375        let out = f
376            .dispatch(
377                &[
378                    ArgumentHandle::new(&one, &ctx),
379                    ArgumentHandle::new(&err, &ctx),
380                ],
381                &ctx.function_context(None),
382            )
383            .unwrap()
384            .into_literal();
385        match out {
386            LiteralValue::Error(e) => assert_eq!(e, "#DIV/0!"),
387            v => panic!("expected error got {v:?}"),
388        }
389    }
390}