icydb_core/db/query/builder/
numeric_projection.rs1use 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#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct NumericProjectionExpr {
36 field: String,
37 expr: Expr,
38}
39
40impl NumericProjectionExpr {
41 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 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 #[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 #[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 #[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 #[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 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 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 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 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 #[must_use]
166 pub(in crate::db) const fn expr(&self) -> &Expr {
167 &self.expr
168 }
169
170 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#[derive(Clone, Debug, Eq, PartialEq)]
214pub struct RoundProjectionExpr {
215 field: String,
216 expr: Expr,
217}
218
219impl RoundProjectionExpr {
220 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 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 #[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#[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#[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#[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#[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
324pub 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#[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}