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)
90            .expect("typed numeric projection helpers should always produce numeric literals")
91    }
92
93    // Build one field-plus-literal numeric projection.
94    #[cfg(feature = "sql")]
95    pub(in crate::db) fn add_value(
96        field: impl Into<String>,
97        literal: Value,
98    ) -> Result<Self, QueryError> {
99        Self::arithmetic_value(field, BinaryOp::Add, literal)
100    }
101
102    // Build one field-minus-literal numeric projection.
103    #[cfg(feature = "sql")]
104    pub(in crate::db) fn sub_value(
105        field: impl Into<String>,
106        literal: Value,
107    ) -> Result<Self, QueryError> {
108        Self::arithmetic_value(field, BinaryOp::Sub, literal)
109    }
110
111    // Build one field-times-literal numeric projection.
112    #[cfg(feature = "sql")]
113    pub(in crate::db) fn mul_value(
114        field: impl Into<String>,
115        literal: Value,
116    ) -> Result<Self, QueryError> {
117        Self::arithmetic_value(field, BinaryOp::Mul, literal)
118    }
119
120    // Build one field-divided-by-literal numeric projection.
121    #[cfg(feature = "sql")]
122    pub(in crate::db) fn div_value(
123        field: impl Into<String>,
124        literal: Value,
125    ) -> Result<Self, QueryError> {
126        Self::arithmetic_value(field, BinaryOp::Div, literal)
127    }
128
129    // Build one field-plus-literal numeric projection from one typed numeric
130    // literal helper.
131    pub(in crate::db) fn add_numeric_literal(
132        field: impl Into<String>,
133        literal: impl Into<InputValue> + NumericValue,
134    ) -> Self {
135        Self::arithmetic_numeric_literal(field, BinaryOp::Add, literal)
136    }
137
138    // Build one field-minus-literal numeric projection from one typed numeric
139    // literal helper.
140    pub(in crate::db) fn sub_numeric_literal(
141        field: impl Into<String>,
142        literal: impl Into<InputValue> + NumericValue,
143    ) -> Self {
144        Self::arithmetic_numeric_literal(field, BinaryOp::Sub, literal)
145    }
146
147    // Build one field-times-literal numeric projection from one typed numeric
148    // literal helper.
149    pub(in crate::db) fn mul_numeric_literal(
150        field: impl Into<String>,
151        literal: impl Into<InputValue> + NumericValue,
152    ) -> Self {
153        Self::arithmetic_numeric_literal(field, BinaryOp::Mul, literal)
154    }
155
156    // Build one field-divided-by-literal numeric projection from one typed
157    // numeric literal helper.
158    pub(in crate::db) fn div_numeric_literal(
159        field: impl Into<String>,
160        literal: impl Into<InputValue> + NumericValue,
161    ) -> Self {
162        Self::arithmetic_numeric_literal(field, BinaryOp::Div, literal)
163    }
164
165    /// Borrow the canonical planner expression carried by this helper.
166    #[must_use]
167    pub(in crate::db) const fn expr(&self) -> &Expr {
168        &self.expr
169    }
170
171    // Build one rounded projection over either a plain field or one existing
172    // bounded numeric expression rooted in the same source field.
173    pub(in crate::db) fn round_with_scale(
174        &self,
175        scale: u32,
176    ) -> Result<RoundProjectionExpr, QueryError> {
177        RoundProjectionExpr::new(
178            self.field.clone(),
179            self.expr.clone(),
180            Value::Nat64(u64::from(scale)),
181        )
182    }
183}
184
185impl ValueProjectionExpr for NumericProjectionExpr {
186    fn field(&self) -> &str {
187        self.field.as_str()
188    }
189
190    fn projection_plan(&self) -> ScalarProjectionPlan {
191        ScalarProjectionPlan::new(self.expr.clone())
192    }
193
194    fn projection_label(&self) -> String {
195        render_scalar_projection_expr_plan_label(&self.expr)
196    }
197
198    fn apply_value(&self, value: Value) -> Result<Value, QueryError> {
199        eval_builder_expr_for_value_preview(&self.expr, self.field.as_str(), &value)
200    }
201}
202
203///
204/// RoundProjectionExpr
205///
206/// Shared bounded numeric rounding projection over one source field and one
207/// canonical scalar numeric expression.
208/// This keeps `ROUND` on the scalar projection seam without opening a generic
209/// function-builder surface.
210///
211
212#[derive(Clone, Debug, Eq, PartialEq)]
213pub struct RoundProjectionExpr {
214    field: String,
215    expr: Expr,
216}
217
218impl RoundProjectionExpr {
219    // Build one bounded `ROUND(expr, scale)` projection after validating that
220    // `scale` stays on the admitted non-negative integer seam.
221    pub(in crate::db) fn new(
222        field: impl Into<String>,
223        inner: Expr,
224        scale: Value,
225    ) -> Result<Self, QueryError> {
226        match scale {
227            Value::Int64(value) if value < 0 => {
228                return Err(QueryError::unsupported_projection(
229                    QueryProjectionCode::NumericScaleArguments,
230                ));
231            }
232            Value::Int64(_) | Value::Nat64(_) => {}
233            _ => {
234                return Err(QueryError::unsupported_projection(
235                    QueryProjectionCode::NumericScaleArguments,
236                ));
237            }
238        }
239
240        Ok(Self {
241            field: field.into(),
242            expr: Expr::FunctionCall {
243                function: Function::Round,
244                args: vec![inner, Expr::Literal(scale)],
245            },
246        })
247    }
248
249    // Build one rounded field projection.
250    pub(in crate::db) fn field(field: impl Into<String>, scale: u32) -> Result<Self, QueryError> {
251        let field = field.into();
252
253        Self::new(
254            field.clone(),
255            Expr::Field(FieldId::new(field)),
256            Value::Nat64(u64::from(scale)),
257        )
258    }
259
260    /// Borrow the canonical planner expression carried by this helper.
261    #[must_use]
262    pub(in crate::db) const fn expr(&self) -> &Expr {
263        &self.expr
264    }
265}
266
267impl ValueProjectionExpr for RoundProjectionExpr {
268    fn field(&self) -> &str {
269        self.field.as_str()
270    }
271
272    fn projection_plan(&self) -> ScalarProjectionPlan {
273        ScalarProjectionPlan::new(self.expr.clone())
274    }
275
276    fn projection_label(&self) -> String {
277        render_scalar_projection_expr_plan_label(&self.expr)
278    }
279
280    fn apply_value(&self, value: Value) -> Result<Value, QueryError> {
281        eval_builder_expr_for_value_preview(&self.expr, self.field.as_str(), &value)
282    }
283}
284
285/// Build `field + literal`.
286#[must_use]
287pub fn add(
288    field: impl AsRef<str>,
289    literal: impl Into<InputValue> + NumericValue,
290) -> NumericProjectionExpr {
291    NumericProjectionExpr::add_numeric_literal(field.as_ref().to_string(), literal)
292}
293
294/// Build `field - literal`.
295#[must_use]
296pub fn sub(
297    field: impl AsRef<str>,
298    literal: impl Into<InputValue> + NumericValue,
299) -> NumericProjectionExpr {
300    NumericProjectionExpr::sub_numeric_literal(field.as_ref().to_string(), literal)
301}
302
303/// Build `field * literal`.
304#[must_use]
305pub fn mul(
306    field: impl AsRef<str>,
307    literal: impl Into<InputValue> + NumericValue,
308) -> NumericProjectionExpr {
309    NumericProjectionExpr::mul_numeric_literal(field.as_ref().to_string(), literal)
310}
311
312/// Build `field / literal`.
313#[must_use]
314pub fn div(
315    field: impl AsRef<str>,
316    literal: impl Into<InputValue> + NumericValue,
317) -> NumericProjectionExpr {
318    NumericProjectionExpr::div_numeric_literal(field.as_ref().to_string(), literal)
319}
320
321/// Build `ROUND(field, scale)`.
322pub fn round(field: impl AsRef<str>, scale: u32) -> RoundProjectionExpr {
323    RoundProjectionExpr::field(field.as_ref().to_string(), scale)
324        .expect("ROUND(field, scale) helper should always produce a bounded projection")
325}
326
327/// Build `ROUND(expr, scale)` for one existing bounded numeric projection.
328#[must_use]
329pub fn round_expr(projection: &NumericProjectionExpr, scale: u32) -> RoundProjectionExpr {
330    projection
331        .round_with_scale(scale)
332        .expect("ROUND(expr, scale) helper should always produce a bounded projection")
333}
334
335#[cfg(test)]
336mod tests {
337    use super::{NumericProjectionExpr, RoundProjectionExpr};
338    use crate::{
339        db::{
340            QueryError,
341            query::plan::expr::{BinaryOp, Expr, FieldId},
342        },
343        value::Value,
344    };
345    use icydb_diagnostic_code::{DiagnosticCode, DiagnosticDetail, QueryProjectionCode};
346
347    fn assert_query_projection_error(err: QueryError, reason: QueryProjectionCode) {
348        let diagnostic = err.diagnostic();
349
350        assert_eq!(
351            diagnostic.code(),
352            DiagnosticCode::QueryUnsupportedProjection
353        );
354        assert_eq!(
355            diagnostic.detail(),
356            Some(&DiagnosticDetail::QueryProjection { reason }),
357        );
358    }
359
360    #[test]
361    fn numeric_projection_rejects_non_numeric_literal_with_compact_projection_code() {
362        let err = NumericProjectionExpr::arithmetic_value("age", BinaryOp::Add, Value::Bool(true))
363            .expect_err("non-numeric projection literal should fail closed");
364
365        assert_query_projection_error(err, QueryProjectionCode::NumericLiteralRequired);
366    }
367
368    #[test]
369    fn round_projection_rejects_negative_scale_with_compact_projection_code() {
370        let err =
371            RoundProjectionExpr::new("age", Expr::Field(FieldId::new("age")), Value::Int64(-1))
372                .expect_err("negative ROUND scale should fail closed");
373
374        assert_query_projection_error(err, QueryProjectionCode::NumericScaleArguments);
375    }
376
377    #[test]
378    fn round_projection_rejects_non_integer_scale_with_compact_projection_code() {
379        let err = RoundProjectionExpr::new(
380            "age",
381            Expr::Field(FieldId::new("age")),
382            Value::Text("invalid".to_string()),
383        )
384        .expect_err("non-integer ROUND scale should fail closed");
385
386        assert_query_projection_error(err, QueryProjectionCode::NumericScaleArguments);
387    }
388}