Skip to main content

icydb_core/db/query/builder/
text_projection.rs

1//! Module: query::builder::text_projection
2//! Responsibility: shared narrow text-projection builder surface for fluent
3//! terminals and SQL computed projection execution.
4//! Does not own: generic query planning, grouped semantics, or SQL parsing.
5//! Boundary: models the admitted text transform family and applies it to one
6//! already-loaded scalar value.
7
8use crate::{db::QueryError, traits::FieldValue, value::Value};
9
10///
11/// TextProjectionTransform
12///
13/// Canonical narrow text-projection transform taxonomy shared by fluent and
14/// SQL computed projection surfaces.
15/// This is intentionally limited to the admitted single-field text function
16/// family already shipped on the SQL surface.
17///
18
19#[derive(Clone, Copy, Debug, Eq, PartialEq)]
20pub enum TextProjectionTransform {
21    Field,
22    Trim,
23    Ltrim,
24    Rtrim,
25    Lower,
26    Upper,
27    Length,
28    Left,
29    Right,
30    StartsWith,
31    EndsWith,
32    Contains,
33    Position,
34    Replace,
35    Substring,
36}
37
38impl TextProjectionTransform {
39    /// Return the stable uppercase function label for this transform.
40    #[must_use]
41    pub const fn label(self) -> &'static str {
42        match self {
43            Self::Field => "FIELD",
44            Self::Trim => "TRIM",
45            Self::Ltrim => "LTRIM",
46            Self::Rtrim => "RTRIM",
47            Self::Lower => "LOWER",
48            Self::Upper => "UPPER",
49            Self::Length => "LENGTH",
50            Self::Left => "LEFT",
51            Self::Right => "RIGHT",
52            Self::StartsWith => "STARTS_WITH",
53            Self::EndsWith => "ENDS_WITH",
54            Self::Contains => "CONTAINS",
55            Self::Position => "POSITION",
56            Self::Replace => "REPLACE",
57            Self::Substring => "SUBSTRING",
58        }
59    }
60}
61
62///
63/// TextProjectionExpr
64///
65/// Shared narrow text-projection expression over one source field.
66/// This remains a terminal/projection helper, not a generic expression system.
67/// Literal slots preserve the exact shipped SQL text-function argument family.
68///
69
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct TextProjectionExpr {
72    field: String,
73    transform: TextProjectionTransform,
74    literal: Option<Value>,
75    literal2: Option<Value>,
76    literal3: Option<Value>,
77}
78
79impl TextProjectionExpr {
80    /// Build one no-literal text projection over a source field.
81    #[must_use]
82    pub fn new(field: impl Into<String>, transform: TextProjectionTransform) -> Self {
83        Self {
84            field: field.into(),
85            transform,
86            literal: None,
87            literal2: None,
88            literal3: None,
89        }
90    }
91
92    /// Build one text projection carrying one literal argument.
93    #[must_use]
94    pub fn with_literal(
95        field: impl Into<String>,
96        transform: TextProjectionTransform,
97        literal: impl FieldValue,
98    ) -> Self {
99        Self {
100            field: field.into(),
101            transform,
102            literal: Some(literal.to_value()),
103            literal2: None,
104            literal3: None,
105        }
106    }
107
108    /// Build one text projection carrying two literal arguments.
109    #[must_use]
110    pub fn with_two_literals(
111        field: impl Into<String>,
112        transform: TextProjectionTransform,
113        literal: impl FieldValue,
114        literal2: impl FieldValue,
115    ) -> Self {
116        Self {
117            field: field.into(),
118            transform,
119            literal: Some(literal.to_value()),
120            literal2: Some(literal2.to_value()),
121            literal3: None,
122        }
123    }
124
125    /// Borrow the source field name.
126    #[must_use]
127    pub const fn field(&self) -> &str {
128        self.field.as_str()
129    }
130
131    /// Return the transform taxonomy for this projection expression.
132    #[must_use]
133    pub const fn transform(&self) -> TextProjectionTransform {
134        self.transform
135    }
136
137    /// Borrow the first optional literal argument.
138    #[must_use]
139    pub const fn literal(&self) -> Option<&Value> {
140        self.literal.as_ref()
141    }
142
143    /// Borrow the second optional literal argument.
144    #[must_use]
145    pub const fn literal2(&self) -> Option<&Value> {
146        self.literal2.as_ref()
147    }
148
149    /// Borrow the third optional literal argument.
150    #[must_use]
151    pub const fn literal3(&self) -> Option<&Value> {
152        self.literal3.as_ref()
153    }
154
155    /// Override the first optional literal argument.
156    #[must_use]
157    pub fn with_optional_literal(mut self, literal: Option<Value>) -> Self {
158        self.literal = literal;
159        self
160    }
161
162    /// Override the second optional literal argument.
163    #[must_use]
164    pub fn with_optional_second_literal(mut self, literal: Option<Value>) -> Self {
165        self.literal2 = literal;
166        self
167    }
168
169    /// Override the third optional literal argument.
170    #[must_use]
171    pub fn with_optional_third_literal(mut self, literal: Option<Value>) -> Self {
172        self.literal3 = literal;
173        self
174    }
175
176    /// Render the stable SQL-style output label for this projection.
177    #[must_use]
178    pub fn sql_label(&self) -> String {
179        let function_name = self.transform.label();
180        let field = self.field.as_str();
181
182        match (
183            self.transform,
184            self.literal.as_ref(),
185            self.literal2.as_ref(),
186            self.literal3.as_ref(),
187        ) {
188            (TextProjectionTransform::Field, _, _, _) => field.to_string(),
189            (TextProjectionTransform::Position, Some(literal), _, _) => format!(
190                "{function_name}({}, {field})",
191                render_text_projection_literal(literal),
192            ),
193            (
194                TextProjectionTransform::StartsWith
195                | TextProjectionTransform::EndsWith
196                | TextProjectionTransform::Contains,
197                Some(literal),
198                _,
199                _,
200            ) => format!(
201                "{function_name}({field}, {})",
202                render_text_projection_literal(literal),
203            ),
204            (TextProjectionTransform::Replace, Some(from), Some(to), _) => format!(
205                "{function_name}({field}, {}, {})",
206                render_text_projection_literal(from),
207                render_text_projection_literal(to),
208            ),
209            (
210                TextProjectionTransform::Left | TextProjectionTransform::Right,
211                Some(length),
212                _,
213                _,
214            ) => {
215                format!(
216                    "{function_name}({field}, {})",
217                    render_text_projection_literal(length),
218                )
219            }
220            (TextProjectionTransform::Substring, Some(start), Some(len), _) => format!(
221                "{function_name}({field}, {}, {})",
222                render_text_projection_literal(start),
223                render_text_projection_literal(len),
224            ),
225            (TextProjectionTransform::Substring, Some(start), None, _) => format!(
226                "{function_name}({field}, {})",
227                render_text_projection_literal(start),
228            ),
229            _ => format!("{function_name}({field})"),
230        }
231    }
232
233    /// Apply this projection to one already-loaded scalar value.
234    pub fn apply_value(&self, value: Value) -> Result<Value, QueryError> {
235        match self.transform {
236            TextProjectionTransform::Field => Ok(value),
237            TextProjectionTransform::Trim
238            | TextProjectionTransform::Ltrim
239            | TextProjectionTransform::Rtrim
240            | TextProjectionTransform::Lower
241            | TextProjectionTransform::Upper
242            | TextProjectionTransform::Length
243            | TextProjectionTransform::Left
244            | TextProjectionTransform::Right
245            | TextProjectionTransform::StartsWith
246            | TextProjectionTransform::EndsWith
247            | TextProjectionTransform::Contains
248            | TextProjectionTransform::Position
249            | TextProjectionTransform::Replace
250            | TextProjectionTransform::Substring => match value {
251                Value::Null => Ok(Value::Null),
252                Value::Text(text) => self.apply_non_null_text(text),
253                other => Err(self.text_input_error(&other)),
254            },
255        }
256    }
257
258    // Build the deterministic text-input mismatch error for this projection.
259    fn text_input_error(&self, other: &Value) -> QueryError {
260        QueryError::unsupported_query(format!(
261            "{}({}) requires text input, found {other:?}",
262            self.transform.label(),
263            self.field,
264        ))
265    }
266
267    // Resolve the optional text literal argument used by the binary text helpers.
268    fn text_literal(&self) -> Result<Option<&str>, QueryError> {
269        match self.literal.as_ref() {
270            Some(Value::Null) => Ok(None),
271            Some(Value::Text(text)) => Ok(Some(text.as_str())),
272            Some(other) => Err(QueryError::unsupported_query(format!(
273                "{}({}, ...) requires text literal argument, found {other:?}",
274                self.transform.label(),
275                self.field,
276            ))),
277            None => Err(QueryError::invariant(format!(
278                "{} projection item was missing its literal argument",
279                self.transform.label(),
280            ))),
281        }
282    }
283
284    // Resolve the second optional text literal used by `REPLACE`.
285    fn second_text_literal(&self) -> Result<Option<&str>, QueryError> {
286        match self.literal2.as_ref() {
287            Some(Value::Null) => Ok(None),
288            Some(Value::Text(text)) => Ok(Some(text.as_str())),
289            Some(other) => Err(QueryError::unsupported_query(format!(
290                "{}({}, ..., ...) requires text literal argument, found {other:?}",
291                self.transform.label(),
292                self.field,
293            ))),
294            None => Err(QueryError::invariant(format!(
295                "{} projection item was missing its second literal argument",
296                self.transform.label(),
297            ))),
298        }
299    }
300
301    // Resolve one integer-like literal used by the numeric text helpers.
302    fn numeric_literal(
303        &self,
304        label: &'static str,
305        value: Option<&Value>,
306    ) -> Result<Option<i64>, QueryError> {
307        match value {
308            Some(Value::Null) => Ok(None),
309            Some(Value::Int(value)) => Ok(Some(*value)),
310            Some(Value::Uint(value)) => Ok(Some(i64::try_from(*value).unwrap_or(i64::MAX))),
311            Some(other) => Err(QueryError::unsupported_query(format!(
312                "{}({}, ...) requires integer or NULL {label}, found {other:?}",
313                self.transform.label(),
314                self.field,
315            ))),
316            None if label == "length" => Ok(None),
317            None => Err(QueryError::invariant(format!(
318                "{} projection item was missing its {label} literal",
319                self.transform.label(),
320            ))),
321        }
322    }
323
324    // Apply one numeric text transform using the current narrow contract.
325    fn apply_numeric_text(&self, text: &str) -> Result<Value, QueryError> {
326        match self.transform {
327            TextProjectionTransform::Left => {
328                let len = self.numeric_literal("length", self.literal.as_ref())?;
329
330                Ok(match len {
331                    Some(len) => Value::Text(left_chars(text, len)),
332                    None => Value::Null,
333                })
334            }
335            TextProjectionTransform::Right => {
336                let len = self.numeric_literal("length", self.literal.as_ref())?;
337
338                Ok(match len {
339                    Some(len) => Value::Text(right_chars(text, len)),
340                    None => Value::Null,
341                })
342            }
343            TextProjectionTransform::Substring => {
344                let start = self.numeric_literal("start", self.literal.as_ref())?;
345                let len = self.numeric_literal("length", self.literal2.as_ref())?;
346
347                Ok(match start {
348                    Some(start) => Value::Text(substring_1_based(text, start, len)),
349                    None => Value::Null,
350                })
351            }
352            _ => Err(QueryError::invariant(
353                "numeric text projection helper received a non-numeric transform",
354            )),
355        }
356    }
357
358    // Apply one nullable boolean text predicate after resolving the shared literal contract.
359    fn apply_binary_text_predicate(
360        &self,
361        text: &str,
362        predicate: impl FnOnce(&str, &str) -> bool,
363    ) -> Result<Value, QueryError> {
364        let literal = self.text_literal()?;
365
366        Ok(match literal {
367            Some(needle) => Value::Bool(predicate(text, needle)),
368            None => Value::Null,
369        })
370    }
371
372    // Apply one non-null text transform after the caller has already resolved
373    // the source value.
374    fn apply_non_null_text(&self, text: String) -> Result<Value, QueryError> {
375        match self.transform {
376            TextProjectionTransform::Field => Ok(Value::Text(text)),
377            TextProjectionTransform::Trim => Ok(Value::Text(text.trim().to_string())),
378            TextProjectionTransform::Ltrim => Ok(Value::Text(text.trim_start().to_string())),
379            TextProjectionTransform::Rtrim => Ok(Value::Text(text.trim_end().to_string())),
380            TextProjectionTransform::Lower => Ok(Value::Text(text.to_lowercase())),
381            TextProjectionTransform::Upper => Ok(Value::Text(text.to_uppercase())),
382            TextProjectionTransform::Length => {
383                let len = u64::try_from(text.chars().count()).unwrap_or(u64::MAX);
384
385                Ok(Value::Uint(len))
386            }
387            TextProjectionTransform::Left
388            | TextProjectionTransform::Right
389            | TextProjectionTransform::Substring => self.apply_numeric_text(text.as_str()),
390            TextProjectionTransform::StartsWith => self
391                .apply_binary_text_predicate(text.as_str(), |text, needle| {
392                    text.starts_with(needle)
393                }),
394            TextProjectionTransform::EndsWith => self
395                .apply_binary_text_predicate(text.as_str(), |text, needle| text.ends_with(needle)),
396            TextProjectionTransform::Contains => self
397                .apply_binary_text_predicate(text.as_str(), |text, needle| text.contains(needle)),
398            TextProjectionTransform::Position => {
399                let literal = self.text_literal()?;
400
401                Ok(match literal {
402                    Some(needle) => Value::Uint(text_position_1_based(text.as_str(), needle)),
403                    None => Value::Null,
404                })
405            }
406            TextProjectionTransform::Replace => {
407                let from = self.text_literal()?;
408                let to = self.second_text_literal()?;
409
410                Ok(match (from, to) {
411                    (Some(from), Some(to)) => Value::Text(text.replace(from, to)),
412                    _ => Value::Null,
413                })
414            }
415        }
416    }
417}
418
419/// Build `TRIM(field)`.
420#[must_use]
421pub fn trim(field: impl AsRef<str>) -> TextProjectionExpr {
422    TextProjectionExpr::new(field.as_ref().to_string(), TextProjectionTransform::Trim)
423}
424
425/// Build `LTRIM(field)`.
426#[must_use]
427pub fn ltrim(field: impl AsRef<str>) -> TextProjectionExpr {
428    TextProjectionExpr::new(field.as_ref().to_string(), TextProjectionTransform::Ltrim)
429}
430
431/// Build `RTRIM(field)`.
432#[must_use]
433pub fn rtrim(field: impl AsRef<str>) -> TextProjectionExpr {
434    TextProjectionExpr::new(field.as_ref().to_string(), TextProjectionTransform::Rtrim)
435}
436
437/// Build `LOWER(field)`.
438#[must_use]
439pub fn lower(field: impl AsRef<str>) -> TextProjectionExpr {
440    TextProjectionExpr::new(field.as_ref().to_string(), TextProjectionTransform::Lower)
441}
442
443/// Build `UPPER(field)`.
444#[must_use]
445pub fn upper(field: impl AsRef<str>) -> TextProjectionExpr {
446    TextProjectionExpr::new(field.as_ref().to_string(), TextProjectionTransform::Upper)
447}
448
449/// Build `LENGTH(field)`.
450#[must_use]
451pub fn length(field: impl AsRef<str>) -> TextProjectionExpr {
452    TextProjectionExpr::new(field.as_ref().to_string(), TextProjectionTransform::Length)
453}
454
455/// Build `LEFT(field, length)`.
456#[must_use]
457pub fn left(field: impl AsRef<str>, length: impl FieldValue) -> TextProjectionExpr {
458    TextProjectionExpr::with_literal(
459        field.as_ref().to_string(),
460        TextProjectionTransform::Left,
461        length,
462    )
463}
464
465/// Build `RIGHT(field, length)`.
466#[must_use]
467pub fn right(field: impl AsRef<str>, length: impl FieldValue) -> TextProjectionExpr {
468    TextProjectionExpr::with_literal(
469        field.as_ref().to_string(),
470        TextProjectionTransform::Right,
471        length,
472    )
473}
474
475/// Build `STARTS_WITH(field, literal)`.
476#[must_use]
477pub fn starts_with(field: impl AsRef<str>, literal: impl FieldValue) -> TextProjectionExpr {
478    TextProjectionExpr::with_literal(
479        field.as_ref().to_string(),
480        TextProjectionTransform::StartsWith,
481        literal,
482    )
483}
484
485/// Build `ENDS_WITH(field, literal)`.
486#[must_use]
487pub fn ends_with(field: impl AsRef<str>, literal: impl FieldValue) -> TextProjectionExpr {
488    TextProjectionExpr::with_literal(
489        field.as_ref().to_string(),
490        TextProjectionTransform::EndsWith,
491        literal,
492    )
493}
494
495/// Build `CONTAINS(field, literal)`.
496#[must_use]
497pub fn contains(field: impl AsRef<str>, literal: impl FieldValue) -> TextProjectionExpr {
498    TextProjectionExpr::with_literal(
499        field.as_ref().to_string(),
500        TextProjectionTransform::Contains,
501        literal,
502    )
503}
504
505/// Build `POSITION(literal, field)`.
506#[must_use]
507pub fn position(field: impl AsRef<str>, literal: impl FieldValue) -> TextProjectionExpr {
508    TextProjectionExpr::with_literal(
509        field.as_ref().to_string(),
510        TextProjectionTransform::Position,
511        literal,
512    )
513}
514
515/// Build `REPLACE(field, from, to)`.
516#[must_use]
517pub fn replace(
518    field: impl AsRef<str>,
519    from: impl FieldValue,
520    to: impl FieldValue,
521) -> TextProjectionExpr {
522    TextProjectionExpr::with_two_literals(
523        field.as_ref().to_string(),
524        TextProjectionTransform::Replace,
525        from,
526        to,
527    )
528}
529
530/// Build `SUBSTRING(field, start)`.
531#[must_use]
532pub fn substring(field: impl AsRef<str>, start: impl FieldValue) -> TextProjectionExpr {
533    TextProjectionExpr::with_literal(
534        field.as_ref().to_string(),
535        TextProjectionTransform::Substring,
536        start,
537    )
538}
539
540/// Build `SUBSTRING(field, start, length)`.
541#[must_use]
542pub fn substring_with_length(
543    field: impl AsRef<str>,
544    start: impl FieldValue,
545    length: impl FieldValue,
546) -> TextProjectionExpr {
547    TextProjectionExpr::with_two_literals(
548        field.as_ref().to_string(),
549        TextProjectionTransform::Substring,
550        start,
551        length,
552    )
553}
554
555// Render one projection literal back into a stable SQL-style label fragment.
556fn render_text_projection_literal(value: &Value) -> String {
557    match value {
558        Value::Null => "NULL".to_string(),
559        Value::Text(text) => format!("'{}'", text.replace('\'', "''")),
560        Value::Int(value) => value.to_string(),
561        Value::Uint(value) => value.to_string(),
562        _ => "<invalid-text-literal>".to_string(),
563    }
564}
565
566// Return the SQL-style one-based character position of `needle` in `haystack`.
567fn text_position_1_based(haystack: &str, needle: &str) -> u64 {
568    let Some(byte_index) = haystack.find(needle) else {
569        return 0;
570    };
571    let char_offset = haystack[..byte_index].chars().count();
572
573    u64::try_from(char_offset)
574        .unwrap_or(u64::MAX)
575        .saturating_add(1)
576}
577
578// Return the first `count` characters from `text` using character semantics.
579fn left_chars(text: &str, count: i64) -> String {
580    if count <= 0 {
581        return String::new();
582    }
583
584    text.chars()
585        .take(usize::try_from(count).unwrap_or(usize::MAX))
586        .collect()
587}
588
589// Return the last `count` characters from `text` using character semantics.
590fn right_chars(text: &str, count: i64) -> String {
591    if count <= 0 {
592        return String::new();
593    }
594
595    let count = usize::try_from(count).unwrap_or(usize::MAX);
596    let total = text.chars().count();
597    let skip = total.saturating_sub(count);
598
599    text.chars().skip(skip).collect()
600}
601
602// Apply the narrow SQL-style `SUBSTRING(text, start, len?)` contract using
603// 1-based character indexing.
604fn substring_1_based(text: &str, start: i64, len: Option<i64>) -> String {
605    if start <= 0 {
606        return String::new();
607    }
608    if matches!(len, Some(length) if length <= 0) {
609        return String::new();
610    }
611
612    let start_index = usize::try_from(start.saturating_sub(1)).unwrap_or(usize::MAX);
613    let chars = text.chars().skip(start_index);
614
615    match len {
616        Some(length) => chars
617            .take(usize::try_from(length).unwrap_or(usize::MAX))
618            .collect(),
619        None => chars.collect(),
620    }
621}
622
623///
624/// TESTS
625///
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    #[test]
632    fn lower_text_projection_renders_sql_label() {
633        assert_eq!(lower("name").sql_label(), "LOWER(name)");
634    }
635
636    #[test]
637    fn replace_text_projection_applies_shared_transform() {
638        let value = replace("name", "Ada", "Eve")
639            .apply_value(Value::Text("Ada Ada".to_string()))
640            .expect("replace projection should apply");
641
642        assert_eq!(value, Value::Text("Eve Eve".to_string()));
643    }
644}