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