Skip to main content

qql_core/ast/
formula.rs

1use super::{FilterExpr, Value};
2use alloc::boxed::Box;
3use alloc::string::String;
4use alloc::vec::Vec;
5
6/// Formula expression tree evaluated by `QUERY FORMULA` to rescore points.
7#[derive(Debug, Clone, PartialEq)]
8#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
9pub enum FormulaExpr {
10    /// Numeric literal.
11    Constant {
12        /// Literal numeric value.
13        value: f64,
14    },
15    /// Variable reference: `$score`, a `DEFAULTS`-bound name, or a datetime key.
16    Variable {
17        /// Variable name, e.g. `$score` or a `DEFAULTS` key.
18        name: String,
19    },
20    /// `left + right`.
21    Sum {
22        /// Left operand.
23        left: Box<FormulaExpr>,
24        /// Right operand.
25        right: Box<FormulaExpr>,
26    },
27    /// `left - right`.
28    Sub {
29        /// Left operand.
30        left: Box<FormulaExpr>,
31        /// Right operand.
32        right: Box<FormulaExpr>,
33    },
34    /// `left * right`.
35    Mul {
36        /// Left operand.
37        left: Box<FormulaExpr>,
38        /// Right operand.
39        right: Box<FormulaExpr>,
40    },
41    /// `left / right`, with optional `[DEFAULT n]` zero-division fallback.
42    Div {
43        /// Dividend.
44        left: Box<FormulaExpr>,
45        /// Divisor.
46        right: Box<FormulaExpr>,
47        /// Value substituted when the divisor is zero.
48        by_zero_default: Option<f64>,
49    },
50    /// Unary negation.
51    Neg {
52        /// Expression to negate.
53        operand: Box<FormulaExpr>,
54    },
55    /// `ABS(x)`.
56    Abs {
57        /// Argument.
58        x: Box<FormulaExpr>,
59    },
60    /// `SQRT(x)` with optional `[DEFAULT n]` negative fallback.
61    Sqrt {
62        /// Argument.
63        x: Box<FormulaExpr>,
64        /// Value substituted or clamped when x < 0.
65        domain_default: Option<f64>,
66    },
67    /// `LOG(x)` — base-10 logarithm with optional `[DEFAULT n]` non-positive fallback.
68    Log {
69        /// Argument.
70        x: Box<FormulaExpr>,
71        /// Value substituted or clamped when x <= 0.
72        domain_default: Option<f64>,
73    },
74    /// `LN(x)` — natural logarithm with optional `[DEFAULT n]` non-positive fallback.
75    Ln {
76        /// Argument.
77        x: Box<FormulaExpr>,
78        /// Value substituted or clamped when x <= 0.
79        domain_default: Option<f64>,
80    },
81    /// `EXP(x)` — e raised to `x`.
82    Exp {
83        /// Argument.
84        x: Box<FormulaExpr>,
85    },
86    /// `ACOSH(x)` — inverse hyperbolic cosine with optional `[DEFAULT n]` fallback.
87    Acosh {
88        /// Argument.
89        x: Box<FormulaExpr>,
90        /// Value substituted or clamped when x < 1.
91        domain_default: Option<f64>,
92    },
93    /// `POW(base, exponent)`.
94    Pow {
95        /// Base expression.
96        base: Box<FormulaExpr>,
97        /// Exponent expression.
98        exponent: Box<FormulaExpr>,
99    },
100    /// N-ary `MAX(...)`. At least one operand (parser-enforced).
101    Max {
102        /// Folded operands (n >= 1).
103        args: Vec<FormulaExpr>,
104    },
105    /// N-ary `MIN(...)`. At least one operand (parser-enforced).
106    Min {
107        /// Folded operands (n >= 1).
108        args: Vec<FormulaExpr>,
109    },
110    /// `GEO_DISTANCE(lat, lon, field)` — meters between the coordinate and a geo field.
111    GeoDistance {
112        /// Query latitude in degrees.
113        lat: f64,
114        /// Query longitude in degrees.
115        lon: f64,
116        /// Payload geo field to measure against.
117        field: String,
118    },
119    /// `EXP_DECAY` / `GAUSS_DECAY` / `LIN_DECAY` decay curve over `x`.
120    Decay {
121        /// Curve family: `exp_decay`, `gauss_decay`, or `lin_decay`.
122        kind: String,
123        /// Decaying expression, e.g. a datetime key or `$score`.
124        x: Box<FormulaExpr>,
125        /// Decay origin; `None` defaults to zero.
126        target: Option<Box<FormulaExpr>>,
127        /// Distance from `target` at which the output falls to `midpoint`.
128        scale: Option<f64>,
129        /// Output value at that distance from `target`.
130        midpoint: Option<f64>,
131    },
132    /// `CASE WHEN cond THEN then_ ELSE else_ END`.
133    Case {
134        /// Boolean condition, evaluated as a filter.
135        cond: Box<FilterExpr>,
136        /// Value produced when the condition holds.
137        then_: Box<FormulaExpr>,
138        /// Value produced otherwise.
139        else_: Box<FormulaExpr>,
140    },
141    /// Inline `MATCH(field, values)` boolean used as a 0/1 condition.
142    MatchCondition {
143        /// Payload field to test.
144        field: String,
145        /// Accepted values (any-of).
146        values: Vec<Value>,
147    },
148    /// `DATETIME('…')` — ISO 8601 datetime constant.
149    Datetime {
150        /// ISO 8601 datetime string.
151        value: String,
152    },
153    /// `DATETIME_KEY('field')` — payload datetime field read as a datetime.
154    DatetimeKey {
155        /// Payload datetime field name.
156        key: String,
157    },
158}
159
160/// Returns `true` if `s` is a valid ISO 8601 date or datetime string (`YYYY-MM-DD` or `YYYY-MM-DD[T| ]hh:mm:ss[.s][Z|±hh[:mm]]`).
161/// Strictly rejects trailing non-datetime characters (e.g. "2024-01-01XYZ").
162pub fn looks_like_iso_datetime(s: &str) -> bool {
163    let bytes = s.as_bytes();
164    if bytes.len() < 10 {
165        return false;
166    }
167    // Check YYYY-MM-DD
168    if !bytes[0..4].iter().all(u8::is_ascii_digit)
169        || bytes[4] != b'-'
170        || !bytes[5..7].iter().all(u8::is_ascii_digit)
171        || bytes[7] != b'-'
172        || !bytes[8..10].iter().all(u8::is_ascii_digit)
173    {
174        return false;
175    }
176    if bytes.len() == 10 {
177        return true;
178    }
179    // If longer, must be separated by 'T', 't', or ' '
180    let sep = bytes[10];
181    if sep != b'T' && sep != b't' && sep != b' ' {
182        return false;
183    }
184    // Must have at least hh:mm:ss (8 chars) -> 10 + 1 + 8 = 19
185    if bytes.len() < 19 {
186        return false;
187    }
188    if !bytes[11..13].iter().all(u8::is_ascii_digit)
189        || bytes[13] != b':'
190        || !bytes[14..16].iter().all(u8::is_ascii_digit)
191        || bytes[16] != b':'
192        || !bytes[17..19].iter().all(u8::is_ascii_digit)
193    {
194        return false;
195    }
196    let mut i = 19;
197    // Optional fractional seconds: .123...
198    if i < bytes.len() && bytes[i] == b'.' {
199        i += 1;
200        let frac_start = i;
201        while i < bytes.len() && bytes[i].is_ascii_digit() {
202            i += 1;
203        }
204        if i == frac_start {
205            return false; // '.' with no digits following
206        }
207    }
208    if i == bytes.len() {
209        return true;
210    }
211    // Optional timezone: 'Z', 'z', or '+hh[:mm]' / '-hh[:mm]'
212    if bytes[i] == b'Z' || bytes[i] == b'z' {
213        return i + 1 == bytes.len();
214    }
215    if bytes[i] == b'+' || bytes[i] == b'-' {
216        i += 1;
217        let tz_start = i;
218        while i < bytes.len() && (bytes[i].is_ascii_digit() || bytes[i] == b':') {
219            i += 1;
220        }
221        let tz_len = i - tz_start;
222        // Valid timezone specs: hh (2), hhmm (4), hh:mm (5)
223        if (tz_len == 2 || tz_len == 4 || tz_len == 5) && i == bytes.len() {
224            return true;
225        }
226        return false;
227    }
228    false
229}