Skip to main content

icydb_core/db/query/builder/
numeric_projection.rs

1//! Module: query::builder::numeric_projection
2//! Responsibility: shared bounded numeric projection helpers used by fluent
3//! terminals and structural lowering.
4//! Does not own: generic arithmetic expression parsing, grouped semantics, or
5//! executor routing.
6//! Boundary: this models the admitted scalar arithmetic surface without
7//! opening a general expression-builder API.
8
9use crate::{
10    db::{
11        QueryError,
12        query::{
13            builder::{
14                ScalarProjectionPlan, ValueProjectionExpr,
15                scalar_projection::render_scalar_projection_expr_plan_label,
16            },
17            plan::expr::{BinaryOp, Expr, FieldId, Function, eval_builder_expr_for_value_preview},
18        },
19    },
20    traits::NumericValue,
21    value::{InputValue, Value},
22};
23use icydb_diagnostic_code::QueryProjectionCode;
24
25///
26/// NumericProjectionExpr
27///
28/// Shared bounded numeric projection over one source field and one numeric
29/// literal.
30/// This stays on the narrow `field op literal` seam admitted by the shipped
31/// scalar projection surfaces.
32///
33
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct NumericProjectionExpr {
36    field: String,
37    expr: Expr,
38}
39
40impl NumericProjectionExpr {
41    // Build one bounded field-op-literal numeric projection after validating
42    // that the literal stays on the admitted numeric seam.
43    fn arithmetic_value(
44        field: impl Into<String>,
45        op: BinaryOp,
46        literal: Value,
47    ) -> Result<Self, QueryError> {
48        if !matches!(
49            literal,
50            Value::Int64(_)
51                | Value::Int128(_)
52                | Value::IntBig(_)
53                | Value::Nat64(_)
54                | Value::Nat128(_)
55                | Value::NatBig(_)
56                | Value::Decimal(_)
57                | Value::Float32(_)
58                | Value::Float64(_)
59                | Value::Duration(_)
60                | Value::Timestamp(_)
61                | Value::Date(_)
62        ) {
63            return Err(QueryError::unsupported_projection(
64                QueryProjectionCode::NumericLiteralRequired,
65            ));
66        }
67
68        let field = field.into();
69
70        Ok(Self {
71            expr: Expr::Binary {
72                op,
73                left: Box::new(Expr::Field(FieldId::new(field.clone()))),
74                right: Box::new(Expr::Literal(literal)),
75            },
76            field,
77        })
78    }
79
80    // Build one bounded field-op-literal numeric projection from one typed
81    // numeric literal helper.
82    fn arithmetic_numeric_literal(
83        field: impl Into<String>,
84        op: BinaryOp,
85        literal: impl Into<InputValue> + NumericValue,
86    ) -> Self {
87        let literal = Value::from(literal.into());
88
89        Self::arithmetic_value(field, op, literal).expect("numeric projection invariant")
90    }
91
92    // Build one field-plus-literal numeric projection.
93    #[cfg(feature = "sql")]
94    pub(in crate::db) fn add_value(
95        field: impl Into<String>,
96        literal: Value,
97    ) -> Result<Self, QueryError> {
98        Self::arithmetic_value(field, BinaryOp::Add, literal)
99    }
100
101    // Build one field-minus-literal numeric projection.
102    #[cfg(feature = "sql")]
103    pub(in crate::db) fn sub_value(
104        field: impl Into<String>,
105        literal: Value,
106    ) -> Result<Self, QueryError> {
107        Self::arithmetic_value(field, BinaryOp::Sub, literal)
108    }
109
110    // Build one field-times-literal numeric projection.
111    #[cfg(feature = "sql")]
112    pub(in crate::db) fn mul_value(
113        field: impl Into<String>,
114        literal: Value,
115    ) -> Result<Self, QueryError> {
116        Self::arithmetic_value(field, BinaryOp::Mul, literal)
117    }
118
119    // Build one field-divided-by-literal numeric projection.
120    #[cfg(feature = "sql")]
121    pub(in crate::db) fn div_value(
122        field: impl Into<String>,
123        literal: Value,
124    ) -> Result<Self, QueryError> {
125        Self::arithmetic_value(field, BinaryOp::Div, literal)
126    }
127
128    // Build one field-plus-literal numeric projection from one typed numeric
129    // literal helper.
130    pub(in crate::db) fn add_numeric_literal(
131        field: impl Into<String>,
132        literal: impl Into<InputValue> + NumericValue,
133    ) -> Self {
134        Self::arithmetic_numeric_literal(field, BinaryOp::Add, literal)
135    }
136
137    // Build one field-minus-literal numeric projection from one typed numeric
138    // literal helper.
139    pub(in crate::db) fn sub_numeric_literal(
140        field: impl Into<String>,
141        literal: impl Into<InputValue> + NumericValue,
142    ) -> Self {
143        Self::arithmetic_numeric_literal(field, BinaryOp::Sub, literal)
144    }
145
146    // Build one field-times-literal numeric projection from one typed numeric
147    // literal helper.
148    pub(in crate::db) fn mul_numeric_literal(
149        field: impl Into<String>,
150        literal: impl Into<InputValue> + NumericValue,
151    ) -> Self {
152        Self::arithmetic_numeric_literal(field, BinaryOp::Mul, literal)
153    }
154
155    // Build one field-divided-by-literal numeric projection from one typed
156    // numeric literal helper.
157    pub(in crate::db) fn div_numeric_literal(
158        field: impl Into<String>,
159        literal: impl Into<InputValue> + NumericValue,
160    ) -> Self {
161        Self::arithmetic_numeric_literal(field, BinaryOp::Div, literal)
162    }
163
164    /// Borrow the canonical planner expression carried by this helper.
165    #[must_use]
166    pub(in crate::db) const fn expr(&self) -> &Expr {
167        &self.expr
168    }
169
170    // Build one rounded projection over either a plain field or one existing
171    // bounded numeric expression rooted in the same source field.
172    pub(in crate::db) fn round_with_scale(
173        &self,
174        scale: u32,
175    ) -> Result<RoundProjectionExpr, QueryError> {
176        RoundProjectionExpr::new(
177            self.field.clone(),
178            self.expr.clone(),
179            Value::Nat64(u64::from(scale)),
180        )
181    }
182}
183
184impl ValueProjectionExpr for NumericProjectionExpr {
185    fn field(&self) -> &str {
186        self.field.as_str()
187    }
188
189    fn projection_plan(&self) -> ScalarProjectionPlan {
190        ScalarProjectionPlan::new(self.expr.clone())
191    }
192
193    fn projection_label(&self) -> String {
194        render_scalar_projection_expr_plan_label(&self.expr)
195    }
196
197    fn apply_value(&self, value: Value) -> Result<Value, QueryError> {
198        eval_builder_expr_for_value_preview(&self.expr, self.field.as_str(), &value)
199    }
200}
201
202///
203/// RoundProjectionExpr
204///
205/// Shared bounded numeric rounding projection over one source field and one
206/// canonical scalar numeric expression.
207/// This keeps `ROUND` on the scalar projection seam without opening a generic
208/// function-builder surface.
209///
210
211#[derive(Clone, Debug, Eq, PartialEq)]
212pub struct RoundProjectionExpr {
213    field: String,
214    expr: Expr,
215}
216
217impl RoundProjectionExpr {
218    // Build one bounded `ROUND(expr, scale)` projection after validating that
219    // `scale` stays on the admitted non-negative integer seam.
220    pub(in crate::db) fn new(
221        field: impl Into<String>,
222        inner: Expr,
223        scale: Value,
224    ) -> Result<Self, QueryError> {
225        match scale {
226            Value::Int64(value) if value < 0 => {
227                return Err(QueryError::unsupported_projection(
228                    QueryProjectionCode::NumericScaleArguments,
229                ));
230            }
231            Value::Int64(_) | Value::Nat64(_) => {}
232            _ => {
233                return Err(QueryError::unsupported_projection(
234                    QueryProjectionCode::NumericScaleArguments,
235                ));
236            }
237        }
238
239        Ok(Self {
240            field: field.into(),
241            expr: Expr::FunctionCall {
242                function: Function::Round,
243                args: vec![inner, Expr::Literal(scale)],
244            },
245        })
246    }
247
248    // Build one rounded field projection.
249    pub(in crate::db) fn field(field: impl Into<String>, scale: u32) -> Result<Self, QueryError> {
250        let field = field.into();
251
252        Self::new(
253            field.clone(),
254            Expr::Field(FieldId::new(field)),
255            Value::Nat64(u64::from(scale)),
256        )
257    }
258
259    /// Borrow the canonical planner expression carried by this helper.
260    #[must_use]
261    pub(in crate::db) const fn expr(&self) -> &Expr {
262        &self.expr
263    }
264}
265
266impl ValueProjectionExpr for RoundProjectionExpr {
267    fn field(&self) -> &str {
268        self.field.as_str()
269    }
270
271    fn projection_plan(&self) -> ScalarProjectionPlan {
272        ScalarProjectionPlan::new(self.expr.clone())
273    }
274
275    fn projection_label(&self) -> String {
276        render_scalar_projection_expr_plan_label(&self.expr)
277    }
278
279    fn apply_value(&self, value: Value) -> Result<Value, QueryError> {
280        eval_builder_expr_for_value_preview(&self.expr, self.field.as_str(), &value)
281    }
282}
283
284/// Build `field + literal`.
285#[must_use]
286pub fn add(
287    field: impl AsRef<str>,
288    literal: impl Into<InputValue> + NumericValue,
289) -> NumericProjectionExpr {
290    NumericProjectionExpr::add_numeric_literal(field.as_ref().to_string(), literal)
291}
292
293/// Build `field - literal`.
294#[must_use]
295pub fn sub(
296    field: impl AsRef<str>,
297    literal: impl Into<InputValue> + NumericValue,
298) -> NumericProjectionExpr {
299    NumericProjectionExpr::sub_numeric_literal(field.as_ref().to_string(), literal)
300}
301
302/// Build `field * literal`.
303#[must_use]
304pub fn mul(
305    field: impl AsRef<str>,
306    literal: impl Into<InputValue> + NumericValue,
307) -> NumericProjectionExpr {
308    NumericProjectionExpr::mul_numeric_literal(field.as_ref().to_string(), literal)
309}
310
311/// Build `field / literal`.
312#[must_use]
313pub fn div(
314    field: impl AsRef<str>,
315    literal: impl Into<InputValue> + NumericValue,
316) -> NumericProjectionExpr {
317    NumericProjectionExpr::div_numeric_literal(field.as_ref().to_string(), literal)
318}
319
320/// Build `ROUND(field, scale)`.
321pub fn round(field: impl AsRef<str>, scale: u32) -> RoundProjectionExpr {
322    RoundProjectionExpr::field(field.as_ref().to_string(), scale)
323        .expect("numeric projection invariant")
324}
325
326/// Build `ROUND(expr, scale)` for one existing bounded numeric projection.
327#[must_use]
328pub fn round_expr(projection: &NumericProjectionExpr, scale: u32) -> RoundProjectionExpr {
329    projection
330        .round_with_scale(scale)
331        .expect("numeric projection invariant")
332}
333
334#[cfg(test)]
335mod tests {
336    use super::{NumericProjectionExpr, RoundProjectionExpr};
337    use crate::{
338        db::{
339            QueryError,
340            query::plan::expr::{BinaryOp, Expr, FieldId},
341        },
342        value::Value,
343    };
344    use icydb_diagnostic_code::{DiagnosticCode, DiagnosticDetail, QueryProjectionCode};
345
346    fn assert_query_projection_error(err: QueryError, reason: QueryProjectionCode) {
347        let diagnostic = err.diagnostic();
348
349        assert_eq!(
350            diagnostic.code(),
351            DiagnosticCode::QueryUnsupportedProjection
352        );
353        assert_eq!(
354            diagnostic.detail(),
355            Some(&DiagnosticDetail::QueryProjection { reason }),
356        );
357    }
358
359    #[test]
360    fn numeric_projection_rejects_non_numeric_literal_with_compact_projection_code() {
361        let err = NumericProjectionExpr::arithmetic_value("age", BinaryOp::Add, Value::Bool(true))
362            .expect_err("non-numeric projection literal should fail closed");
363
364        assert_query_projection_error(err, QueryProjectionCode::NumericLiteralRequired);
365    }
366
367    #[test]
368    fn round_projection_rejects_negative_scale_with_compact_projection_code() {
369        let err =
370            RoundProjectionExpr::new("age", Expr::Field(FieldId::new("age")), Value::Int64(-1))
371                .expect_err("negative ROUND scale should fail closed");
372
373        assert_query_projection_error(err, QueryProjectionCode::NumericScaleArguments);
374    }
375
376    #[test]
377    fn round_projection_rejects_non_integer_scale_with_compact_projection_code() {
378        let err = RoundProjectionExpr::new(
379            "age",
380            Expr::Field(FieldId::new("age")),
381            Value::Text("invalid".to_string()),
382        )
383        .expect_err("non-integer ROUND scale should fail closed");
384
385        assert_query_projection_error(err, QueryProjectionCode::NumericScaleArguments);
386    }
387}