1use crate::{db::QueryError, traits::FieldValue, value::Value};
9
10#[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 #[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#[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 #[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 #[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 #[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 #[must_use]
127 pub const fn field(&self) -> &str {
128 self.field.as_str()
129 }
130
131 #[must_use]
133 pub const fn transform(&self) -> TextProjectionTransform {
134 self.transform
135 }
136
137 #[must_use]
139 pub const fn literal(&self) -> Option<&Value> {
140 self.literal.as_ref()
141 }
142
143 #[must_use]
145 pub const fn literal2(&self) -> Option<&Value> {
146 self.literal2.as_ref()
147 }
148
149 #[must_use]
151 pub const fn literal3(&self) -> Option<&Value> {
152 self.literal3.as_ref()
153 }
154
155 #[must_use]
157 pub fn with_optional_literal(mut self, literal: Option<Value>) -> Self {
158 self.literal = literal;
159 self
160 }
161
162 #[must_use]
164 pub fn with_optional_second_literal(mut self, literal: Option<Value>) -> Self {
165 self.literal2 = literal;
166 self
167 }
168
169 #[must_use]
171 pub fn with_optional_third_literal(mut self, literal: Option<Value>) -> Self {
172 self.literal3 = literal;
173 self
174 }
175
176 #[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 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 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 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 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 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 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 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 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#[must_use]
421pub fn trim(field: impl AsRef<str>) -> TextProjectionExpr {
422 TextProjectionExpr::new(field.as_ref().to_string(), TextProjectionTransform::Trim)
423}
424
425#[must_use]
427pub fn ltrim(field: impl AsRef<str>) -> TextProjectionExpr {
428 TextProjectionExpr::new(field.as_ref().to_string(), TextProjectionTransform::Ltrim)
429}
430
431#[must_use]
433pub fn rtrim(field: impl AsRef<str>) -> TextProjectionExpr {
434 TextProjectionExpr::new(field.as_ref().to_string(), TextProjectionTransform::Rtrim)
435}
436
437#[must_use]
439pub fn lower(field: impl AsRef<str>) -> TextProjectionExpr {
440 TextProjectionExpr::new(field.as_ref().to_string(), TextProjectionTransform::Lower)
441}
442
443#[must_use]
445pub fn upper(field: impl AsRef<str>) -> TextProjectionExpr {
446 TextProjectionExpr::new(field.as_ref().to_string(), TextProjectionTransform::Upper)
447}
448
449#[must_use]
451pub fn length(field: impl AsRef<str>) -> TextProjectionExpr {
452 TextProjectionExpr::new(field.as_ref().to_string(), TextProjectionTransform::Length)
453}
454
455#[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#[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#[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#[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#[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#[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#[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#[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#[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
555fn 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
566fn 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
578fn 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
589fn 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
602fn 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#[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}