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
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<Value> + 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<Value> + 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<Value> + 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<Value> + 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 super::scalar_projection::private::Sealed for NumericProjectionExpr {}
185
186impl ValueProjectionExpr for NumericProjectionExpr {
187    fn field(&self) -> &str {
188        self.field.as_str()
189    }
190
191    fn projection_plan(&self) -> ScalarProjectionPlan {
192        ScalarProjectionPlan::new(self.expr.clone())
193    }
194
195    fn projection_label(&self) -> String {
196        render_scalar_projection_expr_plan_label(&self.expr)
197    }
198
199    fn apply_value(&self, value: Value) -> Result<Value, QueryError> {
200        eval_builder_expr_for_value_preview(&self.expr, self.field.as_str(), &value)
201    }
202}
203
204///
205/// RoundProjectionExpr
206///
207/// Shared bounded numeric rounding projection over one source field and one
208/// canonical scalar numeric expression.
209/// This keeps `ROUND` on the scalar projection seam without opening a generic
210/// function-builder surface.
211///
212
213#[derive(Clone, Debug, Eq, PartialEq)]
214pub struct RoundProjectionExpr {
215    field: String,
216    expr: Expr,
217}
218
219impl RoundProjectionExpr {
220    // Build one bounded `ROUND(expr, scale)` projection after validating that
221    // `scale` stays on the admitted non-negative integer seam.
222    pub(in crate::db) fn new(
223        field: impl Into<String>,
224        inner: Expr,
225        scale: Value,
226    ) -> Result<Self, QueryError> {
227        match scale {
228            Value::Int64(value) if value < 0 => {
229                return Err(QueryError::unsupported_projection(
230                    QueryProjectionCode::NumericScaleArguments,
231                ));
232            }
233            Value::Int64(_) | Value::Nat64(_) => {}
234            _ => {
235                return Err(QueryError::unsupported_projection(
236                    QueryProjectionCode::NumericScaleArguments,
237                ));
238            }
239        }
240
241        Ok(Self {
242            field: field.into(),
243            expr: Expr::FunctionCall {
244                function: Function::Round,
245                args: vec![inner, Expr::Literal(scale)],
246            },
247        })
248    }
249
250    // Build one rounded field projection.
251    pub(in crate::db) fn field(field: impl Into<String>, scale: u32) -> Result<Self, QueryError> {
252        let field = field.into();
253
254        Self::new(
255            field.clone(),
256            Expr::Field(FieldId::new(field)),
257            Value::Nat64(u64::from(scale)),
258        )
259    }
260
261    /// Borrow the canonical planner expression carried by this helper.
262    #[must_use]
263    pub(in crate::db) const fn expr(&self) -> &Expr {
264        &self.expr
265    }
266}
267
268impl super::scalar_projection::private::Sealed for RoundProjectionExpr {}
269
270impl ValueProjectionExpr for RoundProjectionExpr {
271    fn field(&self) -> &str {
272        self.field.as_str()
273    }
274
275    fn projection_plan(&self) -> ScalarProjectionPlan {
276        ScalarProjectionPlan::new(self.expr.clone())
277    }
278
279    fn projection_label(&self) -> String {
280        render_scalar_projection_expr_plan_label(&self.expr)
281    }
282
283    fn apply_value(&self, value: Value) -> Result<Value, QueryError> {
284        eval_builder_expr_for_value_preview(&self.expr, self.field.as_str(), &value)
285    }
286}
287
288/// Build `field + literal`.
289#[must_use]
290pub fn add(
291    field: impl AsRef<str>,
292    literal: impl Into<Value> + NumericValue,
293) -> NumericProjectionExpr {
294    NumericProjectionExpr::add_numeric_literal(field.as_ref().to_string(), literal)
295}
296
297/// Build `field - literal`.
298#[must_use]
299pub fn sub(
300    field: impl AsRef<str>,
301    literal: impl Into<Value> + NumericValue,
302) -> NumericProjectionExpr {
303    NumericProjectionExpr::sub_numeric_literal(field.as_ref().to_string(), literal)
304}
305
306/// Build `field * literal`.
307#[must_use]
308pub fn mul(
309    field: impl AsRef<str>,
310    literal: impl Into<Value> + NumericValue,
311) -> NumericProjectionExpr {
312    NumericProjectionExpr::mul_numeric_literal(field.as_ref().to_string(), literal)
313}
314
315/// Build `field / literal`.
316#[must_use]
317pub fn div(
318    field: impl AsRef<str>,
319    literal: impl Into<Value> + NumericValue,
320) -> NumericProjectionExpr {
321    NumericProjectionExpr::div_numeric_literal(field.as_ref().to_string(), literal)
322}
323
324/// Build `ROUND(field, scale)`.
325///
326/// # Panics
327///
328/// Panics when `scale` exceeds the supported numeric projection scale.
329pub fn round(field: impl AsRef<str>, scale: u32) -> RoundProjectionExpr {
330    RoundProjectionExpr::field(field.as_ref().to_string(), scale)
331        .expect("numeric projection invariant")
332}
333
334/// Build `ROUND(expr, scale)` for one existing bounded numeric projection.
335///
336/// # Panics
337///
338/// Panics when `scale` exceeds the supported numeric projection scale.
339#[must_use]
340pub fn round_expr(projection: &NumericProjectionExpr, scale: u32) -> RoundProjectionExpr {
341    projection
342        .round_with_scale(scale)
343        .expect("numeric projection invariant")
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}