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).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<InputValue> + 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<InputValue> + 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<InputValue> + 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<InputValue> + 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 ValueProjectionExpr for NumericProjectionExpr {
185 fn field(&self) -> &str {
186 self.field.as_str()
187 }
188
189 fn projection_plan(&self) -> ScalarProjectionPlan {
190 ScalarProjectionPlan::new(self.expr.clone())
191 }
192
193 fn projection_label(&self) -> String {
194 render_scalar_projection_expr_plan_label(&self.expr)
195 }
196
197 fn apply_value(&self, value: Value) -> Result<Value, QueryError> {
198 eval_builder_expr_for_value_preview(&self.expr, self.field.as_str(), &value)
199 }
200}
201
202#[derive(Clone, Debug, Eq, PartialEq)]
212pub struct RoundProjectionExpr {
213 field: String,
214 expr: Expr,
215}
216
217impl RoundProjectionExpr {
218 pub(in crate::db) fn new(
221 field: impl Into<String>,
222 inner: Expr,
223 scale: Value,
224 ) -> Result<Self, QueryError> {
225 match scale {
226 Value::Int64(value) if value < 0 => {
227 return Err(QueryError::unsupported_projection(
228 QueryProjectionCode::NumericScaleArguments,
229 ));
230 }
231 Value::Int64(_) | Value::Nat64(_) => {}
232 _ => {
233 return Err(QueryError::unsupported_projection(
234 QueryProjectionCode::NumericScaleArguments,
235 ));
236 }
237 }
238
239 Ok(Self {
240 field: field.into(),
241 expr: Expr::FunctionCall {
242 function: Function::Round,
243 args: vec![inner, Expr::Literal(scale)],
244 },
245 })
246 }
247
248 pub(in crate::db) fn field(field: impl Into<String>, scale: u32) -> Result<Self, QueryError> {
250 let field = field.into();
251
252 Self::new(
253 field.clone(),
254 Expr::Field(FieldId::new(field)),
255 Value::Nat64(u64::from(scale)),
256 )
257 }
258
259 #[must_use]
261 pub(in crate::db) const fn expr(&self) -> &Expr {
262 &self.expr
263 }
264}
265
266impl ValueProjectionExpr for RoundProjectionExpr {
267 fn field(&self) -> &str {
268 self.field.as_str()
269 }
270
271 fn projection_plan(&self) -> ScalarProjectionPlan {
272 ScalarProjectionPlan::new(self.expr.clone())
273 }
274
275 fn projection_label(&self) -> String {
276 render_scalar_projection_expr_plan_label(&self.expr)
277 }
278
279 fn apply_value(&self, value: Value) -> Result<Value, QueryError> {
280 eval_builder_expr_for_value_preview(&self.expr, self.field.as_str(), &value)
281 }
282}
283
284#[must_use]
286pub fn add(
287 field: impl AsRef<str>,
288 literal: impl Into<InputValue> + NumericValue,
289) -> NumericProjectionExpr {
290 NumericProjectionExpr::add_numeric_literal(field.as_ref().to_string(), literal)
291}
292
293#[must_use]
295pub fn sub(
296 field: impl AsRef<str>,
297 literal: impl Into<InputValue> + NumericValue,
298) -> NumericProjectionExpr {
299 NumericProjectionExpr::sub_numeric_literal(field.as_ref().to_string(), literal)
300}
301
302#[must_use]
304pub fn mul(
305 field: impl AsRef<str>,
306 literal: impl Into<InputValue> + NumericValue,
307) -> NumericProjectionExpr {
308 NumericProjectionExpr::mul_numeric_literal(field.as_ref().to_string(), literal)
309}
310
311#[must_use]
313pub fn div(
314 field: impl AsRef<str>,
315 literal: impl Into<InputValue> + NumericValue,
316) -> NumericProjectionExpr {
317 NumericProjectionExpr::div_numeric_literal(field.as_ref().to_string(), literal)
318}
319
320pub fn round(field: impl AsRef<str>, scale: u32) -> RoundProjectionExpr {
322 RoundProjectionExpr::field(field.as_ref().to_string(), scale)
323 .expect("numeric projection invariant")
324}
325
326#[must_use]
328pub fn round_expr(projection: &NumericProjectionExpr, scale: u32) -> RoundProjectionExpr {
329 projection
330 .round_with_scale(scale)
331 .expect("numeric projection invariant")
332}
333
334#[cfg(test)]
335mod tests {
336 use super::{NumericProjectionExpr, RoundProjectionExpr};
337 use crate::{
338 db::{
339 QueryError,
340 query::plan::expr::{BinaryOp, Expr, FieldId},
341 },
342 value::Value,
343 };
344 use icydb_diagnostic_code::{DiagnosticCode, DiagnosticDetail, QueryProjectionCode};
345
346 fn assert_query_projection_error(err: QueryError, reason: QueryProjectionCode) {
347 let diagnostic = err.diagnostic();
348
349 assert_eq!(
350 diagnostic.code(),
351 DiagnosticCode::QueryUnsupportedProjection
352 );
353 assert_eq!(
354 diagnostic.detail(),
355 Some(&DiagnosticDetail::QueryProjection { reason }),
356 );
357 }
358
359 #[test]
360 fn numeric_projection_rejects_non_numeric_literal_with_compact_projection_code() {
361 let err = NumericProjectionExpr::arithmetic_value("age", BinaryOp::Add, Value::Bool(true))
362 .expect_err("non-numeric projection literal should fail closed");
363
364 assert_query_projection_error(err, QueryProjectionCode::NumericLiteralRequired);
365 }
366
367 #[test]
368 fn round_projection_rejects_negative_scale_with_compact_projection_code() {
369 let err =
370 RoundProjectionExpr::new("age", Expr::Field(FieldId::new("age")), Value::Int64(-1))
371 .expect_err("negative ROUND scale should fail closed");
372
373 assert_query_projection_error(err, QueryProjectionCode::NumericScaleArguments);
374 }
375
376 #[test]
377 fn round_projection_rejects_non_integer_scale_with_compact_projection_code() {
378 let err = RoundProjectionExpr::new(
379 "age",
380 Expr::Field(FieldId::new("age")),
381 Value::Text("invalid".to_string()),
382 )
383 .expect_err("non-integer ROUND scale should fail closed");
384
385 assert_query_projection_error(err, QueryProjectionCode::NumericScaleArguments);
386 }
387}