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