Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions parse-test/postgres-tests.json
Original file line number Diff line number Diff line change
Expand Up @@ -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\"",
Expand Down
10 changes: 9 additions & 1 deletion qusql-parse/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ fn read_keywords_from_keywords_rs(path: &Path) -> Vec<Keyword> {
};

// Skip special cases
if name == "NOT_A_KEYWORD" || name == "QUOTED_IDENTIFIER" {
if name == "NOT_A_KEYWORD"
|| name == "QUOTED_IDENTIFIER"
|| name == "DOUBLE_QUOTED_IDENTIFIER"
{
continue;
}

Expand Down Expand Up @@ -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();
}
Expand Down
5 changes: 4 additions & 1 deletion qusql-parse/src/expression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion qusql-parse/src/function_expression.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}])
}
_ => {
Expand Down
1 change: 1 addition & 0 deletions qusql-parse/src/keywords.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
82 changes: 73 additions & 9 deletions qusql-parse/src/lexer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
);
}
Expand Down
96 changes: 96 additions & 0 deletions qusql-parse/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize>, 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)],
);
}
13 changes: 10 additions & 3 deletions qusql-parse/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,11 @@ impl<'a, 'b> Parser<'a, 'b> {
span: Span,
) -> Result<Identifier<'a>, 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()) {
Expand Down Expand Up @@ -413,6 +418,11 @@ impl<'a, 'b> Parser<'a, 'b> {
restricted: Restrict,
) -> Result<Identifier<'a>, 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) {
Expand All @@ -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"),
}
}
Expand Down