diff --git a/.openspec/grammar/sqlite.ebnf b/.openspec/grammar/sqlite.ebnf index 609b8c3..b1947db 100644 --- a/.openspec/grammar/sqlite.ebnf +++ b/.openspec/grammar/sqlite.ebnf @@ -411,7 +411,38 @@ rollback-stmt ::= "ROLLBACK" [ "TRANSACTION" ] ; table-name ::= identifier ; (* V2 *) column-name ::= identifier ; (* V2 *) -identifier ::= ID ; (* bare, "double-quoted", [bracketed], `backticked` -- V2 tokenizer (#60) *) +identifier ::= ID | fallback-keyword ; + (* V2 tokenizer (#60): ID is bare, + "double-quoted", [bracketed], `backticked`. + fallback-keyword added by #696. *) + +fallback-keyword ::= "ABORT" | "ACTION" | "AFTER" | "ALWAYS" | "ANALYZE" + | "ASC" | "ATTACH" | "BEFORE" | "BEGIN" | "BY" | "CASCADE" + | "CAST" | "COLUMN" | "CONFLICT" | "CROSS" | "CURRENT" + | "CURRENT_DATE" | "CURRENT_TIME" | "CURRENT_TIMESTAMP" + | "DATABASE" | "DEFERRED" | "DESC" | "DETACH" | "DO" + | "EACH" | "END" | "EXCLUDE" | "EXCLUSIVE" | "EXPLAIN" + | "FAIL" | "FILTER" | "FIRST" | "FOLLOWING" | "FOR" + | "FULL" | "GENERATED" | "GLOB" | "GROUPS" | "IF" + | "IGNORE" | "IMMEDIATE" | "INDEXED" | "INITIALLY" + | "INNER" | "INSTEAD" | "KEY" | "LAST" | "LEFT" | "LIKE" + | "MATCH" | "MATERIALIZED" | "NATURAL" | "NO" | "NULLS" + | "OF" | "OFFSET" | "OTHERS" | "OUTER" | "OVER" + | "PARTITION" | "PLAN" | "PRAGMA" | "PRECEDING" | "QUERY" + | "RAISE" | "RANGE" | "RECURSIVE" | "REGEXP" | "REINDEX" + | "RELEASE" | "RENAME" | "REPLACE" | "RESTRICT" | "RIGHT" + | "ROLLBACK" | "ROW" | "ROWS" | "SAVEPOINT" | "TEMP" + | "TEMPORARY" | "TIES" | "TRIGGER" | "UNBOUNDED" + | "VACUUM" | "VIEW" | "VIRTUAL" | "WINDOW" | "WITH" + | "WITHOUT" ; + (* V2 [parse.y:272 fallback] -- the 89 + non-reserved keywords Lemon's + `%fallback ID` retries as a plain ID + wherever the grammar can't shift them + as the keyword itself (#696). The other + 57 keywords in the tokenizer's table + stay fully reserved, matching the + oracle. *) (* ===================== Future blocks (stubs -- the denominator) ===================== * V4: subqueries in FROM (table-valued) diff --git a/.openspec/specs/002-parser/spec.md b/.openspec/specs/002-parser/spec.md index 7a20c49..8b535a2 100644 --- a/.openspec/specs/002-parser/spec.md +++ b/.openspec/specs/002-parser/spec.md @@ -464,6 +464,14 @@ The parser MUST accept all SQL that SQLite accepts, and reject all SQL that SQLi **Tests:** `tests/unit/parser.rs::test_error_on_missing_columns`, `tests/corpus/parser_oracle_test.rs::parser_matches_oracle_three_way_outcome` +#### Scenario: Non-reserved (fallback) keywords work as identifiers + +- GIVEN the 89 keywords `parse.y:272`'s `%fallback ID` declares non-reserved (e.g. `KEY`, `VALUE`-adjacent words, `MATCH`, `FIRST`, `ROW`) used as a column/table name or `AS`-alias, such as `CREATE TABLE p(namespace TEXT, key TEXT, value TEXT, PRIMARY KEY(namespace, key))` followed by `SELECT key, value FROM p` +- WHEN parsed +- THEN parse succeeds, with the word still working as its keyword in keyword position in the same statement (`PRIMARY KEY(key)`, `ORDER BY key`); the other 57 keywords stay reserved and are rejected as bare identifiers, matching the oracle + +**Tests:** `tests/corpus/fallback_keyword_test.rs::every_fallback_word_is_accepted_as_a_column_name_matching_the_oracle`, `tests/corpus/fallback_keyword_test.rs::every_reserved_word_is_rejected_as_a_bare_column_name_matching_the_oracle`, `tests/corpus/fallback_keyword_test.rs::sqe_namespace_properties_table_matches_the_oracle`, `tests/corpus/fallback_keyword_test.rs::a_fallback_word_works_as_both_keyword_and_identifier_in_one_statement`, `tests/corpus/fallback_keyword_test.rs::as_alias_using_a_fallback_word_parses` + #### Scenario: SQL text corpus labels match real SQLite - GIVEN the three-way labeled corpus at `tests/corpus/sql/{valid_in_subset,valid_out_of_subset,invalid}/*.sql` (#2), covering the V2 SELECT-core subset plus representative V3/V4+ statements and malformed SQL diff --git a/CHANGELOG.md b/CHANGELOG.md index b7e3b49..88e5601 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,21 @@ All notable changes to sqlite-rs. Format follows [Keep a Changelog](https://keep ### Fixed +- 89 of our 146 keywords were reserved unconditionally, unlike real + SQLite, which treats them as ordinary identifiers outside keyword + position (`parse.y:272`'s `%fallback ID`) — this blocked schemas as + ordinary as `CREATE TABLE p(namespace TEXT, key TEXT, value TEXT, + PRIMARY KEY(namespace, key))`. `parser::grammar`'s `identifier()` (the + single choke point nearly every identifier-accepting production already + funneled through) and `primary_expr`'s column-reference arm now accept + the 89-word fallback set; `KEY`/`FIRST`/`MATCH`/etc. still work as + keywords in keyword position in the same statement. The other 57 + keywords stay fully reserved. Deliberately not extended to a bare + (no-`AS`) alias, since that regressed `a NATURAL JOIN b`/`t LEFT JOIN + (...)` — those keywords are the join operator there, a shift/reduce + call this recursive-descent parser can't make the way SQLite's LALR + table does; `AS ` is the safe form (#696, spec 002 + Req 2). - A SQL comment before or after a statement (`-- c\nCREATE TABLE t(a);`, `CREATE TABLE t(a); -- c`, or comment-only input) was a parse error, even though `split_statements` already grouped a leading comment with diff --git a/src/parser/grammar.rs b/src/parser/grammar.rs index b2f3782..04fde4a 100644 --- a/src/parser/grammar.rs +++ b/src/parser/grammar.rs @@ -27,7 +27,7 @@ use super::ast::*; use super::error::{PResult, ParseFail}; -use super::tokenizer::{Keyword, Param, Span, Token, TokenKind}; +use super::tokenizer::{is_fallback_keyword, keyword_text, Keyword, Param, Span, Token, TokenKind}; /// Recursive-descent parser state: the token stream, a cursor into it, and /// the current expression-nesting depth (see [`MAX_EXPR_DEPTH`]). @@ -211,6 +211,13 @@ impl Parser { let span = self.advance_span(); Ok((name, span)) } + // `parse.y:272`'s `%fallback ID` list (#696): 89 keywords are + // non-reserved in real SQLite and double as ordinary + // identifiers wherever a keyword isn't expected. + TokenKind::Keyword(kw) if is_fallback_keyword(kw) => { + let span = self.advance_span(); + Ok((keyword_text(kw).to_string(), span)) + } _ => { let tok = self.peek().clone(); Err(ParseFail::Invalid { @@ -1381,6 +1388,13 @@ impl Parser { self.advance(); return Ok(Some(name)); } + // Deliberately not extended to bare (no `AS`) fallback keywords: + // real SQLite's LALR table resolves e.g. `a NATURAL JOIN b`'s + // `NATURAL` as the join operator, not a bare alias for `a`, via + // shift/reduce precedence this recursive-descent parser has no + // equivalent for. Accepting it here regressed exactly that case + // (#696) — `AS ` (`identifier()`, above) is + // the safe, unambiguous form and is what the ticket requires. Ok(None) } @@ -2235,7 +2249,33 @@ impl Parser { self.advance(); self.exists_tail(start, false) } - TokenKind::Identifier(name) => { + // SQLite treats most keywords as usable function names when + // followed by `(` (e.g. `replace(...)`, `glob(...)`) — only + // the handful matched above (CASE/CAST/EXISTS/CURRENT_*) + // are true reserved words in expression position. Checked + // before the #696 fallback-identifier arm below so that a + // fallback keyword followed by `(` (e.g. `key(x)`) is still + // a function call, not a column reference. + TokenKind::Keyword(kw) if matches!(self.peek_at(1).kind, TokenKind::LParen) => { + self.advance(); + self.function_call(format!("{kw:?}"), tok.span) + } + // `parse.y:272`'s `%fallback ID` list (#696): a non-reserved + // keyword not followed by `(` is an ordinary column + // reference, same as `TokenKind::Identifier` below. + TokenKind::Identifier(_) | TokenKind::Keyword(_) => { + let name = match tok.kind { + TokenKind::Identifier(name) => name, + TokenKind::Keyword(kw) if is_fallback_keyword(kw) => { + keyword_text(kw).to_string() + } + other => { + return Err(ParseFail::Invalid { + message: format!("expected column or expression, found {other:?}"), + span: tok.span, + }) + } + }; self.advance(); if matches!(self.peek().kind, TokenKind::LParen) { return self.function_call(name, tok.span); @@ -2271,14 +2311,6 @@ impl Parser { }; Ok(Expr { kind, span }) } - // SQLite treats most keywords as usable function names when - // followed by `(` (e.g. `replace(...)`, `glob(...)`) — only - // the handful matched above (CASE/CAST/EXISTS/CURRENT_*) - // are true reserved words in expression position. - TokenKind::Keyword(kw) if matches!(self.peek_at(1).kind, TokenKind::LParen) => { - self.advance(); - self.function_call(format!("{kw:?}"), tok.span) - } TokenKind::LParen => { self.advance(); if self.at_kw(Keyword::SELECT) { diff --git a/src/parser/tokenizer.rs b/src/parser/tokenizer.rs index b95e31c..35c1f0f 100644 --- a/src/parser/tokenizer.rs +++ b/src/parser/tokenizer.rs @@ -589,6 +589,123 @@ const KEYWORDS: &[(&str, Keyword)] = &[ ("WITHOUT", Keyword::WITHOUT), ]; +/// `parse.y:272`'s `%fallback ID` list (3.53.4, pinned oracle): these 89 +/// keywords are non-reserved in real SQLite — Lemon retries them as a +/// plain `ID` wherever the grammar can't shift them as the keyword +/// itself, so they double as ordinary identifiers (column/table/alias +/// names) everywhere a keyword isn't expected. The other 57 keywords in +/// `KEYWORDS` above stay fully reserved. See #696. +const FALLBACK_KEYWORDS: &[Keyword] = &[ + Keyword::ABORT, + Keyword::ACTION, + Keyword::AFTER, + Keyword::ALWAYS, + Keyword::ANALYZE, + Keyword::ASC, + Keyword::ATTACH, + Keyword::BEFORE, + Keyword::BEGIN, + Keyword::BY, + Keyword::CASCADE, + Keyword::CAST, + Keyword::COLUMN, + Keyword::CONFLICT, + Keyword::CROSS, + Keyword::CURRENT, + Keyword::CURRENT_DATE, + Keyword::CURRENT_TIME, + Keyword::CURRENT_TIMESTAMP, + Keyword::DATABASE, + Keyword::DEFERRED, + Keyword::DESC, + Keyword::DETACH, + Keyword::DO, + Keyword::EACH, + Keyword::END, + Keyword::EXCLUDE, + Keyword::EXCLUSIVE, + Keyword::EXPLAIN, + Keyword::FAIL, + Keyword::FILTER, + Keyword::FIRST, + Keyword::FOLLOWING, + Keyword::FOR, + Keyword::FULL, + Keyword::GENERATED, + Keyword::GLOB, + Keyword::GROUPS, + Keyword::IF, + Keyword::IGNORE, + Keyword::IMMEDIATE, + Keyword::INDEXED, + Keyword::INITIALLY, + Keyword::INNER, + Keyword::INSTEAD, + Keyword::KEY, + Keyword::LAST, + Keyword::LEFT, + Keyword::LIKE, + Keyword::MATCH, + Keyword::MATERIALIZED, + Keyword::NATURAL, + Keyword::NO, + Keyword::NULLS, + Keyword::OF, + Keyword::OFFSET, + Keyword::OTHERS, + Keyword::OUTER, + Keyword::OVER, + Keyword::PARTITION, + Keyword::PLAN, + Keyword::PRAGMA, + Keyword::PRECEDING, + Keyword::QUERY, + Keyword::RAISE, + Keyword::RANGE, + Keyword::RECURSIVE, + Keyword::REGEXP, + Keyword::REINDEX, + Keyword::RELEASE, + Keyword::RENAME, + Keyword::REPLACE, + Keyword::RESTRICT, + Keyword::RIGHT, + Keyword::ROLLBACK, + Keyword::ROW, + Keyword::ROWS, + Keyword::SAVEPOINT, + Keyword::TEMP, + Keyword::TEMPORARY, + Keyword::TIES, + Keyword::TRIGGER, + Keyword::UNBOUNDED, + Keyword::VACUUM, + Keyword::VIEW, + Keyword::VIRTUAL, + Keyword::WINDOW, + Keyword::WITH, + Keyword::WITHOUT, +]; + +/// True for the 89 keywords `parse.y:272`'s `%fallback ID` declares +/// non-reserved (see [`FALLBACK_KEYWORDS`]) — these double as ordinary +/// identifiers outside keyword position. The other 57 keywords in +/// `KEYWORDS` stay fully reserved, matching the oracle. +pub fn is_fallback_keyword(kw: Keyword) -> bool { + FALLBACK_KEYWORDS.contains(&kw) +} + +/// The literal source text a keyword token was recognized from +/// (uppercase, per [`KEYWORDS`]) — used to recover an identifier's text +/// when a [`is_fallback_keyword`] keyword is accepted in an identifier +/// position (#696). +pub fn keyword_text(kw: Keyword) -> &'static str { + KEYWORDS + .iter() + .find(|(_, k)| *k == kw) + .map_or("", |(text, _)| text) +} + /// Case-insensitive ASCII ordering of `a` against `b`, without /// allocating an uppercased copy of either — used by [`lookup_word`]'s /// binary search so per-identifier lookup costs no heap allocation. diff --git a/tests/corpus/extracted_sql_test.rs b/tests/corpus/extracted_sql_test.rs index ee3933e..6b112a6 100644 --- a/tests/corpus/extracted_sql_test.rs +++ b/tests/corpus/extracted_sql_test.rs @@ -278,7 +278,7 @@ fn deeply_nested_expressions_hit_the_depth_guard_instead_of_the_stack() { /// never raise it without a documented cause like the #240/#257/#403 bumps /// above. A raise means a regression that reclassified valid SQL as /// malformed. -const SELECT_INVALID_BASELINE: usize = 2; +const SELECT_INVALID_BASELINE: usize = 1; /// Invariant 2: the parser must not call real, SQLite-accepted SELECT invalid. /// `Unsupported` is expected and fine — the V2 grammar is a deliberate slice. diff --git a/tests/corpus/fallback_keyword_test.rs b/tests/corpus/fallback_keyword_test.rs new file mode 100644 index 0000000..367fe7c --- /dev/null +++ b/tests/corpus/fallback_keyword_test.rs @@ -0,0 +1,344 @@ +// Copyright 2026 Schuberg Philis +// SPDX-License-Identifier: Apache-2.0 +//! Oracle-diffed regression tests for #696: `parse.y:272`'s `%fallback +//! ID` list makes 89 of SQLite's 146 keywords non-reserved — they +//! double as ordinary identifiers (column/table/alias names) wherever a +//! keyword isn't expected. We used to reserve all 146 unconditionally, +//! which blocked schemas as ordinary as `PRIMARY KEY(namespace, key)`. +//! +//! The 89-word fallback set and the 57 still-reserved words below are +//! transcribed from the issue's own measurement against the pinned +//! 3.53.4 oracle (`src/parser/tokenizer.rs`'s `FALLBACK_KEYWORDS`). + +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use crate::oracle::{pinned_oracle, skip_no_oracle}; + +const CLI: &str = env!("CARGO_BIN_EXE_sqlite-rs"); + +/// The 89 keywords `parse.y:272` declares non-reserved (`%fallback ID`). +const FALLBACK_WORDS: &[&str] = &[ + "ABORT", + "ACTION", + "AFTER", + "ALWAYS", + "ANALYZE", + "ASC", + "ATTACH", + "BEFORE", + "BEGIN", + "BY", + "CASCADE", + "CAST", + "COLUMN", + "CONFLICT", + "CROSS", + "CURRENT", + "CURRENT_DATE", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "DATABASE", + "DEFERRED", + "DESC", + "DETACH", + "DO", + "EACH", + "END", + "EXCLUDE", + "EXCLUSIVE", + "EXPLAIN", + "FAIL", + "FILTER", + "FIRST", + "FOLLOWING", + "FOR", + "FULL", + "GENERATED", + "GLOB", + "GROUPS", + "IF", + "IGNORE", + "IMMEDIATE", + "INDEXED", + "INITIALLY", + "INNER", + "INSTEAD", + "KEY", + "LAST", + "LEFT", + "LIKE", + "MATCH", + "MATERIALIZED", + "NATURAL", + "NO", + "NULLS", + "OF", + "OFFSET", + "OTHERS", + "OUTER", + "OVER", + "PARTITION", + "PLAN", + "PRAGMA", + "PRECEDING", + "QUERY", + "RAISE", + "RANGE", + "RECURSIVE", + "REGEXP", + "REINDEX", + "RELEASE", + "RENAME", + "REPLACE", + "RESTRICT", + "RIGHT", + "ROLLBACK", + "ROW", + "ROWS", + "SAVEPOINT", + "TEMP", + "TEMPORARY", + "TIES", + "TRIGGER", + "UNBOUNDED", + "VACUUM", + "VIEW", + "VIRTUAL", + "WINDOW", + "WITH", + "WITHOUT", +]; + +/// The 57 keywords that stay fully reserved — not usable as a bare +/// (unquoted) column name. +const RESERVED_WORDS: &[&str] = &[ + "ADD", + "ALL", + "ALTER", + "AND", + "AS", + "AUTOINCREMENT", + "BETWEEN", + "CASE", + "CHECK", + "COLLATE", + "COMMIT", + "CONSTRAINT", + "CREATE", + "DEFAULT", + "DEFERRABLE", + "DELETE", + "DISTINCT", + "DROP", + "ELSE", + "ESCAPE", + "EXCEPT", + "EXISTS", + "FOREIGN", + "FROM", + "GROUP", + "HAVING", + "IN", + "INDEX", + "INSERT", + "INTERSECT", + "INTO", + "IS", + "ISNULL", + "JOIN", + "LIMIT", + "NOT", + "NOTHING", + "NOTNULL", + "ON", + "OR", + "ORDER", + "PRIMARY", + "REFERENCES", + "RETURNING", + "SELECT", + "SET", + "TABLE", + "THEN", + "TO", + "TRANSACTION", + "UNION", + "UNIQUE", + "UPDATE", + "USING", + "VALUES", + "WHEN", + "WHERE", +]; + +fn scratch_db(label: &str) -> PathBuf { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "sqlite-rs-fallback-kw-{label}-{}-{n}", + std::process::id() + )); + std::fs::remove_dir_all(&dir).ok(); + std::fs::create_dir_all(&dir).unwrap(); + dir.join("scratch.db") +} + +/// Runs `sql` against `db` through the pinned oracle `sqlite3` binary. +fn run(oracle: &Path, db: &Path, sql: &str) -> Output { + Command::new(oracle) + .arg(db) + .arg(sql) + .output() + .unwrap_or_else(|e| panic!("running {} {} {sql:?}: {e}", oracle.display(), db.display())) +} + +fn our_exec(db: &Path, sql: &str) -> Output { + Command::new(CLI) + .arg("exec") + .arg(db) + .arg(sql) + .output() + .unwrap_or_else(|e| panic!("running {CLI} exec {} {sql:?}: {e}", db.display())) +} + +#[test] +fn every_fallback_word_is_accepted_as_a_column_name_matching_the_oracle() { + let Some(oracle) = pinned_oracle() else { + skip_no_oracle("every_fallback_word_is_accepted_as_a_column_name_matching_the_oracle"); + return; + }; + for word in FALLBACK_WORDS { + let sql = format!("CREATE TABLE t({word} TEXT)"); + let oracle_db = scratch_db(&format!("fallback-oracle-{word}")); + let oracle_out = run(&oracle, &oracle_db, &sql); + assert!( + oracle_out.status.success(), + "oracle unexpectedly rejected fallback word {word}: {}", + String::from_utf8_lossy(&oracle_out.stderr) + ); + + let our_db = scratch_db(&format!("fallback-ours-{word}")); + let our_out = our_exec(&our_db, &sql); + assert!( + our_out.status.success(), + "we rejected fallback word {word} as a column name, oracle accepts it: {}", + String::from_utf8_lossy(&our_out.stderr) + ); + } +} + +#[test] +fn every_reserved_word_is_rejected_as_a_bare_column_name_matching_the_oracle() { + let Some(oracle) = pinned_oracle() else { + skip_no_oracle("every_reserved_word_is_rejected_as_a_bare_column_name_matching_the_oracle"); + return; + }; + for word in RESERVED_WORDS { + let sql = format!("CREATE TABLE t({word} TEXT)"); + let oracle_db = scratch_db(&format!("reserved-oracle-{word}")); + let oracle_out = run(&oracle, &oracle_db, &sql); + assert!( + !oracle_out.status.success(), + "oracle unexpectedly accepted reserved word {word} as a bare column name" + ); + + let our_db = scratch_db(&format!("reserved-ours-{word}")); + let our_out = our_exec(&our_db, &sql); + assert!( + !our_out.status.success(), + "we accepted reserved word {word} as a bare column name, oracle rejects it" + ); + } +} + +#[test] +fn sqe_namespace_properties_table_matches_the_oracle() { + let sql_create = + "CREATE TABLE p(namespace TEXT, key TEXT, value TEXT, PRIMARY KEY(namespace, key))"; + let sql_insert = "INSERT INTO p VALUES('ns', 'k', 'v')"; + let sql_select = "SELECT key, value FROM p"; + + let our_db = scratch_db("sqe-ours"); + assert!(our_exec(&our_db, sql_create).status.success()); + assert!(our_exec(&our_db, sql_insert).status.success()); + let our_query = Command::new(CLI) + .arg("query") + .arg(&our_db) + .arg(sql_select) + .output() + .unwrap(); + assert!(our_query.status.success()); + assert_eq!(String::from_utf8_lossy(&our_query.stdout), "k|v\n"); + + let Some(oracle) = pinned_oracle() else { + skip_no_oracle("sqe_namespace_properties_table_matches_the_oracle (oracle cross-check)"); + return; + }; + let oracle_db = scratch_db("sqe-oracle"); + assert!(run(&oracle, &oracle_db, sql_create).status.success()); + assert!(run(&oracle, &oracle_db, sql_insert).status.success()); + let oracle_query = Command::new(&oracle) + .arg(&oracle_db) + .arg("-list") + .arg(sql_select) + .output() + .unwrap(); + assert!(oracle_query.status.success()); + assert_eq!(String::from_utf8_lossy(&oracle_query.stdout), "k|v\n"); +} + +#[test] +fn a_fallback_word_works_as_both_keyword_and_identifier_in_one_statement() { + // `PRIMARY KEY(key)` uses KEY as the reserved-position keyword and + // as the column name in the same statement; `ORDER BY key` uses it + // as both a bare column reference and a keyword-adjacent word. + let our_db = scratch_db("both-in-one-ours"); + let create = "CREATE TABLE t(key TEXT, PRIMARY KEY(key))"; + assert!(our_exec(&our_db, create).status.success()); + assert!(our_exec(&our_db, "INSERT INTO t VALUES('a')") + .status + .success()); + let select = Command::new(CLI) + .arg("query") + .arg(&our_db) + .arg("SELECT key FROM t ORDER BY key") + .output() + .unwrap(); + assert!(select.status.success(), "{:?}", select.stderr); + assert_eq!(String::from_utf8_lossy(&select.stdout), "a\n"); + + let Some(oracle) = pinned_oracle() else { + skip_no_oracle("a_fallback_word_works_as_both_keyword_and_identifier_in_one_statement (oracle cross-check)"); + return; + }; + let oracle_db = scratch_db("both-in-one-oracle"); + assert!(run(&oracle, &oracle_db, create).status.success()); + assert!(run(&oracle, &oracle_db, "INSERT INTO t VALUES('a')") + .status + .success()); + let oracle_select = Command::new(&oracle) + .arg(&oracle_db) + .arg("-list") + .arg("SELECT key FROM t ORDER BY key") + .output() + .unwrap(); + assert!(oracle_select.status.success()); + assert_eq!(String::from_utf8_lossy(&oracle_select.stdout), "a\n"); +} + +#[test] +fn as_alias_using_a_fallback_word_parses() { + // Regression guard for the `AS first` case the issue's Complexity + // note cites as the trigger for filing #696. + let db = scratch_db("as-alias"); + assert!(our_exec(&db, "CREATE TABLE t(a TEXT)").status.success()); + let out = Command::new(CLI) + .arg("query") + .arg(&db) + .arg("SELECT a AS first FROM t") + .output() + .unwrap(); + assert!(out.status.success(), "{:?}", out.stderr); +} diff --git a/tests/corpus/main.rs b/tests/corpus/main.rs index a80be8c..1900cfe 100644 --- a/tests/corpus/main.rs +++ b/tests/corpus/main.rs @@ -31,6 +31,7 @@ mod cte_test; mod declared_collate_test; mod dump_oracle_test; mod expr_vectors_test; +mod fallback_keyword_test; mod families_test; mod group_by_projection_test; mod harness_test;