datafusion-cli/src/helper.rs (262 lines of code) (raw):
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
//! Helper that helps with interactive editing, including multi-line parsing and validation,
//! and auto-completion for file name during creating external table.
use std::borrow::Cow;
use crate::highlighter::{NoSyntaxHighlighter, SyntaxHighlighter};
use datafusion::sql::parser::{DFParser, Statement};
use datafusion::sql::sqlparser::dialect::dialect_from_str;
use rustyline::completion::{Completer, FilenameCompleter, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::{CmdKind, Highlighter};
use rustyline::hint::Hinter;
use rustyline::validate::{ValidationContext, ValidationResult, Validator};
use rustyline::{Context, Helper, Result};
pub struct CliHelper {
completer: FilenameCompleter,
dialect: String,
highlighter: Box<dyn Highlighter>,
}
impl CliHelper {
pub fn new(dialect: &str, color: bool) -> Self {
let highlighter: Box<dyn Highlighter> = if !color {
Box::new(NoSyntaxHighlighter {})
} else {
Box::new(SyntaxHighlighter::new(dialect))
};
Self {
completer: FilenameCompleter::new(),
dialect: dialect.into(),
highlighter,
}
}
pub fn set_dialect(&mut self, dialect: &str) {
if dialect != self.dialect {
self.dialect = dialect.to_string();
}
}
fn validate_input(&self, input: &str) -> Result<ValidationResult> {
if let Some(sql) = input.strip_suffix(';') {
let dialect = match dialect_from_str(&self.dialect) {
Some(dialect) => dialect,
None => {
return Ok(ValidationResult::Invalid(Some(format!(
" 🤔 Invalid dialect: {}",
self.dialect
))))
}
};
let lines = split_from_semicolon(sql);
for line in lines {
match DFParser::parse_sql_with_dialect(&line, dialect.as_ref()) {
Ok(statements) if statements.is_empty() => {
return Ok(ValidationResult::Invalid(Some(
" 🤔 You entered an empty statement".to_string(),
)));
}
Ok(_statements) => {}
Err(err) => {
return Ok(ValidationResult::Invalid(Some(format!(
" 🤔 Invalid statement: {err}",
))));
}
}
}
Ok(ValidationResult::Valid(None))
} else if input.starts_with('\\') {
// command
Ok(ValidationResult::Valid(None))
} else {
Ok(ValidationResult::Incomplete)
}
}
}
impl Default for CliHelper {
fn default() -> Self {
Self::new("generic", false)
}
}
impl Highlighter for CliHelper {
fn highlight<'l>(&self, line: &'l str, pos: usize) -> Cow<'l, str> {
self.highlighter.highlight(line, pos)
}
fn highlight_char(&self, line: &str, pos: usize, kind: CmdKind) -> bool {
self.highlighter.highlight_char(line, pos, kind)
}
}
impl Hinter for CliHelper {
type Hint = String;
}
/// returns true if the current position is after the open quote for
/// creating an external table.
fn is_open_quote_for_location(line: &str, pos: usize) -> bool {
let mut sql = line[..pos].to_string();
sql.push('\'');
if let Ok(stmts) = DFParser::parse_sql(&sql) {
if let Some(Statement::CreateExternalTable(_)) = stmts.back() {
return true;
}
}
false
}
impl Completer for CliHelper {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
ctx: &Context<'_>,
) -> std::result::Result<(usize, Vec<Pair>), ReadlineError> {
if is_open_quote_for_location(line, pos) {
self.completer.complete(line, pos, ctx)
} else {
Ok((0, Vec::with_capacity(0)))
}
}
}
impl Validator for CliHelper {
fn validate(&self, ctx: &mut ValidationContext<'_>) -> Result<ValidationResult> {
let input = ctx.input().trim_end();
self.validate_input(input)
}
}
impl Helper for CliHelper {}
/// Splits a string which consists of multiple queries.
pub(crate) fn split_from_semicolon(sql: &str) -> Vec<String> {
let mut commands = Vec::new();
let mut current_command = String::new();
let mut in_single_quote = false;
let mut in_double_quote = false;
for c in sql.chars() {
if c == '\'' && !in_double_quote {
in_single_quote = !in_single_quote;
} else if c == '"' && !in_single_quote {
in_double_quote = !in_double_quote;
}
if c == ';' && !in_single_quote && !in_double_quote {
if !current_command.trim().is_empty() {
commands.push(format!("{};", current_command.trim()));
current_command.clear();
}
} else {
current_command.push(c);
}
}
if !current_command.trim().is_empty() {
commands.push(format!("{};", current_command.trim()));
}
commands
}
#[cfg(test)]
mod tests {
use std::io::{BufRead, Cursor};
use super::*;
fn readline_direct(
mut reader: impl BufRead,
validator: &CliHelper,
) -> Result<ValidationResult> {
let mut input = String::new();
if reader.read_line(&mut input)? == 0 {
return Err(ReadlineError::Eof);
}
validator.validate_input(&input)
}
#[test]
fn unescape_readline_input() -> Result<()> {
let validator = CliHelper::default();
// should be valid
let result = readline_direct(
Cursor::new(
r"create external table test stored as csv location 'data.csv' options ('format.delimiter' ',');"
.as_bytes(),
),
&validator,
)?;
assert!(matches!(result, ValidationResult::Valid(None)));
let result = readline_direct(
Cursor::new(
r"create external table test stored as csv location 'data.csv' options ('format.delimiter' '\0');"
.as_bytes()),
&validator,
)?;
assert!(matches!(result, ValidationResult::Valid(None)));
let result = readline_direct(
Cursor::new(
r"create external table test stored as csv location 'data.csv' options ('format.delimiter' '\n');"
.as_bytes()),
&validator,
)?;
assert!(matches!(result, ValidationResult::Valid(None)));
let result = readline_direct(
Cursor::new(
r"create external table test stored as csv location 'data.csv' options ('format.delimiter' '\r');"
.as_bytes()),
&validator,
)?;
assert!(matches!(result, ValidationResult::Valid(None)));
let result = readline_direct(
Cursor::new(
r"create external table test stored as csv location 'data.csv' options ('format.delimiter' '\t');"
.as_bytes()),
&validator,
)?;
assert!(matches!(result, ValidationResult::Valid(None)));
let result = readline_direct(
Cursor::new(
r"create external table test stored as csv location 'data.csv' options ('format.delimiter' '\\');"
.as_bytes()),
&validator,
)?;
assert!(matches!(result, ValidationResult::Valid(None)));
let result = readline_direct(
Cursor::new(
r"create external table test stored as csv location 'data.csv' options ('format.delimiter' ',,');"
.as_bytes()),
&validator,
)?;
assert!(matches!(result, ValidationResult::Valid(None)));
let result = readline_direct(
Cursor::new(
r"select '\', '\\', '\\\\\', 'dsdsds\\\\', '\t', '\0', '\n';".as_bytes(),
),
&validator,
)?;
assert!(matches!(result, ValidationResult::Valid(None)));
Ok(())
}
#[test]
fn sql_dialect() -> Result<()> {
let mut validator = CliHelper::default();
// should be invalid in generic dialect
let result =
readline_direct(Cursor::new(r"select 1 # 2;".as_bytes()), &validator)?;
assert!(
matches!(result, ValidationResult::Invalid(Some(e)) if e.contains("Invalid statement"))
);
// valid in postgresql dialect
validator.set_dialect("postgresql");
let result =
readline_direct(Cursor::new(r"select 1 # 2;".as_bytes()), &validator)?;
assert!(matches!(result, ValidationResult::Valid(None)));
Ok(())
}
#[test]
fn test_split_from_semicolon() {
let sql = "SELECT 1; SELECT 2;";
let expected = vec!["SELECT 1;", "SELECT 2;"];
assert_eq!(split_from_semicolon(sql), expected);
let sql = r#"SELECT ";";"#;
let expected = vec![r#"SELECT ";";"#];
assert_eq!(split_from_semicolon(sql), expected);
let sql = "SELECT ';';";
let expected = vec!["SELECT ';';"];
assert_eq!(split_from_semicolon(sql), expected);
let sql = r#"SELECT 1; SELECT 'value;value'; SELECT 1 as "text;text";"#;
let expected = vec![
"SELECT 1;",
"SELECT 'value;value';",
r#"SELECT 1 as "text;text";"#,
];
assert_eq!(split_from_semicolon(sql), expected);
let sql = "";
let expected: Vec<String> = Vec::new();
assert_eq!(split_from_semicolon(sql), expected);
let sql = "SELECT 1";
let expected = vec!["SELECT 1;"];
assert_eq!(split_from_semicolon(sql), expected);
let sql = "SELECT 1; ";
let expected = vec!["SELECT 1;"];
assert_eq!(split_from_semicolon(sql), expected);
}
}