diff --git a/parse-test/postgres-tests.json b/parse-test/postgres-tests.json index 6f698046..da9e5a50 100644 --- a/parse-test/postgres-tests.json +++ b/parse-test/postgres-tests.json @@ -3651,12 +3651,8 @@ }, { "input": "SELECT \"alias\".\"bar baz\", \"myfun\"(), \"simple id\" AS \"column alias\" FROM \"a table\" AS \"alias\"", - "output": null, - "issues": [ - "Issue {\n level: Error,\n message: \"Expected expression\",\n span: 34..35,\n sql_segment: \")\",\n fragments: [],\n help: None,\n}" - ], - "failure": true, - "should_fail": true + "output": "Select(\n Select {\n select_span: 0..6,\n flags: [],\n select_exprs: [\n SelectExpr {\n expr: Identifier(\n IdentifierExpression {\n parts: [\n Name(\n Identifier {\n value: \"alias\",\n span: 7..14,\n case_sensitive: true,\n },\n ),\n Name(\n Identifier {\n value: \"bar baz\",\n span: 15..24,\n case_sensitive: true,\n },\n ),\n ],\n },\n ),\n as_: None,\n },\n SelectExpr {\n expr: Function(\n FunctionCallExpression {\n function: Other(\n [\n Identifier {\n value: \"myfun\",\n span: 26..33,\n case_sensitive: true,\n },\n ],\n ),\n args: [],\n function_span: 26..33,\n r_paren_span: 34..35,\n },\n ),\n as_: None,\n },\n SelectExpr {\n expr: Identifier(\n IdentifierExpression {\n parts: [\n Name(\n Identifier {\n value: \"simple id\",\n span: 37..48,\n case_sensitive: true,\n },\n ),\n ],\n },\n ),\n as_: Some(\n Identifier {\n value: \"column alias\",\n span: 52..66,\n case_sensitive: true,\n },\n ),\n },\n ],\n from_span: Some(\n 67..71,\n ),\n table_references: Some(\n [\n Table {\n identifier: QualifiedName {\n prefix: [],\n identifier: Identifier {\n value: \"a table\",\n span: 72..81,\n case_sensitive: true,\n },\n },\n as_span: Some(\n 82..84,\n ),\n as_: Some(\n Identifier {\n value: \"alias\",\n span: 85..92,\n case_sensitive: true,\n },\n ),\n index_hints: [],\n },\n ],\n ),\n where_: None,\n group_by: None,\n having: None,\n window_span: None,\n order_by: None,\n limit: None,\n distinct_on: None,\n offset: None,\n fetch: None,\n locking: None,\n },\n)", + "issues": [] }, { "input": "SELECT \"quoted \"\" ident\"", diff --git a/qusql-parse/build.rs b/qusql-parse/build.rs index 42db436d..202a8053 100644 --- a/qusql-parse/build.rs +++ b/qusql-parse/build.rs @@ -66,7 +66,10 @@ fn read_keywords_from_keywords_rs(path: &Path) -> Vec { }; // Skip special cases - if name == "NOT_A_KEYWORD" || name == "QUOTED_IDENTIFIER" { + if name == "NOT_A_KEYWORD" + || name == "QUOTED_IDENTIFIER" + || name == "DOUBLE_QUOTED_IDENTIFIER" + { continue; } @@ -373,6 +376,11 @@ fn main() { " Keyword::QUOTED_IDENTIFIER => \"QUOTED_IDENTIFIER\"," ) .unwrap(); + writeln!( + out, + " Keyword::DOUBLE_QUOTED_IDENTIFIER => \"DOUBLE_QUOTED_IDENTIFIER\"," + ) + .unwrap(); for kw in &keywords { writeln!(out, " Keyword::{} => \"{}\",", kw.name, kw.name).unwrap(); } diff --git a/qusql-parse/src/expression.rs b/qusql-parse/src/expression.rs index a9183f8b..b9a65efc 100644 --- a/qusql-parse/src/expression.rs +++ b/qusql-parse/src/expression.rs @@ -2631,7 +2631,10 @@ pub(crate) fn parse_expression_restricted<'a>( _ => "", }, span: fn_span.clone(), - case_sensitive: false, + case_sensitive: matches!( + fn_tok, + Token::Ident(_, Keyword::DOUBLE_QUOTED_IDENTIFIER) + ), }; all_idents.push(fn_ident); // function_span covers from first qualifier to function name diff --git a/qusql-parse/src/function_expression.rs b/qusql-parse/src/function_expression.rs index 8ff82e12..ad3daa13 100644 --- a/qusql-parse/src/function_expression.rs +++ b/qusql-parse/src/function_expression.rs @@ -3526,7 +3526,7 @@ pub(crate) fn parse_function<'a>( Function::Other(alloc::vec![Identifier { value: v, span: span.clone(), - case_sensitive: false, + case_sensitive: matches!(k, Keyword::DOUBLE_QUOTED_IDENTIFIER), }]) } _ => { diff --git a/qusql-parse/src/keywords.rs b/qusql-parse/src/keywords.rs index 610478bf..52afdba4 100644 --- a/qusql-parse/src/keywords.rs +++ b/qusql-parse/src/keywords.rs @@ -330,6 +330,7 @@ pub(crate) enum Keyword { #[default] NOT_A_KEYWORD, QUOTED_IDENTIFIER, + DOUBLE_QUOTED_IDENTIFIER, // Possibly restricted keywords above. Keywords never restricted below. _LIST_, ABS, diff --git a/qusql-parse/src/lexer.rs b/qusql-parse/src/lexer.rs index 22e37291..9ca3cb5d 100644 --- a/qusql-parse/src/lexer.rs +++ b/qusql-parse/src/lexer.rs @@ -1016,6 +1016,12 @@ impl<'a> Lexer<'a> { self.chars.next(); } _ => { + if self.dialect.is_postgresql() { + break Token::Ident( + self.s(start + 1..i), + Keyword::DOUBLE_QUOTED_IDENTIFIER, + ); + } break Token::String( self.s(start + 1..i), StringType::DoubleQuoted, @@ -1416,6 +1422,64 @@ mod tests { } else { panic!("Expected quoted identifier with escaped backtick"); } + + // Make sure double-quoting works as a string in this dialect + if let Token::String(value, StringType::DoubleQuoted) = lex_single("\"my_table\"", &dialect) + { + assert_eq!(value, "my_table"); + } else { + panic!("Expected double-quoted string"); + } + } + + /// Tests double-quoted identifiers in postgresql dialect. + #[test] + fn test_double_quoted_identifiers() { + for dialect in [SQLDialect::PostgreSQL, SQLDialect::PostGIS] { + // Unquoted identifiers + if let Token::Ident(name, Keyword::NOT_A_KEYWORD) = lex_single("my_table", &dialect) { + assert_eq!(name, "my_table"); + } else { + panic!("Expected unquoted identifier"); + } + + if let Token::Ident(name, Keyword::NOT_A_KEYWORD) = lex_single("column123", &dialect) { + assert_eq!(name, "column123"); + } else { + panic!("Expected unquoted identifier"); + } + + // Double-quoted identifier + if let Token::Ident(name, Keyword::DOUBLE_QUOTED_IDENTIFIER) = + lex_single("\"ColumnOneTwoThree\"", &dialect) + { + assert_eq!(name, "ColumnOneTwoThree"); + } else { + panic!("Expected double-quoted identifier"); + } + + if let Token::Ident(name, Keyword::DOUBLE_QUOTED_IDENTIFIER) = + lex_single("\"select\"", &dialect) + { + assert_eq!(name, "select"); + } else { + panic!("Expected double-quoted identifier"); + } + + if let Token::Ident(name, Keyword::DOUBLE_QUOTED_IDENTIFIER) = + lex_single("\"a\"\"b\"", &dialect) + { + assert_eq!(name, "a\"\"b"); + } else { + panic!("Expected double-quoted identifier"); + } + + assert_eq!( + lex_single("\"unterminated", &dialect), + Token::Invalid, + "Expected invalid unterminated double-quoted identifier" + ); + } } /// Tests that numbers are correctly recognized in various formats, including integers, floats, and scientific notation. @@ -2262,12 +2326,12 @@ mod tests { } } - /// Tests dialect-specific backslash behaviour inside single- and double-quoted string literals. + /// Tests dialect-specific backslash behaviour inside single quotes and double quotes. /// /// In MariaDB, `\` escapes the next character, so `\'` keeps the string open. - /// In PostgreSQL, `\` is a plain literal character inside `'...'` / `"..."` (only - /// `E'...'` strings support backslash escapes), so `'\''` closes the string right - /// after the backslash. + /// In PostgreSQL, `\` is a plain literal character inside `'...'` (strings) / `"..."` (quoted + /// identifiers) (only `E'...'` strings support backslash escapes), so `'\''` closes the string + /// right after the backslash. #[test] fn test_backslash_string_escaping() { let pg = SQLDialect::PostgreSQL; @@ -2334,18 +2398,18 @@ mod tests { panic!("Expected MariaDB single-quoted string '\\n'"); } - // ── double-quoted strings ──────────────────────────────────────────── + // ── double-quoted text ──────────────────────────────────────────── // r#""\""# is the 3-char SQL text " \ " - // PostgreSQL: \ is literal → closing quote ends the string → content = "\" - if let Token::String(value, StringType::DoubleQuoted) = lex_single(r#""\""#, &pg) { + // PostgreSQL: \ is literal → closing quote ends the quoted identifier → content = "\" + if let Token::Ident(value, Keyword::DOUBLE_QUOTED_IDENTIFIER) = lex_single(r#""\""#, &pg) { assert_eq!( value, r"\", - "PG: backslash should be literal in double-quoted string" + "PG: backslash should be literal in double-quoted identifier" ); } else { panic!( - "Expected PG double-quoted string containing backslash, got {:?}", + "Expected PG double-quoted identifier containing backslash, got {:?}", lex_single(r#""\""#, &pg) ); } diff --git a/qusql-parse/src/lib.rs b/qusql-parse/src/lib.rs index f65eb889..bfc015c1 100644 --- a/qusql-parse/src/lib.rs +++ b/qusql-parse/src/lib.rs @@ -578,3 +578,99 @@ pub fn parse_use_index() { let _result = parse_statement(sql, &mut issues, &options); assert!(issues.is_ok(), "{}", issues); } + +#[test] +pub fn parse_double_quoted_ident() { + use core::ops::Deref; + let sql = "SELECT \"a\" FROM \"b\""; + let options = ParseOptions::new() + .dialect(SQLDialect::PostgreSQL) + .arguments(SQLArguments::QuestionMark) + .warn_unquoted_identifiers(false); + + let mut issues = Issues::new(sql); + let result = parse_statement(sql, &mut issues, &options).unwrap(); + assert!(issues.is_ok(), "{}", issues); + let Statement::Select(s) = result else { + panic!("Expected select, got {result:?}"); + }; + let [ + SelectExpr { + expr: Expression::Identifier(id), + as_: None, + }, + ] = s.select_exprs.as_slice() + else { + panic!("Expected one select expr (ident), got {:?}", s.select_exprs); + }; + let [IdentifierPart::Name(n)] = id.parts.as_slice() else { + panic!("Expected one-part name identifier, got {id:?}"); + }; + assert_eq!(n.deref(), "a"); + assert!(n.case_sensitive); + assert_eq!(n.span, 7..10); + let [ + TableReference::Table { + identifier, + as_span: None, + as_: None, + .. + }, + ] = s.table_references.as_deref().unwrap() + else { + panic!("Expected one table reference"); + }; + assert_eq!(identifier.span(), 16..19); + assert!(identifier.prefix.is_empty()); + assert_eq!(identifier.identifier.deref(), "b"); + assert!(identifier.identifier.case_sensitive); + assert_eq!(identifier.identifier.span, 16..19); +} + +#[test] +pub fn parse_double_quoted_function_names() { + use core::ops::Deref; + fn check_fn(sql: &str, span: core::ops::Range, expected_ids: &[(&str, bool)]) { + let options = ParseOptions::new() + .dialect(SQLDialect::PostgreSQL) + .arguments(SQLArguments::QuestionMark) + .warn_unquoted_identifiers(false); + + let mut issues = Issues::new(sql); + let result = parse_statement(sql, &mut issues, &options).unwrap(); + assert!(issues.is_ok(), "{}", issues); + let Statement::Select(s) = result else { + panic!("Expected select, got {result:?}"); + }; + let [ + SelectExpr { + expr: Expression::Function(f), + as_: None, + }, + ] = s.select_exprs.as_slice() + else { + panic!("Expected one select expr (fn), got {:?}", s.select_exprs); + }; + assert!(f.args.is_empty()); + assert_eq!(f.span(), span); + let Function::Other(ref ids) = f.function else { + panic!("Expected Function::Other, got {f:?}"); + }; + assert_eq!(ids.len(), expected_ids.len()); + for (id, expected_id) in ids.into_iter().zip(expected_ids) { + assert_eq!(id.deref(), expected_id.0); + assert_eq!(id.case_sensitive, expected_id.1); + } + } + check_fn("SELECT \"MyFn\"()", 7..15, &[("MyFn", true)]); + check_fn( + "SELECT public.\"MyFn\"()", + 7..22, + &[("public", false), ("MyFn", true)], + ); + check_fn( + "SELECT public.MyFn()", + 7..20, + &[("public", false), ("MyFn", false)], + ); +} diff --git a/qusql-parse/src/parser.rs b/qusql-parse/src/parser.rs index 85e3828f..5c01263b 100644 --- a/qusql-parse/src/parser.rs +++ b/qusql-parse/src/parser.rs @@ -384,6 +384,11 @@ impl<'a, 'b> Parser<'a, 'b> { span: Span, ) -> Result, ParseError> { match &token { + Token::Ident(v, Keyword::DOUBLE_QUOTED_IDENTIFIER) + if self.options.dialect.is_postgresql() => + { + Ok(Identifier::new_case_sensitive(v, span)) + } Token::Ident(v, kw) => { let v = *v; if kw.restricted(self.reserved()) { @@ -413,6 +418,11 @@ impl<'a, 'b> Parser<'a, 'b> { restricted: Restrict, ) -> Result, ParseError> { match &self.token { + Token::Ident(v, Keyword::DOUBLE_QUOTED_IDENTIFIER) + if self.options.dialect.is_postgresql() => + { + Ok(Identifier::new_case_sensitive(v, self.consume())) + } Token::Ident(v, kw) => { let v = *v; if kw.restricted(restricted) { @@ -436,9 +446,6 @@ impl<'a, 'b> Parser<'a, 'b> { } Ok(Identifier::new(v, self.consume())) } - Token::String(v, StringType::DoubleQuoted) if self.options.dialect.is_postgresql() => { - Ok(Identifier::new_case_sensitive(v, self.consume())) - } _ => self.expected_failure("identifier"), } }