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 traits::NumericValue,
21 value::{InputValue, 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<InputValue> + NumericValue,
86 ) -> Self {
87 let literal = Value::from(literal.into());
88
89 Self::arithmetic_value(field, op, literal)
90 .expect("typed numeric projection helpers should always produce numeric literals")
91 }
92
93 #[cfg(feature = "sql")]
95 pub(in crate::db) fn add_value(
96 field: impl Into<String>,
97 literal: Value,
98 ) -> Result<Self, QueryError> {
99 Self::arithmetic_value(field, BinaryOp::Add, literal)
100 }
101
102 #[cfg(feature = "sql")]
104 pub(in crate::db) fn sub_value(
105 field: impl Into<String>,
106 literal: Value,
107 ) -> Result<Self, QueryError> {
108 Self::arithmetic_value(field, BinaryOp::Sub, literal)
109 }
110
111 #[cfg(feature = "sql")]
113 pub(in crate::db) fn mul_value(
114 field: impl Into<String>,
115 literal: Value,
116 ) -> Result<Self, QueryError> {
117 Self::arithmetic_value(field, BinaryOp::Mul, literal)
118 }
119
120 #[cfg(feature = "sql")]
122 pub(in crate::db) fn div_value(
123 field: impl Into<String>,
124 literal: Value,
125 ) -> Result<Self, QueryError> {
126 Self::arithmetic_value(field, BinaryOp::Div, literal)
127 }
128
129 pub(in crate::db) fn add_numeric_literal(
132 field: impl Into<String>,
133 literal: impl Into<InputValue> + NumericValue,
134 ) -> Self {
135 Self::arithmetic_numeric_literal(field, BinaryOp::Add, literal)
136 }
137
138 pub(in crate::db) fn sub_numeric_literal(
141 field: impl Into<String>,
142 literal: impl Into<InputValue> + NumericValue,
143 ) -> Self {
144 Self::arithmetic_numeric_literal(field, BinaryOp::Sub, literal)
145 }
146
147 pub(in crate::db) fn mul_numeric_literal(
150 field: impl Into<String>,
151 literal: impl Into<InputValue> + NumericValue,
152 ) -> Self {
153 Self::arithmetic_numeric_literal(field, BinaryOp::Mul, literal)
154 }
155
156 pub(in crate::db) fn div_numeric_literal(
159 field: impl Into<String>,
160 literal: impl Into<InputValue> + NumericValue,
161 ) -> Self {
162 Self::arithmetic_numeric_literal(field, BinaryOp::Div, literal)
163 }
164
165 #[must_use]
167 pub(in crate::db) const fn expr(&self) -> &Expr {
168 &self.expr
169 }
170
171 pub(in crate::db) fn round_with_scale(
174 &self,
175 scale: u32,
176 ) -> Result<RoundProjectionExpr, QueryError> {
177 RoundProjectionExpr::new(
178 self.field.clone(),
179 self.expr.clone(),
180 Value::Nat64(u64::from(scale)),
181 )
182 }
183}
184
185impl ValueProjectionExpr for NumericProjectionExpr {
186 fn field(&self) -> &str {
187 self.field.as_str()
188 }
189
190 fn projection_plan(&self) -> ScalarProjectionPlan {
191 ScalarProjectionPlan::new(self.expr.clone())
192 }
193
194 fn projection_label(&self) -> String {
195 render_scalar_projection_expr_plan_label(&self.expr)
196 }
197
198 fn apply_value(&self, value: Value) -> Result<Value, QueryError> {
199 eval_builder_expr_for_value_preview(&self.expr, self.field.as_str(), &value)
200 }
201}
202
203#[derive(Clone, Debug, Eq, PartialEq)]
213pub struct RoundProjectionExpr {
214 field: String,
215 expr: Expr,
216}
217
218impl RoundProjectionExpr {
219 pub(in crate::db) fn new(
222 field: impl Into<String>,
223 inner: Expr,
224 scale: Value,
225 ) -> Result<Self, QueryError> {
226 match scale {
227 Value::Int64(value) if value < 0 => {
228 return Err(QueryError::unsupported_projection(
229 QueryProjectionCode::NumericScaleArguments,
230 ));
231 }
232 Value::Int64(_) | Value::Nat64(_) => {}
233 _ => {
234 return Err(QueryError::unsupported_projection(
235 QueryProjectionCode::NumericScaleArguments,
236 ));
237 }
238 }
239
240 Ok(Self {
241 field: field.into(),
242 expr: Expr::FunctionCall {
243 function: Function::Round,
244 args: vec![inner, Expr::Literal(scale)],
245 },
246 })
247 }
248
249 pub(in crate::db) fn field(field: impl Into<String>, scale: u32) -> Result<Self, QueryError> {
251 let field = field.into();
252
253 Self::new(
254 field.clone(),
255 Expr::Field(FieldId::new(field)),
256 Value::Nat64(u64::from(scale)),
257 )
258 }
259
260 #[must_use]
262 pub(in crate::db) const fn expr(&self) -> &Expr {
263 &self.expr
264 }
265}
266
267impl ValueProjectionExpr for RoundProjectionExpr {
268 fn field(&self) -> &str {
269 self.field.as_str()
270 }
271
272 fn projection_plan(&self) -> ScalarProjectionPlan {
273 ScalarProjectionPlan::new(self.expr.clone())
274 }
275
276 fn projection_label(&self) -> String {
277 render_scalar_projection_expr_plan_label(&self.expr)
278 }
279
280 fn apply_value(&self, value: Value) -> Result<Value, QueryError> {
281 eval_builder_expr_for_value_preview(&self.expr, self.field.as_str(), &value)
282 }
283}
284
285#[must_use]
287pub fn add(
288 field: impl AsRef<str>,
289 literal: impl Into<InputValue> + NumericValue,
290) -> NumericProjectionExpr {
291 NumericProjectionExpr::add_numeric_literal(field.as_ref().to_string(), literal)
292}
293
294#[must_use]
296pub fn sub(
297 field: impl AsRef<str>,
298 literal: impl Into<InputValue> + NumericValue,
299) -> NumericProjectionExpr {
300 NumericProjectionExpr::sub_numeric_literal(field.as_ref().to_string(), literal)
301}
302
303#[must_use]
305pub fn mul(
306 field: impl AsRef<str>,
307 literal: impl Into<InputValue> + NumericValue,
308) -> NumericProjectionExpr {
309 NumericProjectionExpr::mul_numeric_literal(field.as_ref().to_string(), literal)
310}
311
312#[must_use]
314pub fn div(
315 field: impl AsRef<str>,
316 literal: impl Into<InputValue> + NumericValue,
317) -> NumericProjectionExpr {
318 NumericProjectionExpr::div_numeric_literal(field.as_ref().to_string(), literal)
319}
320
321pub fn round(field: impl AsRef<str>, scale: u32) -> RoundProjectionExpr {
323 RoundProjectionExpr::field(field.as_ref().to_string(), scale)
324 .expect("ROUND(field, scale) helper should always produce a bounded projection")
325}
326
327#[must_use]
329pub fn round_expr(projection: &NumericProjectionExpr, scale: u32) -> RoundProjectionExpr {
330 projection
331 .round_with_scale(scale)
332 .expect("ROUND(expr, scale) helper should always produce a bounded projection")
333}
334
335#[cfg(test)]
336mod tests {
337 use super::{NumericProjectionExpr, RoundProjectionExpr};
338 use crate::{
339 db::{
340 QueryError,
341 query::plan::expr::{BinaryOp, Expr, FieldId},
342 },
343 value::Value,
344 };
345 use icydb_diagnostic_code::{DiagnosticCode, DiagnosticDetail, QueryProjectionCode};
346
347 fn assert_query_projection_error(err: QueryError, reason: QueryProjectionCode) {
348 let diagnostic = err.diagnostic();
349
350 assert_eq!(
351 diagnostic.code(),
352 DiagnosticCode::QueryUnsupportedProjection
353 );
354 assert_eq!(
355 diagnostic.detail(),
356 Some(&DiagnosticDetail::QueryProjection { reason }),
357 );
358 }
359
360 #[test]
361 fn numeric_projection_rejects_non_numeric_literal_with_compact_projection_code() {
362 let err = NumericProjectionExpr::arithmetic_value("age", BinaryOp::Add, Value::Bool(true))
363 .expect_err("non-numeric projection literal should fail closed");
364
365 assert_query_projection_error(err, QueryProjectionCode::NumericLiteralRequired);
366 }
367
368 #[test]
369 fn round_projection_rejects_negative_scale_with_compact_projection_code() {
370 let err =
371 RoundProjectionExpr::new("age", Expr::Field(FieldId::new("age")), Value::Int64(-1))
372 .expect_err("negative ROUND scale should fail closed");
373
374 assert_query_projection_error(err, QueryProjectionCode::NumericScaleArguments);
375 }
376
377 #[test]
378 fn round_projection_rejects_non_integer_scale_with_compact_projection_code() {
379 let err = RoundProjectionExpr::new(
380 "age",
381 Expr::Field(FieldId::new("age")),
382 Value::Text("invalid".to_string()),
383 )
384 .expect_err("non-integer ROUND scale should fail closed");
385
386 assert_query_projection_error(err, QueryProjectionCode::NumericScaleArguments);
387 }
388}