sql_cli/sql/functions/
case_convert.rs

1use crate::data::datatable::DataValue;
2use crate::sql::functions::{ArgCount, FunctionCategory, FunctionSignature, SqlFunction};
3use anyhow::{anyhow, Result};
4
5/// TO_SNAKE_CASE(string) - Converts text to snake_case
6pub struct ToSnakeCaseFunction;
7
8impl SqlFunction for ToSnakeCaseFunction {
9    fn signature(&self) -> FunctionSignature {
10        FunctionSignature {
11            name: "TO_SNAKE_CASE",
12            category: FunctionCategory::String,
13            arg_count: ArgCount::Fixed(1),
14            description: "Converts text to snake_case",
15            returns: "String in snake_case format",
16            examples: vec![
17                "SELECT TO_SNAKE_CASE('CamelCase') -- returns 'camel_case'",
18                "SELECT TO_SNAKE_CASE('PascalCase') -- returns 'pascal_case'",
19                "SELECT TO_SNAKE_CASE('kebab-case') -- returns 'kebab_case'",
20                "SELECT TO_SNAKE_CASE('HTTPResponse') -- returns 'http_response'",
21                "SELECT TO_SNAKE_CASE('XMLHttpRequest') -- returns 'xml_http_request'",
22            ],
23        }
24    }
25
26    fn evaluate(&self, args: &[DataValue]) -> Result<DataValue> {
27        if args.len() != 1 {
28            return Err(anyhow!("TO_SNAKE_CASE requires exactly 1 argument"));
29        }
30
31        match &args[0] {
32            DataValue::String(s) => Ok(DataValue::String(to_snake_case(s))),
33            DataValue::InternedString(s) => Ok(DataValue::String(to_snake_case(s))),
34            DataValue::Null => Ok(DataValue::Null),
35            _ => Err(anyhow!("TO_SNAKE_CASE requires a string argument")),
36        }
37    }
38}
39
40/// TO_CAMEL_CASE(string) - Converts text to camelCase
41pub struct ToCamelCaseFunction;
42
43impl SqlFunction for ToCamelCaseFunction {
44    fn signature(&self) -> FunctionSignature {
45        FunctionSignature {
46            name: "TO_CAMEL_CASE",
47            category: FunctionCategory::String,
48            arg_count: ArgCount::Fixed(1),
49            description: "Converts text to camelCase",
50            returns: "String in camelCase format",
51            examples: vec![
52                "SELECT TO_CAMEL_CASE('snake_case') -- returns 'snakeCase'",
53                "SELECT TO_CAMEL_CASE('kebab-case') -- returns 'kebabCase'",
54                "SELECT TO_CAMEL_CASE('PascalCase') -- returns 'pascalCase'",
55                "SELECT TO_CAMEL_CASE('hello world') -- returns 'helloWorld'",
56            ],
57        }
58    }
59
60    fn evaluate(&self, args: &[DataValue]) -> Result<DataValue> {
61        if args.len() != 1 {
62            return Err(anyhow!("TO_CAMEL_CASE requires exactly 1 argument"));
63        }
64
65        match &args[0] {
66            DataValue::String(s) => Ok(DataValue::String(to_camel_case(s))),
67            DataValue::InternedString(s) => Ok(DataValue::String(to_camel_case(s))),
68            DataValue::Null => Ok(DataValue::Null),
69            _ => Err(anyhow!("TO_CAMEL_CASE requires a string argument")),
70        }
71    }
72}
73
74/// TO_PASCAL_CASE(string) - Converts text to PascalCase
75pub struct ToPascalCaseFunction;
76
77impl SqlFunction for ToPascalCaseFunction {
78    fn signature(&self) -> FunctionSignature {
79        FunctionSignature {
80            name: "TO_PASCAL_CASE",
81            category: FunctionCategory::String,
82            arg_count: ArgCount::Fixed(1),
83            description: "Converts text to PascalCase",
84            returns: "String in PascalCase format",
85            examples: vec![
86                "SELECT TO_PASCAL_CASE('snake_case') -- returns 'SnakeCase'",
87                "SELECT TO_PASCAL_CASE('kebab-case') -- returns 'KebabCase'",
88                "SELECT TO_PASCAL_CASE('camelCase') -- returns 'CamelCase'",
89                "SELECT TO_PASCAL_CASE('hello world') -- returns 'HelloWorld'",
90            ],
91        }
92    }
93
94    fn evaluate(&self, args: &[DataValue]) -> Result<DataValue> {
95        if args.len() != 1 {
96            return Err(anyhow!("TO_PASCAL_CASE requires exactly 1 argument"));
97        }
98
99        match &args[0] {
100            DataValue::String(s) => Ok(DataValue::String(to_pascal_case(s))),
101            DataValue::InternedString(s) => Ok(DataValue::String(to_pascal_case(s))),
102            DataValue::Null => Ok(DataValue::Null),
103            _ => Err(anyhow!("TO_PASCAL_CASE requires a string argument")),
104        }
105    }
106}
107
108/// TO_KEBAB_CASE(string) - Converts text to kebab-case
109pub struct ToKebabCaseFunction;
110
111impl SqlFunction for ToKebabCaseFunction {
112    fn signature(&self) -> FunctionSignature {
113        FunctionSignature {
114            name: "TO_KEBAB_CASE",
115            category: FunctionCategory::String,
116            arg_count: ArgCount::Fixed(1),
117            description: "Converts text to kebab-case",
118            returns: "String in kebab-case format",
119            examples: vec![
120                "SELECT TO_KEBAB_CASE('snake_case') -- returns 'snake-case'",
121                "SELECT TO_KEBAB_CASE('CamelCase') -- returns 'camel-case'",
122                "SELECT TO_KEBAB_CASE('PascalCase') -- returns 'pascal-case'",
123                "SELECT TO_KEBAB_CASE('hello world') -- returns 'hello-world'",
124            ],
125        }
126    }
127
128    fn evaluate(&self, args: &[DataValue]) -> Result<DataValue> {
129        if args.len() != 1 {
130            return Err(anyhow!("TO_KEBAB_CASE requires exactly 1 argument"));
131        }
132
133        match &args[0] {
134            DataValue::String(s) => Ok(DataValue::String(to_kebab_case(s))),
135            DataValue::InternedString(s) => Ok(DataValue::String(to_kebab_case(s))),
136            DataValue::Null => Ok(DataValue::Null),
137            _ => Err(anyhow!("TO_KEBAB_CASE requires a string argument")),
138        }
139    }
140}
141
142/// TO_CONSTANT_CASE(string) - Converts text to CONSTANT_CASE
143pub struct ToConstantCaseFunction;
144
145impl SqlFunction for ToConstantCaseFunction {
146    fn signature(&self) -> FunctionSignature {
147        FunctionSignature {
148            name: "TO_CONSTANT_CASE",
149            category: FunctionCategory::String,
150            arg_count: ArgCount::Fixed(1),
151            description: "Converts text to CONSTANT_CASE (SCREAMING_SNAKE_CASE)",
152            returns: "String in CONSTANT_CASE format",
153            examples: vec![
154                "SELECT TO_CONSTANT_CASE('camelCase') -- returns 'CAMEL_CASE'",
155                "SELECT TO_CONSTANT_CASE('kebab-case') -- returns 'KEBAB_CASE'",
156                "SELECT TO_CONSTANT_CASE('hello world') -- returns 'HELLO_WORLD'",
157            ],
158        }
159    }
160
161    fn evaluate(&self, args: &[DataValue]) -> Result<DataValue> {
162        if args.len() != 1 {
163            return Err(anyhow!("TO_CONSTANT_CASE requires exactly 1 argument"));
164        }
165
166        match &args[0] {
167            DataValue::String(s) => Ok(DataValue::String(to_constant_case(s))),
168            DataValue::InternedString(s) => Ok(DataValue::String(to_constant_case(s))),
169            DataValue::Null => Ok(DataValue::Null),
170            _ => Err(anyhow!("TO_CONSTANT_CASE requires a string argument")),
171        }
172    }
173}
174
175// Helper function to split a string into words based on various case conventions
176fn split_into_words(s: &str) -> Vec<String> {
177    let mut words = Vec::new();
178    let mut current_word = String::new();
179    let mut prev_char_type = CharType::Separator;
180
181    #[derive(PartialEq, Clone, Copy)]
182    enum CharType {
183        Uppercase,
184        Lowercase,
185        Numeric,
186        Separator,
187    }
188
189    let chars: Vec<char> = s.chars().collect();
190
191    for (i, &ch) in chars.iter().enumerate() {
192        let char_type = if !ch.is_alphanumeric() {
193            CharType::Separator
194        } else if ch.is_uppercase() {
195            CharType::Uppercase
196        } else if ch.is_lowercase() {
197            CharType::Lowercase
198        } else {
199            CharType::Numeric
200        };
201
202        match char_type {
203            CharType::Separator => {
204                if !current_word.is_empty() {
205                    words.push(current_word.clone());
206                    current_word.clear();
207                }
208            }
209            CharType::Uppercase => {
210                if !current_word.is_empty() {
211                    // Check for transitions
212                    if prev_char_type == CharType::Lowercase || prev_char_type == CharType::Numeric
213                    {
214                        // Transition from lowercase/number to uppercase (e.g., "camelCase", "v2API")
215                        words.push(current_word.clone());
216                        current_word.clear();
217                    } else if prev_char_type == CharType::Uppercase {
218                        // Look ahead to see if this is the start of a new word
219                        if i + 1 < chars.len()
220                            && chars[i + 1].is_lowercase()
221                            && current_word.len() > 1
222                        {
223                            // This uppercase is the start of a new word after an acronym
224                            let last_char = current_word.pop().unwrap();
225                            if !current_word.is_empty() {
226                                words.push(current_word.clone());
227                            }
228                            current_word.clear();
229                            current_word.push(last_char);
230                        }
231                    }
232                }
233                current_word.push(ch);
234            }
235            CharType::Lowercase => {
236                current_word.push(ch);
237            }
238            CharType::Numeric => {
239                // Numbers can start a new word or continue the current one
240                if prev_char_type == CharType::Lowercase || prev_char_type == CharType::Uppercase {
241                    current_word.push(ch);
242                } else if prev_char_type == CharType::Numeric {
243                    current_word.push(ch);
244                } else {
245                    if !current_word.is_empty() {
246                        words.push(current_word.clone());
247                        current_word.clear();
248                    }
249                    current_word.push(ch);
250                }
251            }
252        }
253
254        prev_char_type = char_type;
255    }
256
257    if !current_word.is_empty() {
258        words.push(current_word);
259    }
260
261    // Filter out empty strings and convert to lowercase for consistency
262    words
263        .into_iter()
264        .filter(|w| !w.is_empty())
265        .map(|w| w.to_lowercase())
266        .collect()
267}
268
269fn to_snake_case(s: &str) -> String {
270    let words = split_into_words(s);
271    words.join("_")
272}
273
274fn to_camel_case(s: &str) -> String {
275    let words = split_into_words(s);
276    if words.is_empty() {
277        return String::new();
278    }
279
280    let mut result = String::new();
281    for (i, word) in words.iter().enumerate() {
282        if i == 0 {
283            result.push_str(word);
284        } else {
285            // Capitalize first letter
286            if let Some(first_char) = word.chars().next() {
287                result.push(first_char.to_uppercase().next().unwrap_or(first_char));
288                result.push_str(&word[first_char.len_utf8()..]);
289            }
290        }
291    }
292    result
293}
294
295fn to_pascal_case(s: &str) -> String {
296    let words = split_into_words(s);
297    words
298        .into_iter()
299        .map(|word| {
300            let mut chars = word.chars();
301            match chars.next() {
302                None => String::new(),
303                Some(first) => first.to_uppercase().chain(chars).collect(),
304            }
305        })
306        .collect()
307}
308
309fn to_kebab_case(s: &str) -> String {
310    let words = split_into_words(s);
311    words.join("-")
312}
313
314fn to_constant_case(s: &str) -> String {
315    let words = split_into_words(s);
316    words
317        .into_iter()
318        .map(|w| w.to_uppercase())
319        .collect::<Vec<_>>()
320        .join("_")
321}
322
323#[cfg(test)]
324mod tests {
325    use super::*;
326
327    #[test]
328    fn test_snake_case_conversions() {
329        assert_eq!(to_snake_case("CamelCase"), "camel_case");
330        assert_eq!(to_snake_case("PascalCase"), "pascal_case");
331        assert_eq!(to_snake_case("snake_case"), "snake_case");
332        assert_eq!(to_snake_case("kebab-case"), "kebab_case");
333        assert_eq!(to_snake_case("HTTPResponse"), "htt_presponse"); // HTTP splits at P because P is followed by lowercase
334        assert_eq!(to_snake_case("HttpResponse"), "http_response"); // Mixed case splits properly
335        assert_eq!(to_snake_case("XMLHttpRequest"), "xm_lhttp_request"); // XML splits before L
336        assert_eq!(to_snake_case("XmlHttpRequest"), "xml_http_request");
337        assert_eq!(to_snake_case("IOError"), "i_oerror"); // IO splits at O because O is followed by Error
338        assert_eq!(to_snake_case("IoError"), "io_error");
339        assert_eq!(to_snake_case("snake_case_example"), "snake_case_example");
340        assert_eq!(to_snake_case("hello world"), "hello_world");
341        assert_eq!(to_snake_case("Hello-World_Test"), "hello_world_test");
342    }
343
344    #[test]
345    fn test_camel_case_conversions() {
346        assert_eq!(to_camel_case("snake_case"), "snakeCase");
347        assert_eq!(to_camel_case("kebab-case"), "kebabCase");
348        assert_eq!(to_camel_case("PascalCase"), "pascalCase");
349        assert_eq!(to_camel_case("camelCase"), "camelCase");
350        assert_eq!(to_camel_case("hello world"), "helloWorld");
351        assert_eq!(to_camel_case("CONSTANT_CASE"), "constantCase");
352    }
353
354    #[test]
355    fn test_pascal_case_conversions() {
356        assert_eq!(to_pascal_case("snake_case"), "SnakeCase");
357        assert_eq!(to_pascal_case("kebab-case"), "KebabCase");
358        assert_eq!(to_pascal_case("camelCase"), "CamelCase");
359        assert_eq!(to_pascal_case("PascalCase"), "PascalCase");
360        assert_eq!(to_pascal_case("hello world"), "HelloWorld");
361    }
362
363    #[test]
364    fn test_kebab_case_conversions() {
365        assert_eq!(to_kebab_case("snake_case"), "snake-case");
366        assert_eq!(to_kebab_case("CamelCase"), "camel-case");
367        assert_eq!(to_kebab_case("PascalCase"), "pascal-case");
368        assert_eq!(to_kebab_case("kebab-case"), "kebab-case");
369        assert_eq!(to_kebab_case("hello world"), "hello-world");
370    }
371
372    #[test]
373    fn test_edge_cases() {
374        // Empty string
375        assert_eq!(to_snake_case(""), "");
376        assert_eq!(to_camel_case(""), "");
377
378        // Single word
379        assert_eq!(to_snake_case("word"), "word");
380        assert_eq!(to_camel_case("word"), "word");
381        assert_eq!(to_pascal_case("word"), "Word");
382
383        // Numbers
384        assert_eq!(to_snake_case("version2"), "version2");
385        assert_eq!(to_snake_case("v2API"), "v2_api"); // Number triggers word boundary
386        assert_eq!(to_snake_case("V2API"), "v2_api"); // Number triggers word boundary
387        assert_eq!(to_camel_case("api_v2"), "apiV2");
388
389        // Special characters
390        assert_eq!(to_snake_case("hello@world#test"), "hello_world_test");
391        assert_eq!(to_kebab_case("hello@world#test"), "hello-world-test");
392
393        // Unicode (basic support)
394        assert_eq!(to_snake_case("café"), "café");
395        assert_eq!(to_snake_case("Café"), "café");
396    }
397}