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