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};
23
24///
25/// NumericProjectionExpr
26///
27/// Shared bounded numeric projection over one source field and one numeric
28/// literal.
29/// This stays on the narrow `field op literal` seam admitted by the shipped
30/// scalar projection surfaces.
31///
32
33#[derive(Clone, Debug, Eq, PartialEq)]
34pub struct NumericProjectionExpr {
35    field: String,
36    expr: Expr,
37}
38
39impl NumericProjectionExpr {
40    // Build one bounded field-op-literal numeric projection from one typed
41    // numeric literal helper.
42    fn arithmetic_numeric_literal(
43        field: impl Into<String>,
44        op: BinaryOp,
45        literal: impl Into<Value> + NumericValue,
46    ) -> Self {
47        let literal = literal.into();
48        let field = field.into();
49
50        Self {
51            expr: Expr::Binary {
52                op,
53                left: Box::new(Expr::Field(FieldId::new(field.clone()))),
54                right: Box::new(Expr::Literal(literal)),
55            },
56            field,
57        }
58    }
59
60    // Build one field-plus-literal numeric projection from one typed numeric
61    // literal helper.
62    pub(in crate::db) fn add_numeric_literal(
63        field: impl Into<String>,
64        literal: impl Into<Value> + NumericValue,
65    ) -> Self {
66        Self::arithmetic_numeric_literal(field, BinaryOp::Add, literal)
67    }
68
69    // Build one field-minus-literal numeric projection from one typed numeric
70    // literal helper.
71    pub(in crate::db) fn sub_numeric_literal(
72        field: impl Into<String>,
73        literal: impl Into<Value> + NumericValue,
74    ) -> Self {
75        Self::arithmetic_numeric_literal(field, BinaryOp::Sub, literal)
76    }
77
78    // Build one field-times-literal numeric projection from one typed numeric
79    // literal helper.
80    pub(in crate::db) fn mul_numeric_literal(
81        field: impl Into<String>,
82        literal: impl Into<Value> + NumericValue,
83    ) -> Self {
84        Self::arithmetic_numeric_literal(field, BinaryOp::Mul, literal)
85    }
86
87    // Build one field-divided-by-literal numeric projection from one typed
88    // numeric literal helper.
89    pub(in crate::db) fn div_numeric_literal(
90        field: impl Into<String>,
91        literal: impl Into<Value> + NumericValue,
92    ) -> Self {
93        Self::arithmetic_numeric_literal(field, BinaryOp::Div, literal)
94    }
95
96    /// Borrow the canonical planner expression carried by this helper.
97    #[must_use]
98    pub(in crate::db) const fn expr(&self) -> &Expr {
99        &self.expr
100    }
101
102    // Build one rounded projection over either a plain field or one existing
103    // bounded numeric expression rooted in the same source field.
104    pub(in crate::db) fn round_with_scale(&self, scale: u32) -> RoundProjectionExpr {
105        RoundProjectionExpr::with_valid_scale(self.field.clone(), self.expr.clone(), scale)
106    }
107}
108
109impl super::scalar_projection::private::Sealed for NumericProjectionExpr {}
110
111impl ValueProjectionExpr for NumericProjectionExpr {
112    fn field(&self) -> &str {
113        self.field.as_str()
114    }
115
116    fn projection_plan(&self) -> ScalarProjectionPlan {
117        ScalarProjectionPlan::new(self.expr.clone())
118    }
119
120    fn projection_label(&self) -> String {
121        render_scalar_projection_expr_plan_label(&self.expr)
122    }
123
124    fn apply_value(&self, value: Value) -> Result<Value, QueryError> {
125        eval_builder_expr_for_value_preview(&self.expr, self.field.as_str(), &value)
126    }
127}
128
129///
130/// RoundProjectionExpr
131///
132/// Shared bounded numeric rounding projection over one source field and one
133/// canonical scalar numeric expression.
134/// This keeps `ROUND` on the scalar projection seam without opening a generic
135/// function-builder surface.
136///
137
138#[derive(Clone, Debug, Eq, PartialEq)]
139pub struct RoundProjectionExpr {
140    field: String,
141    expr: Expr,
142}
143
144impl RoundProjectionExpr {
145    // Build one bounded `ROUND(expr, scale)` projection after validating that
146    // `scale` stays on the admitted non-negative integer seam.
147    #[cfg(test)]
148    pub(in crate::db) fn new(
149        field: impl Into<String>,
150        inner: Expr,
151        scale: Value,
152    ) -> Result<Self, QueryError> {
153        match scale {
154            Value::Int64(value) if value < 0 => {
155                return Err(QueryError::unsupported_projection(
156                    icydb_diagnostic_code::QueryProjectionCode::NumericScaleArguments,
157                ));
158            }
159            Value::Int64(_) | Value::Nat64(_) => {}
160            _ => {
161                return Err(QueryError::unsupported_projection(
162                    icydb_diagnostic_code::QueryProjectionCode::NumericScaleArguments,
163                ));
164            }
165        }
166
167        Ok(Self {
168            field: field.into(),
169            expr: Expr::FunctionCall {
170                function: Function::Round,
171                args: vec![inner, Expr::Literal(scale)],
172            },
173        })
174    }
175
176    // Build one rounded field projection from the intrinsically valid `u32`
177    // public scale domain.
178    pub(in crate::db) fn field(field: impl Into<String>, scale: u32) -> Self {
179        let field = field.into();
180
181        Self::with_valid_scale(field.clone(), Expr::Field(FieldId::new(field)), scale)
182    }
183
184    fn with_valid_scale(field: String, inner: Expr, scale: u32) -> Self {
185        Self {
186            field,
187            expr: Expr::FunctionCall {
188                function: Function::Round,
189                args: vec![inner, Expr::Literal(Value::Nat64(u64::from(scale)))],
190            },
191        }
192    }
193
194    /// Borrow the canonical planner expression carried by this helper.
195    #[must_use]
196    pub(in crate::db) const fn expr(&self) -> &Expr {
197        &self.expr
198    }
199}
200
201impl super::scalar_projection::private::Sealed for RoundProjectionExpr {}
202
203impl ValueProjectionExpr for RoundProjectionExpr {
204    fn field(&self) -> &str {
205        self.field.as_str()
206    }
207
208    fn projection_plan(&self) -> ScalarProjectionPlan {
209        ScalarProjectionPlan::new(self.expr.clone())
210    }
211
212    fn projection_label(&self) -> String {
213        render_scalar_projection_expr_plan_label(&self.expr)
214    }
215
216    fn apply_value(&self, value: Value) -> Result<Value, QueryError> {
217        eval_builder_expr_for_value_preview(&self.expr, self.field.as_str(), &value)
218    }
219}
220
221/// Build `field + literal`.
222#[must_use]
223pub fn add(
224    field: impl AsRef<str>,
225    literal: impl Into<Value> + NumericValue,
226) -> NumericProjectionExpr {
227    NumericProjectionExpr::add_numeric_literal(field.as_ref().to_string(), literal)
228}
229
230/// Build `field - literal`.
231#[must_use]
232pub fn sub(
233    field: impl AsRef<str>,
234    literal: impl Into<Value> + NumericValue,
235) -> NumericProjectionExpr {
236    NumericProjectionExpr::sub_numeric_literal(field.as_ref().to_string(), literal)
237}
238
239/// Build `field * literal`.
240#[must_use]
241pub fn mul(
242    field: impl AsRef<str>,
243    literal: impl Into<Value> + NumericValue,
244) -> NumericProjectionExpr {
245    NumericProjectionExpr::mul_numeric_literal(field.as_ref().to_string(), literal)
246}
247
248/// Build `field / literal`.
249#[must_use]
250pub fn div(
251    field: impl AsRef<str>,
252    literal: impl Into<Value> + NumericValue,
253) -> NumericProjectionExpr {
254    NumericProjectionExpr::div_numeric_literal(field.as_ref().to_string(), literal)
255}
256
257/// Build `ROUND(field, scale)`.
258///
259pub fn round(field: impl AsRef<str>, scale: u32) -> RoundProjectionExpr {
260    RoundProjectionExpr::field(field.as_ref().to_string(), scale)
261}
262
263/// Build `ROUND(expr, scale)` for one existing bounded numeric projection.
264///
265#[must_use]
266pub fn round_expr(projection: &NumericProjectionExpr, scale: u32) -> RoundProjectionExpr {
267    projection.round_with_scale(scale)
268}
269
270#[cfg(test)]
271mod tests {
272    use super::{RoundProjectionExpr, add};
273    use crate::{
274        db::{
275            QueryError,
276            query::plan::expr::{BinaryOp, Expr, FieldId},
277        },
278        types::U256,
279        value::Value,
280    };
281    use icydb_diagnostic_code::{DiagnosticCode, DiagnosticDetail, QueryProjectionCode};
282
283    fn assert_query_projection_error(err: QueryError, reason: QueryProjectionCode) {
284        let diagnostic = err.diagnostic();
285
286        assert_eq!(
287            diagnostic.code(),
288            DiagnosticCode::QueryUnsupportedProjection
289        );
290        assert_eq!(
291            diagnostic.detail(),
292            Some(&DiagnosticDetail::QueryProjection { reason }),
293        );
294    }
295
296    #[test]
297    fn fluent_numeric_projection_accepts_constructed_u256_literals() {
298        let projection = add("amount", U256::from(3_u64));
299
300        assert_eq!(
301            projection.expr(),
302            &Expr::Binary {
303                op: BinaryOp::Add,
304                left: Box::new(Expr::Field(FieldId::new("amount"))),
305                right: Box::new(Expr::Literal(Value::U256(U256::from(3_u64)))),
306            },
307        );
308    }
309
310    #[test]
311    fn round_projection_rejects_negative_scale_with_compact_projection_code() {
312        let err =
313            RoundProjectionExpr::new("age", Expr::Field(FieldId::new("age")), Value::Int64(-1))
314                .expect_err("negative ROUND scale should fail closed");
315
316        assert_query_projection_error(err, QueryProjectionCode::NumericScaleArguments);
317    }
318
319    #[test]
320    fn round_projection_rejects_non_integer_scale_with_compact_projection_code() {
321        let err = RoundProjectionExpr::new(
322            "age",
323            Expr::Field(FieldId::new("age")),
324            Value::Text("invalid".to_string()),
325        )
326        .expect_err("non-integer ROUND scale should fail closed");
327
328        assert_query_projection_error(err, QueryProjectionCode::NumericScaleArguments);
329    }
330}