Skip to main content

icydb_core/db/query/builder/
text_projection.rs

1//! Module: query::builder::text_projection
2//! Responsibility: shared bounded text-function builder surface and function
3//! semantics used by fluent terminals and canonical SQL projection execution.
4//! Does not own: generic query planning, grouped semantics, or SQL parsing.
5//! Boundary: models the admitted text-function family on top of canonical
6//! planner expressions without reopening a generic function registry.
7
8use crate::{
9    db::{
10        QueryError,
11        executor::projection::eval_text_projection_expr_with_value,
12        query::plan::expr::{Expr, FieldId, Function},
13    },
14    traits::FieldValue,
15    value::Value,
16};
17
18///
19/// TextProjectionExpr
20///
21/// Shared bounded text-function projection over one source field.
22/// This stays intentionally narrow even though it now lowers through the same
23/// canonical `Expr` surface used by SQL projection planning.
24///
25
26#[derive(Clone, Debug, Eq, PartialEq)]
27pub struct TextProjectionExpr {
28    field: String,
29    expr: Expr,
30}
31
32impl TextProjectionExpr {
33    // Build one field-function projection carrying only the source field.
34    pub(in crate::db) fn unary(field: impl Into<String>, function: Function) -> Self {
35        let field = field.into();
36
37        Self {
38            expr: Expr::FunctionCall {
39                function,
40                args: vec![Expr::Field(FieldId::new(field.clone()))],
41            },
42            field,
43        }
44    }
45
46    // Build one field-function projection carrying one literal argument.
47    pub(in crate::db) fn with_literal(
48        field: impl Into<String>,
49        function: Function,
50        literal: impl FieldValue,
51    ) -> Self {
52        let field = field.into();
53
54        Self {
55            expr: Expr::FunctionCall {
56                function,
57                args: vec![
58                    Expr::Field(FieldId::new(field.clone())),
59                    Expr::Literal(literal.to_value()),
60                ],
61            },
62            field,
63        }
64    }
65
66    // Build one field-function projection carrying two literal arguments.
67    pub(in crate::db) fn with_two_literals(
68        field: impl Into<String>,
69        function: Function,
70        literal: impl FieldValue,
71        literal2: impl FieldValue,
72    ) -> Self {
73        let field = field.into();
74
75        Self {
76            expr: Expr::FunctionCall {
77                function,
78                args: vec![
79                    Expr::Field(FieldId::new(field.clone())),
80                    Expr::Literal(literal.to_value()),
81                    Expr::Literal(literal2.to_value()),
82                ],
83            },
84            field,
85        }
86    }
87
88    // Build one `POSITION(literal, field)` projection.
89    pub(in crate::db) fn position(field: impl Into<String>, literal: impl FieldValue) -> Self {
90        let field = field.into();
91
92        Self {
93            expr: Expr::FunctionCall {
94                function: Function::Position,
95                args: vec![
96                    Expr::Literal(literal.to_value()),
97                    Expr::Field(FieldId::new(field.clone())),
98                ],
99            },
100            field,
101        }
102    }
103
104    /// Borrow the source field name.
105    #[must_use]
106    pub const fn field(&self) -> &str {
107        self.field.as_str()
108    }
109
110    /// Borrow the canonical planner expression carried by this helper.
111    #[must_use]
112    pub(in crate::db) const fn expr(&self) -> &Expr {
113        &self.expr
114    }
115
116    /// Render the stable SQL-style output label for this projection.
117    #[must_use]
118    pub fn sql_label(&self) -> String {
119        render_text_projection_expr_sql_label(&self.expr)
120    }
121
122    /// Apply this projection to one already-loaded scalar field value.
123    pub fn apply_value(&self, value: Value) -> Result<Value, QueryError> {
124        eval_text_projection_expr_with_value(&self.expr, self.field.as_str(), &value)
125    }
126}
127
128/// Render one canonical bounded text-function expression back into a stable
129/// SQL-style label.
130#[must_use]
131pub(in crate::db) fn render_text_projection_expr_sql_label(expr: &Expr) -> String {
132    match expr {
133        Expr::Field(field) => field.as_str().to_string(),
134        Expr::Literal(value) => render_text_projection_literal(value),
135        Expr::FunctionCall { function, args } => {
136            let rendered_args = args
137                .iter()
138                .map(render_text_projection_expr_sql_label)
139                .collect::<Vec<_>>()
140                .join(", ");
141
142            format!("{}({rendered_args})", function.sql_label())
143        }
144        Expr::Aggregate(_) => "aggregate".to_string(),
145        #[cfg(test)]
146        Expr::Alias { expr, .. } => render_text_projection_expr_sql_label(expr.as_ref()),
147        #[cfg(test)]
148        Expr::Unary { .. } | Expr::Binary { .. } => "expr".to_string(),
149    }
150}
151
152/// Build `TRIM(field)`.
153#[must_use]
154pub fn trim(field: impl AsRef<str>) -> TextProjectionExpr {
155    TextProjectionExpr::unary(field.as_ref().to_string(), Function::Trim)
156}
157
158/// Build `LTRIM(field)`.
159#[must_use]
160pub fn ltrim(field: impl AsRef<str>) -> TextProjectionExpr {
161    TextProjectionExpr::unary(field.as_ref().to_string(), Function::Ltrim)
162}
163
164/// Build `RTRIM(field)`.
165#[must_use]
166pub fn rtrim(field: impl AsRef<str>) -> TextProjectionExpr {
167    TextProjectionExpr::unary(field.as_ref().to_string(), Function::Rtrim)
168}
169
170/// Build `LOWER(field)`.
171#[must_use]
172pub fn lower(field: impl AsRef<str>) -> TextProjectionExpr {
173    TextProjectionExpr::unary(field.as_ref().to_string(), Function::Lower)
174}
175
176/// Build `UPPER(field)`.
177#[must_use]
178pub fn upper(field: impl AsRef<str>) -> TextProjectionExpr {
179    TextProjectionExpr::unary(field.as_ref().to_string(), Function::Upper)
180}
181
182/// Build `LENGTH(field)`.
183#[must_use]
184pub fn length(field: impl AsRef<str>) -> TextProjectionExpr {
185    TextProjectionExpr::unary(field.as_ref().to_string(), Function::Length)
186}
187
188/// Build `LEFT(field, length)`.
189#[must_use]
190pub fn left(field: impl AsRef<str>, length: impl FieldValue) -> TextProjectionExpr {
191    TextProjectionExpr::with_literal(field.as_ref().to_string(), Function::Left, length)
192}
193
194/// Build `RIGHT(field, length)`.
195#[must_use]
196pub fn right(field: impl AsRef<str>, length: impl FieldValue) -> TextProjectionExpr {
197    TextProjectionExpr::with_literal(field.as_ref().to_string(), Function::Right, length)
198}
199
200/// Build `STARTS_WITH(field, literal)`.
201#[must_use]
202pub fn starts_with(field: impl AsRef<str>, literal: impl FieldValue) -> TextProjectionExpr {
203    TextProjectionExpr::with_literal(field.as_ref().to_string(), Function::StartsWith, literal)
204}
205
206/// Build `ENDS_WITH(field, literal)`.
207#[must_use]
208pub fn ends_with(field: impl AsRef<str>, literal: impl FieldValue) -> TextProjectionExpr {
209    TextProjectionExpr::with_literal(field.as_ref().to_string(), Function::EndsWith, literal)
210}
211
212/// Build `CONTAINS(field, literal)`.
213#[must_use]
214pub fn contains(field: impl AsRef<str>, literal: impl FieldValue) -> TextProjectionExpr {
215    TextProjectionExpr::with_literal(field.as_ref().to_string(), Function::Contains, literal)
216}
217
218/// Build `POSITION(literal, field)`.
219#[must_use]
220pub fn position(field: impl AsRef<str>, literal: impl FieldValue) -> TextProjectionExpr {
221    TextProjectionExpr::position(field.as_ref().to_string(), literal)
222}
223
224/// Build `REPLACE(field, from, to)`.
225#[must_use]
226pub fn replace(
227    field: impl AsRef<str>,
228    from: impl FieldValue,
229    to: impl FieldValue,
230) -> TextProjectionExpr {
231    TextProjectionExpr::with_two_literals(field.as_ref().to_string(), Function::Replace, from, to)
232}
233
234/// Build `SUBSTRING(field, start)`.
235#[must_use]
236pub fn substring(field: impl AsRef<str>, start: impl FieldValue) -> TextProjectionExpr {
237    TextProjectionExpr::with_literal(field.as_ref().to_string(), Function::Substring, start)
238}
239
240/// Build `SUBSTRING(field, start, length)`.
241#[must_use]
242pub fn substring_with_length(
243    field: impl AsRef<str>,
244    start: impl FieldValue,
245    length: impl FieldValue,
246) -> TextProjectionExpr {
247    TextProjectionExpr::with_two_literals(
248        field.as_ref().to_string(),
249        Function::Substring,
250        start,
251        length,
252    )
253}
254
255// Render one projection literal back into a stable SQL-style label fragment.
256fn render_text_projection_literal(value: &Value) -> String {
257    match value {
258        Value::Null => "NULL".to_string(),
259        Value::Text(text) => format!("'{}'", text.replace('\'', "''")),
260        Value::Int(value) => value.to_string(),
261        Value::Uint(value) => value.to_string(),
262        _ => "<invalid-text-literal>".to_string(),
263    }
264}
265
266///
267/// TESTS
268///
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[test]
275    fn lower_text_projection_renders_sql_label() {
276        assert_eq!(lower("name").sql_label(), "LOWER(name)");
277    }
278
279    #[test]
280    fn replace_text_projection_applies_shared_transform() {
281        let value = replace("name", "Ada", "Eve")
282            .apply_value(Value::Text("Ada Ada".to_string()))
283            .expect("replace projection should apply");
284
285        assert_eq!(value, Value::Text("Eve Eve".to_string()));
286    }
287}