Skip to main content

datafusion_cli/
helper.rs

1// Licensed to the Apache Software Foundation (ASF) under one
2// or more contributor license agreements.  See the NOTICE file
3// distributed with this work for additional information
4// regarding copyright ownership.  The ASF licenses this file
5// to you under the Apache License, Version 2.0 (the
6// "License"); you may not use this file except in compliance
7// with the License.  You may obtain a copy of the License at
8//
9//   http://www.apache.org/licenses/LICENSE-2.0
10//
11// Unless required by applicable law or agreed to in writing,
12// software distributed under the License is distributed on an
13// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
14// KIND, either express or implied.  See the License for the
15// specific language governing permissions and limitations
16// under the License.
17
18//! Helper that helps with interactive editing, including multi-line parsing and validation,
19//! and auto-completion for file name during creating external table.
20
21use std::borrow::Cow;
22use std::cell::Cell;
23
24use crate::highlighter::{Color, NoSyntaxHighlighter, SyntaxHighlighter};
25
26use datafusion::sql::parser::{DFParser, Statement};
27use datafusion::sql::sqlparser::dialect::dialect_from_str;
28use datafusion_common::config::Dialect;
29
30use rustyline::completion::{Completer, FilenameCompleter, Pair};
31use rustyline::error::ReadlineError;
32use rustyline::highlight::{CmdKind, Highlighter};
33use rustyline::hint::Hinter;
34use rustyline::validate::{ValidationContext, ValidationResult, Validator};
35use rustyline::{Context, Helper, Result};
36
37/// Default suggestion shown when the input line is empty.
38const DEFAULT_HINT_SUGGESTION: &str = " \\? for help, \\q to quit";
39
40pub struct CliHelper {
41    completer: FilenameCompleter,
42    dialect: Dialect,
43    highlighter: Box<dyn Highlighter>,
44    /// Tracks whether to show the default hint. Set to `false` once the user
45    /// types anything, so the hint doesn't reappear after deleting back to
46    /// an empty line. Reset to `true` when the line is submitted.
47    show_hint: Cell<bool>,
48}
49
50impl CliHelper {
51    pub fn new(dialect: &Dialect, color: bool) -> Self {
52        let highlighter: Box<dyn Highlighter> = if !color {
53            Box::new(NoSyntaxHighlighter {})
54        } else {
55            Box::new(SyntaxHighlighter::new(dialect))
56        };
57        Self {
58            completer: FilenameCompleter::new(),
59            dialect: *dialect,
60            highlighter,
61            show_hint: Cell::new(true),
62        }
63    }
64
65    pub fn set_dialect(&mut self, dialect: &Dialect) {
66        if *dialect != self.dialect {
67            self.dialect = *dialect;
68        }
69    }
70
71    /// Re-enable the default hint for the next prompt.
72    pub fn reset_hint(&self) {
73        self.show_hint.set(true);
74    }
75
76    fn validate_input(&self, input: &str) -> Result<ValidationResult> {
77        if let Some(sql) = input.strip_suffix(';') {
78            let dialect = match dialect_from_str(self.dialect) {
79                Some(dialect) => dialect,
80                None => {
81                    return Ok(ValidationResult::Invalid(Some(format!(
82                        "  🤔 Invalid dialect: {}",
83                        self.dialect
84                    ))));
85                }
86            };
87            let lines = split_from_semicolon(sql);
88            for line in lines {
89                match DFParser::parse_sql_with_dialect(&line, dialect.as_ref()) {
90                    Ok(statements) if statements.is_empty() => {
91                        return Ok(ValidationResult::Invalid(Some(
92                            "  🤔 You entered an empty statement".to_string(),
93                        )));
94                    }
95                    Ok(_statements) => {}
96                    Err(err) => {
97                        return Ok(ValidationResult::Invalid(Some(format!(
98                            "  🤔 Invalid statement: {err}",
99                        ))));
100                    }
101                }
102            }
103            Ok(ValidationResult::Valid(None))
104        } else if input.starts_with('\\') {
105            // command
106            Ok(ValidationResult::Valid(None))
107        } else {
108            Ok(ValidationResult::Incomplete)
109        }
110    }
111}
112
113impl Default for CliHelper {
114    fn default() -> Self {
115        Self::new(&Dialect::Generic, false)
116    }
117}
118
119impl Highlighter for CliHelper {
120    fn highlight<'l>(&self, line: &'l str, pos: usize) -> Cow<'l, str> {
121        self.highlighter.highlight(line, pos)
122    }
123
124    fn highlight_char(&self, line: &str, pos: usize, kind: CmdKind) -> bool {
125        self.highlighter.highlight_char(line, pos, kind)
126    }
127
128    fn highlight_hint<'h>(&self, hint: &'h str) -> Cow<'h, str> {
129        Color::gray(hint).into()
130    }
131}
132
133impl Hinter for CliHelper {
134    type Hint = String;
135
136    fn hint(&self, line: &str, _pos: usize, _ctx: &Context<'_>) -> Option<String> {
137        if !line.is_empty() {
138            self.show_hint.set(false);
139        }
140        (self.show_hint.get() && line.trim().is_empty())
141            .then(|| DEFAULT_HINT_SUGGESTION.to_owned())
142    }
143}
144
145/// returns true if the current position is after the open quote for
146/// creating an external table.
147fn is_open_quote_for_location(line: &str, pos: usize) -> bool {
148    let mut sql = line[..pos].to_string();
149    sql.push('\'');
150    DFParser::parse_sql(&sql).is_ok_and(|stmts| {
151        matches!(stmts.back(), Some(Statement::CreateExternalTable(_)))
152    })
153}
154
155impl Completer for CliHelper {
156    type Candidate = Pair;
157
158    fn complete(
159        &self,
160        line: &str,
161        pos: usize,
162        ctx: &Context<'_>,
163    ) -> std::result::Result<(usize, Vec<Pair>), ReadlineError> {
164        if is_open_quote_for_location(line, pos) {
165            self.completer.complete(line, pos, ctx)
166        } else {
167            Ok((0, Vec::with_capacity(0)))
168        }
169    }
170}
171
172impl Validator for CliHelper {
173    fn validate(&self, ctx: &mut ValidationContext<'_>) -> Result<ValidationResult> {
174        let input = ctx.input().trim_end();
175        let result = self.validate_input(input);
176        self.reset_hint();
177        result
178    }
179}
180
181impl Helper for CliHelper {}
182
183/// Splits a string which consists of multiple queries.
184pub(crate) fn split_from_semicolon(sql: &str) -> Vec<String> {
185    let mut commands = Vec::new();
186    let mut current_command = String::new();
187    let mut in_single_quote = false;
188    let mut in_double_quote = false;
189
190    for c in sql.chars() {
191        if c == '\'' && !in_double_quote {
192            in_single_quote = !in_single_quote;
193        } else if c == '"' && !in_single_quote {
194            in_double_quote = !in_double_quote;
195        }
196
197        if c == ';' && !in_single_quote && !in_double_quote {
198            if !current_command.trim().is_empty() {
199                commands.push(format!("{};", current_command.trim()));
200                current_command.clear();
201            }
202        } else {
203            current_command.push(c);
204        }
205    }
206
207    if !current_command.trim().is_empty() {
208        commands.push(format!("{};", current_command.trim()));
209    }
210
211    commands
212}
213
214#[cfg(test)]
215mod tests {
216    use std::io::{BufRead, Cursor};
217
218    use super::*;
219
220    fn readline_direct(
221        mut reader: impl BufRead,
222        validator: &CliHelper,
223    ) -> Result<ValidationResult> {
224        let mut input = String::new();
225
226        if reader.read_line(&mut input)? == 0 {
227            return Err(ReadlineError::Eof);
228        }
229
230        validator.validate_input(&input)
231    }
232
233    #[test]
234    fn unescape_readline_input() -> Result<()> {
235        let validator = CliHelper::default();
236
237        // should be valid
238        let result = readline_direct(
239             Cursor::new(
240                 r"create external table test stored as csv location 'data.csv' options ('format.delimiter' ',');"
241                     .as_bytes(),
242             ),
243             &validator,
244         )?;
245        assert!(matches!(result, ValidationResult::Valid(None)));
246
247        let result = readline_direct(
248             Cursor::new(
249                 r"create external table test stored as csv location 'data.csv' options ('format.delimiter' '\0');"
250                     .as_bytes()),
251             &validator,
252         )?;
253        assert!(matches!(result, ValidationResult::Valid(None)));
254
255        let result = readline_direct(
256             Cursor::new(
257                 r"create external table test stored as csv location 'data.csv' options ('format.delimiter' '\n');"
258                     .as_bytes()),
259             &validator,
260         )?;
261        assert!(matches!(result, ValidationResult::Valid(None)));
262
263        let result = readline_direct(
264             Cursor::new(
265                 r"create external table test stored as csv location 'data.csv' options ('format.delimiter' '\r');"
266                     .as_bytes()),
267             &validator,
268         )?;
269        assert!(matches!(result, ValidationResult::Valid(None)));
270
271        let result = readline_direct(
272             Cursor::new(
273                 r"create external table test stored as csv location 'data.csv' options ('format.delimiter' '\t');"
274                     .as_bytes()),
275             &validator,
276         )?;
277        assert!(matches!(result, ValidationResult::Valid(None)));
278
279        let result = readline_direct(
280             Cursor::new(
281                 r"create external table test stored as csv location 'data.csv' options ('format.delimiter' '\\');"
282                     .as_bytes()),
283             &validator,
284         )?;
285        assert!(matches!(result, ValidationResult::Valid(None)));
286
287        let result = readline_direct(
288             Cursor::new(
289                 r"create external table test stored as csv location 'data.csv' options ('format.delimiter' ',,');"
290                     .as_bytes()),
291             &validator,
292         )?;
293        assert!(matches!(result, ValidationResult::Valid(None)));
294
295        let result = readline_direct(
296            Cursor::new(
297                r"select '\', '\\', '\\\\\', 'dsdsds\\\\', '\t', '\0', '\n';".as_bytes(),
298            ),
299            &validator,
300        )?;
301        assert!(matches!(result, ValidationResult::Valid(None)));
302
303        Ok(())
304    }
305
306    #[test]
307    fn sql_dialect() -> Result<()> {
308        let mut validator = CliHelper::default();
309
310        // should be invalid in generic dialect
311        let result =
312            readline_direct(Cursor::new(r"select 1 # 2;".as_bytes()), &validator)?;
313        assert!(
314            matches!(result, ValidationResult::Invalid(Some(e)) if e.contains("Invalid statement"))
315        );
316
317        // valid in postgresql dialect
318        validator.set_dialect(&Dialect::PostgreSQL);
319        let result =
320            readline_direct(Cursor::new(r"select 1 # 2;".as_bytes()), &validator)?;
321        assert!(matches!(result, ValidationResult::Valid(None)));
322
323        Ok(())
324    }
325
326    #[test]
327    fn test_split_from_semicolon() {
328        let sql = "SELECT 1; SELECT 2;";
329        let expected = vec!["SELECT 1;", "SELECT 2;"];
330        assert_eq!(split_from_semicolon(sql), expected);
331
332        let sql = r#"SELECT ";";"#;
333        let expected = vec![r#"SELECT ";";"#];
334        assert_eq!(split_from_semicolon(sql), expected);
335
336        let sql = "SELECT ';';";
337        let expected = vec!["SELECT ';';"];
338        assert_eq!(split_from_semicolon(sql), expected);
339
340        let sql = r#"SELECT 1; SELECT 'value;value'; SELECT 1 as "text;text";"#;
341        let expected = vec![
342            "SELECT 1;",
343            "SELECT 'value;value';",
344            r#"SELECT 1 as "text;text";"#,
345        ];
346        assert_eq!(split_from_semicolon(sql), expected);
347
348        let sql = "";
349        let expected: Vec<String> = Vec::new();
350        assert_eq!(split_from_semicolon(sql), expected);
351
352        let sql = "SELECT 1";
353        let expected = vec!["SELECT 1;"];
354        assert_eq!(split_from_semicolon(sql), expected);
355
356        let sql = "SELECT 1;   ";
357        let expected = vec!["SELECT 1;"];
358        assert_eq!(split_from_semicolon(sql), expected);
359    }
360}