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}