From c0ad6b68ae4e7bcee1fefb4589f7930d85512c65 Mon Sep 17 00:00:00 2001 From: Snehil Shah Date: Sun, 30 Aug 2026 00:21:55 +0530 Subject: [PATCH] fix: canonicalize keywords Signed-off-by: Snehil Shah --- Makefile | 1 + include/sql_parser/arena.h | 4 + include/sql_parser/compound_query_parser.h | 21 ++- include/sql_parser/delete_parser.h | 4 +- include/sql_parser/expression_parser.h | 58 ++++++-- include/sql_parser/select_parser.h | 16 ++- include/sql_parser/table_ref_parser.h | 4 +- include/sql_parser/update_parser.h | 4 +- src/sql_parser/arena.cpp | 22 ++++ tests/test_digest.cpp | 49 +++++++ tests/test_keyword_canonicalization.cpp | 146 +++++++++++++++++++++ 11 files changed, 306 insertions(+), 23 deletions(-) create mode 100644 tests/test_keyword_canonicalization.cpp diff --git a/Makefile b/Makefile index 984e413..85b7b66 100644 --- a/Makefile +++ b/Makefile @@ -51,6 +51,7 @@ TEST_SRCS = $(TEST_DIR)/test_main.cpp \ $(TEST_DIR)/test_update.cpp \ $(TEST_DIR)/test_delete.cpp \ $(TEST_DIR)/test_compound.cpp \ + $(TEST_DIR)/test_keyword_canonicalization.cpp \ $(TEST_DIR)/test_digest.cpp \ $(TEST_DIR)/test_misc_stmts.cpp \ $(TEST_DIR)/test_value.cpp \ diff --git a/include/sql_parser/arena.h b/include/sql_parser/arena.h index 233fe6e..485581c 100644 --- a/include/sql_parser/arena.h +++ b/include/sql_parser/arena.h @@ -30,6 +30,10 @@ class Arena { StringRef allocate_string(const char* src, uint32_t len); + StringRef allocate_upper(StringRef text); + + StringRef allocate_lower(StringRef text); + void reset(); size_t bytes_used() const; diff --git a/include/sql_parser/compound_query_parser.h b/include/sql_parser/compound_query_parser.h index 8ae0048..1350394 100644 --- a/include/sql_parser/compound_query_parser.h +++ b/include/sql_parser/compound_query_parser.h @@ -73,7 +73,10 @@ class CompoundQueryParser { AstNode* lock = make_node(arena_, NodeType::NODE_LOCKING_CLAUSE); if (lock) { Token strength = tok_.next_token(); - lock->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, strength.text)); + lock->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, + strength.type == TokenType::TK_UPDATE ? StringRef{"UPDATE", 6} + : strength.type == TokenType::TK_SHARE ? StringRef{"SHARE", 5} + : strength.text)); result->add_child(lock); } } @@ -124,7 +127,11 @@ class CompoundQueryParser { // Consume the set operator tok_.skip(); - StringRef op_text = t.text; + StringRef op_text = + t.type == TokenType::TK_UNION ? StringRef{"UNION", 5} + : t.type == TokenType::TK_INTERSECT ? StringRef{"INTERSECT", 9} + : t.type == TokenType::TK_EXCEPT ? StringRef{"EXCEPT", 6} + : t.text; // Check for optional ALL uint16_t flags = 0; @@ -226,7 +233,11 @@ class CompoundQueryParser { if (prec == 0 || prec <= min_prec) break; tok_.skip(); - StringRef op_text = t.text; + StringRef op_text = + t.type == TokenType::TK_UNION ? StringRef{"UNION", 5} + : t.type == TokenType::TK_INTERSECT ? StringRef{"INTERSECT", 9} + : t.type == TokenType::TK_EXCEPT ? StringRef{"EXCEPT", 6} + : t.text; uint16_t flags = 0; if (tok_.peek().type == TokenType::TK_ALL) { @@ -278,7 +289,9 @@ class CompoundQueryParser { Token dir = tok_.peek(); if (dir.type == TokenType::TK_ASC || dir.type == TokenType::TK_DESC) { tok_.skip(); - item->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, dir.text)); + item->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, + dir.type == TokenType::TK_ASC ? StringRef{"ASC", 3} + : StringRef{"DESC", 4})); } order_by->add_child(item); diff --git a/include/sql_parser/delete_parser.h b/include/sql_parser/delete_parser.h index 1421a4e..9d8df46 100644 --- a/include/sql_parser/delete_parser.h +++ b/include/sql_parser/delete_parser.h @@ -294,7 +294,9 @@ class DeleteParser { Token dir = tok_.peek(); if (dir.type == TokenType::TK_ASC || dir.type == TokenType::TK_DESC) { tok_.skip(); - item->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, dir.text)); + item->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, + dir.type == TokenType::TK_ASC ? StringRef{"ASC", 3} + : StringRef{"DESC", 4})); } order_by->add_child(item); diff --git a/include/sql_parser/expression_parser.h b/include/sql_parser/expression_parser.h index 51b15c8..031d847 100644 --- a/include/sql_parser/expression_parser.h +++ b/include/sql_parser/expression_parser.h @@ -41,6 +41,24 @@ using SubqueryParseCallback = AstNode*(*)(Tokenizer&, Arena&); template class ExpressionParser { public: + // Keyword operators are stored under their canonical spelling. + static StringRef canonical_op(const Token& op) { + switch (op.type) { + case TokenType::TK_AND: return StringRef{"AND", 3}; + case TokenType::TK_OR: return StringRef{"OR", 2}; + case TokenType::TK_XOR: return StringRef{"XOR", 3}; + case TokenType::TK_NOT: return StringRef{"NOT", 3}; + case TokenType::TK_IS: return StringRef{"IS", 2}; + case TokenType::TK_IN: return StringRef{"IN", 2}; + case TokenType::TK_LIKE: return StringRef{"LIKE", 4}; + case TokenType::TK_REGEXP: return StringRef{"REGEXP", 6}; + case TokenType::TK_DIV: return StringRef{"DIV", 3}; + case TokenType::TK_MOD: return StringRef{"MOD", 3}; + case TokenType::TK_BETWEEN: return StringRef{"BETWEEN", 7}; + default: return op.text; + } + } + ExpressionParser(Tokenizer& tokenizer, Arena& arena) : tok_(tokenizer), arena_(arena) {} @@ -120,7 +138,14 @@ class ExpressionParser { } case TokenType::TK_NULL: { tok_.skip(); - return make_node_from_token(arena_, NodeType::NODE_LITERAL_NULL, t); + { + // Keep the source span lossless, but store the keyword under + // its canonical spelling. + AstNode* null_node = + make_node_from_token(arena_, NodeType::NODE_LITERAL_NULL, t); + if (null_node) null_node->set_value(StringRef{"NULL", 4}); + return null_node; + } } case TokenType::TK_TRUE: case TokenType::TK_FALSE: { @@ -186,7 +211,7 @@ class ExpressionParser { tok_.skip(); AstNode* operand = parse(Precedence::UNARY); if (!operand) return nullptr; - AstNode* node = make_node(arena_, NodeType::NODE_UNARY_OP, t.text); + AstNode* node = make_node(arena_, NodeType::NODE_UNARY_OP, canonical_op(t)); set_span_through_node_(node, t.source, operand); node->add_child(operand); return node; @@ -196,7 +221,7 @@ class ExpressionParser { tok_.skip(); AstNode* operand = parse(Precedence::UNARY); if (!operand) return nullptr; - AstNode* node = make_node(arena_, NodeType::NODE_UNARY_OP, t.text); + AstNode* node = make_node(arena_, NodeType::NODE_UNARY_OP, canonical_op(t)); set_span_through_node_(node, t.source, operand); node->add_child(operand); return node; @@ -205,7 +230,7 @@ class ExpressionParser { tok_.skip(); AstNode* operand = parse(Precedence::NOT); if (!operand) return nullptr; - AstNode* node = make_node(arena_, NodeType::NODE_UNARY_OP, t.text); + AstNode* node = make_node(arena_, NodeType::NODE_UNARY_OP, canonical_op(t)); set_span_through_node_(node, t.source, operand); node->add_child(operand); return node; @@ -326,7 +351,16 @@ class ExpressionParser { // Check for function call: name( if (tok_.peek().type == TokenType::TK_LPAREN) { tok_.skip(); // consume ( - AstNode* func = make_node(arena_, NodeType::NODE_FUNCTION_CALL, name_token.text); + // Function names are case-insensitive, so store them under a canonical spelling. + StringRef func_name = name_token.text; + if constexpr (D == Dialect::MySQL) { + // MySQL folds all function names up: + func_name = arena_.allocate_upper(func_name); + } else if (!token_was_delimited_(name_token)) { + // PostgreSQL folds undelimited function names down: + func_name = arena_.allocate_lower(func_name); + } + AstNode* func = make_node(arena_, NodeType::NODE_FUNCTION_CALL, func_name); // CAST uses `CAST(expr AS type)` rather than a comma-separated // argument list. Model it as a function call so consumers can // reject or handle the expression without leaving valid input @@ -449,14 +483,14 @@ class ExpressionParser { tok_.skip(); AstNode* in_node = parse_in(left); // Wrap in NOT - AstNode* not_node = make_node(arena_, NodeType::NODE_UNARY_OP, op.text); + AstNode* not_node = make_node(arena_, NodeType::NODE_UNARY_OP, canonical_op(op)); not_node->add_child(in_node); return not_node; } if (actual_op.type == TokenType::TK_BETWEEN) { tok_.skip(); AstNode* between_node = parse_between(left); - AstNode* not_node = make_node(arena_, NodeType::NODE_UNARY_OP, op.text); + AstNode* not_node = make_node(arena_, NodeType::NODE_UNARY_OP, canonical_op(op)); not_node->add_child(between_node); return not_node; } @@ -464,10 +498,10 @@ class ExpressionParser { actual_op.type == TokenType::TK_REGEXP) { tok_.skip(); AstNode* right = parse(prec); - AstNode* like_node = make_node(arena_, NodeType::NODE_BINARY_OP, actual_op.text); + AstNode* like_node = make_node(arena_, NodeType::NODE_BINARY_OP, canonical_op(actual_op)); like_node->add_child(left); if (right) like_node->add_child(right); - AstNode* not_node = make_node(arena_, NodeType::NODE_UNARY_OP, op.text); + AstNode* not_node = make_node(arena_, NodeType::NODE_UNARY_OP, canonical_op(op)); not_node->add_child(like_node); return not_node; } @@ -511,7 +545,7 @@ class ExpressionParser { // Standard binary operator AstNode* right = parse(prec); if (!right) return left; - AstNode* node = make_node(arena_, NodeType::NODE_BINARY_OP, op.text); + AstNode* node = make_node(arena_, NodeType::NODE_BINARY_OP, canonical_op(op)); node->add_child(left); node->add_child(right); return node; @@ -758,7 +792,9 @@ class ExpressionParser { Token dir = tok_.peek(); if (dir.type == TokenType::TK_ASC || dir.type == TokenType::TK_DESC) { tok_.skip(); - item->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, dir.text)); + item->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, + dir.type == TokenType::TK_ASC ? StringRef{"ASC", 3} + : StringRef{"DESC", 4})); } ord->add_child(item); if (tok_.peek().type == TokenType::TK_COMMA) tok_.skip(); diff --git a/include/sql_parser/select_parser.h b/include/sql_parser/select_parser.h index 5e02ecf..daf40e7 100644 --- a/include/sql_parser/select_parser.h +++ b/include/sql_parser/select_parser.h @@ -123,11 +123,14 @@ class SelectParser { if (t.type == TokenType::TK_DISTINCT || t.type == TokenType::TK_ALL) { if (!opts) opts = make_node(arena_, NodeType::NODE_SELECT_OPTIONS); tok_.skip(); - opts->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, t.text)); + opts->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, + t.type == TokenType::TK_DISTINCT ? StringRef{"DISTINCT", 8} + : StringRef{"ALL", 3})); } else if (t.type == TokenType::TK_SQL_CALC_FOUND_ROWS) { if (!opts) opts = make_node(arena_, NodeType::NODE_SELECT_OPTIONS); tok_.skip(); - opts->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, t.text)); + opts->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, + StringRef{"SQL_CALC_FOUND_ROWS", 19})); } else { break; } @@ -298,7 +301,9 @@ class SelectParser { Token dir = tok_.peek(); if (dir.type == TokenType::TK_ASC || dir.type == TokenType::TK_DESC) { tok_.skip(); - item->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, dir.text)); + item->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, + dir.type == TokenType::TK_ASC ? StringRef{"ASC", 3} + : StringRef{"DESC", 4})); } order_by->add_child(item); @@ -349,7 +354,10 @@ class SelectParser { tok_.skip(); // consume FOR Token strength = tok_.next_token(); // UPDATE or SHARE - lock->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, strength.text)); + lock->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, + strength.type == TokenType::TK_UPDATE ? StringRef{"UPDATE", 6} + : strength.type == TokenType::TK_SHARE ? StringRef{"SHARE", 5} + : strength.text)); // Optional: OF table_list if (tok_.peek().type == TokenType::TK_OF) { diff --git a/include/sql_parser/table_ref_parser.h b/include/sql_parser/table_ref_parser.h index 0d320c3..39c2c67 100644 --- a/include/sql_parser/table_ref_parser.h +++ b/include/sql_parser/table_ref_parser.h @@ -135,8 +135,8 @@ class TableRefParser { } // Set join type as value (covers the span from first modifier to JOIN) - StringRef join_type{join_type_start.ptr, - static_cast((join_type_end.ptr + join_type_end.len) - join_type_start.ptr)}; + StringRef join_type = arena_.allocate_upper(StringRef{join_type_start.ptr, + static_cast((join_type_end.ptr + join_type_end.len) - join_type_start.ptr)}); join->value_ptr = join_type.ptr; join->value_len = join_type.len; diff --git a/include/sql_parser/update_parser.h b/include/sql_parser/update_parser.h index f36771c..8421bb7 100644 --- a/include/sql_parser/update_parser.h +++ b/include/sql_parser/update_parser.h @@ -238,7 +238,9 @@ class UpdateParser { Token dir = tok_.peek(); if (dir.type == TokenType::TK_ASC || dir.type == TokenType::TK_DESC) { tok_.skip(); - item->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, dir.text)); + item->add_child(make_node(arena_, NodeType::NODE_IDENTIFIER, + dir.type == TokenType::TK_ASC ? StringRef{"ASC", 3} + : StringRef{"DESC", 4})); } order_by->add_child(item); diff --git a/src/sql_parser/arena.cpp b/src/sql_parser/arena.cpp index 17c19c5..b307287 100644 --- a/src/sql_parser/arena.cpp +++ b/src/sql_parser/arena.cpp @@ -62,6 +62,28 @@ StringRef Arena::allocate_string(const char* src, uint32_t len) { return StringRef{static_cast(mem), len}; } +StringRef Arena::allocate_upper(StringRef text) { + if (!text.ptr || text.len == 0) return text; + char* buf = static_cast(allocate(text.len)); + if (!buf) return text; + for (uint32_t i = 0; i < text.len; ++i) { + const char c = text.ptr[i]; + buf[i] = (c >= 'a' && c <= 'z') ? static_cast(c - 32) : c; + } + return StringRef{buf, text.len}; +} + +StringRef Arena::allocate_lower(StringRef text) { + if (!text.ptr || text.len == 0) return text; + char* buf = static_cast(allocate(text.len)); + if (!buf) return text; + for (uint32_t i = 0; i < text.len; ++i) { + const char c = text.ptr[i]; + buf[i] = (c >= 'A' && c <= 'Z') ? static_cast(c + 32) : c; + } + return StringRef{buf, text.len}; +} + void Arena::reset() { Block* b = primary_->next; while (b) { diff --git a/tests/test_digest.cpp b/tests/test_digest.cpp index f22972c..3970174 100644 --- a/tests/test_digest.cpp +++ b/tests/test_digest.cpp @@ -119,6 +119,14 @@ TEST_F(MySQLDigestTest, KeywordsUppercased) { "SELECT * FROM t WHERE id = ?"); } +TEST_F(MySQLDigestTest, KeywordsUppercasedFromAst) { + EXPECT_EQ(normalized("select a from t where a = 1 and b = 2"), + "SELECT a FROM t WHERE a = ? AND b = ?"); + EXPECT_EQ(normalized("select 1 union select 2"), "SELECT ? UNION SELECT ?"); + EXPECT_EQ(normalized("select a from t order by a desc"), + "SELECT a FROM t ORDER BY a DESC"); +} + // ========== Token-level fallback for Tier 2 ========== TEST_F(MySQLDigestTest, TokenLevelInsert) { @@ -225,6 +233,16 @@ static const DigestTestCase digest_bulk_cases[] = { {"SELECT a FROM t WHERE id = 1", "SELECT b FROM t WHERE id = 1", false, "different columns"}, {"SELECT * FROM t WHERE a = 1", "SELECT * FROM t WHERE b = 1", false, "different where cols"}, {"SELECT * FROM t ORDER BY a", "SELECT * FROM t ORDER BY b", false, "different order"}, + // Keyword casing must not change the digest ... + {"SELECT 1 UNION SELECT 2", "SELECT 1 union SELECT 2", true, "union keyword casing"}, + {"SELECT a FROM t WHERE a = 1 AND b = 2", "SELECT a FROM t WHERE a = 1 and b = 2", true, "AND keyword casing"}, + {"SELECT a FROM t ORDER BY a DESC", "SELECT a FROM t ORDER BY a desc", true, "DESC keyword casing"}, + {"SELECT DISTINCT a FROM t", "SELECT distinct a FROM t", true, "DISTINCT keyword casing"}, + {"SELECT a FROM t1 INNER JOIN t2 ON t1.a = t2.b", "SELECT a FROM t1 inner join t2 ON t1.a = t2.b", true, "join type casing"}, + {"SELECT COUNT(*) FROM t", "SELECT count(*) FROM t", true, "function name casing"}, + // ... but identifier casing must. + {"SELECT a FROM MyTable", "SELECT a FROM mytable", false, "table name case is significant"}, + {"SELECT MyCol FROM t", "SELECT mycol FROM t", false, "column name case is significant"}, }; TEST(MySQLDigestBulk, HashConsistency) { @@ -278,6 +296,19 @@ class PgSQLDigestTest : public ::testing::Test { protected: Parser parser; + // AST-based digest (parses SQL, invalidates previous arena allocations) + StableDigest digest_ast(const char* sql) { + auto r = parser.parse(sql, strlen(sql)); + Digest digest(parser.arena()); + DigestResult dr; + if (r.ast) { + dr = digest.compute(r.ast); + } else { + dr = digest.compute(sql, strlen(sql)); + } + return StableDigest{std::string(dr.normalized.ptr, dr.normalized.len), dr.hash}; + } + StableDigest digest_token(const char* sql) { parser.reset(); Digest digest(parser.arena()); @@ -290,6 +321,24 @@ class PgSQLDigestTest : public ::testing::Test { } }; +// ========== Function name canonicalization ========== + +TEST_F(PgSQLDigestTest, UndelimitedFunctionNamesFold) { + auto lower = digest_ast("SELECT myfunc(a) FROM t"); + auto upper = digest_ast("SELECT MYFUNC(a) FROM t"); + EXPECT_EQ(lower.normalized, "SELECT myfunc(a) FROM t"); + EXPECT_EQ(upper.normalized, "SELECT myfunc(a) FROM t"); + EXPECT_EQ(lower.hash, upper.hash); +} + +TEST_F(PgSQLDigestTest, DelimitedFunctionNameKeepsItsOwnSpelling) { + // PostgreSQL folds undelimited names down, so "MYFUNC" is a different function. + auto undelimited = digest_ast("SELECT myfunc(a) FROM t"); + auto delimited = digest_ast("SELECT \"MYFUNC\"(a) FROM t"); + EXPECT_EQ(delimited.normalized, "SELECT MYFUNC(a) FROM t"); + EXPECT_NE(undelimited.hash, delimited.hash); +} + TEST_F(PgSQLDigestTest, BasicDigest) { EXPECT_EQ(normalized_token("SELECT * FROM users WHERE id = 42"), "SELECT * FROM users WHERE id = ?"); diff --git a/tests/test_keyword_canonicalization.cpp b/tests/test_keyword_canonicalization.cpp new file mode 100644 index 0000000..8580590 --- /dev/null +++ b/tests/test_keyword_canonicalization.cpp @@ -0,0 +1,146 @@ +#include +#include "sql_parser/parser.h" + +using namespace sql_parser; + +class MySQLKeywordCanonicalizationTest : public ::testing::Test { +protected: + Parser parser; + + const AstNode* find_descendant(const AstNode* node, NodeType type) { + if (!node) return nullptr; + if (node->type == type) return node; + for (const AstNode* c = node->first_child; c; c = c->next_sibling) { + if (const AstNode* hit = find_descendant(c, type)) return hit; + } + return nullptr; + } + + std::string value_of(const char* sql, NodeType type) { + auto r = parser.parse(sql, strlen(sql)); + if (!r.ast) return "[PARSE_FAILED]"; + const AstNode* node = find_descendant(r.ast, type); + if (!node) return "[NOT_FOUND]"; + return std::string(node->value_ptr, node->value_len); + } + + std::string child_value_of(const char* sql, NodeType type, int index) { + auto r = parser.parse(sql, strlen(sql)); + if (!r.ast) return "[PARSE_FAILED]"; + const AstNode* node = find_descendant(r.ast, type); + if (!node) return "[NOT_FOUND]"; + const AstNode* child = node->first_child; + for (int i = 0; i < index && child; ++i) child = child->next_sibling; + if (!child) return "[NO_CHILD]"; + return std::string(child->value_ptr, child->value_len); + } +}; + +class PgSQLKeywordCanonicalizationTest : public ::testing::Test { +protected: + Parser parser; + + const AstNode* find_descendant(const AstNode* node, NodeType type) { + if (!node) return nullptr; + if (node->type == type) return node; + for (const AstNode* c = node->first_child; c; c = c->next_sibling) { + if (const AstNode* hit = find_descendant(c, type)) return hit; + } + return nullptr; + } + + std::string value_of(const char* sql, NodeType type) { + auto r = parser.parse(sql, strlen(sql)); + if (!r.ast) return "[PARSE_FAILED]"; + const AstNode* node = find_descendant(r.ast, type); + if (!node) return "[NOT_FOUND]"; + return std::string(node->value_ptr, node->value_len); + } +}; + +// ========== Set operators ========== + +TEST_F(MySQLKeywordCanonicalizationTest, SetOperators) { + EXPECT_EQ(value_of("SELECT 1 union SELECT 2", NodeType::NODE_SET_OPERATION), "UNION"); + EXPECT_EQ(value_of("SELECT 1 UnIoN SELECT 2", NodeType::NODE_SET_OPERATION), "UNION"); + EXPECT_EQ(value_of("SELECT 1 intersect SELECT 2", NodeType::NODE_SET_OPERATION), "INTERSECT"); + EXPECT_EQ(value_of("SELECT 1 except SELECT 2", NodeType::NODE_SET_OPERATION), "EXCEPT"); +} + +// ========== Expression operators ========== + +TEST_F(MySQLKeywordCanonicalizationTest, BinaryOperators) { + EXPECT_EQ(value_of("SELECT a FROM t WHERE a and b", NodeType::NODE_BINARY_OP), "AND"); + EXPECT_EQ(value_of("SELECT a FROM t WHERE a or b", NodeType::NODE_BINARY_OP), "OR"); + EXPECT_EQ(value_of("SELECT a FROM t WHERE b like 'x'", NodeType::NODE_BINARY_OP), "LIKE"); +} + +TEST_F(MySQLKeywordCanonicalizationTest, UnaryOperators) { + EXPECT_EQ(value_of("SELECT not a FROM t", NodeType::NODE_UNARY_OP), "NOT"); +} + +// ========== Literals ========== + +TEST_F(MySQLKeywordCanonicalizationTest, NullLiteral) { + EXPECT_EQ(value_of("SELECT null", NodeType::NODE_LITERAL_NULL), "NULL"); +} + +TEST_F(MySQLKeywordCanonicalizationTest, NullLiteralKeepsLosslessSource) { + const char* sql = "SELECT null"; + auto r = parser.parse(sql, strlen(sql)); + ASSERT_NE(r.ast, nullptr); + const AstNode* node = find_descendant(r.ast, NodeType::NODE_LITERAL_NULL); + ASSERT_NE(node, nullptr); + EXPECT_EQ(std::string(node->source().ptr, node->source().len), "null"); +} + +// ========== Clause keywords ========== + +TEST_F(MySQLKeywordCanonicalizationTest, JoinTypes) { + EXPECT_EQ(value_of("SELECT a FROM t1 inner join t2 ON t1.a = t2.b", + NodeType::NODE_JOIN_CLAUSE), "INNER JOIN"); + EXPECT_EQ(value_of("SELECT a FROM t1 left outer join t2 ON t1.a = t2.b", + NodeType::NODE_JOIN_CLAUSE), "LEFT OUTER JOIN"); +} + +TEST_F(MySQLKeywordCanonicalizationTest, OrderByDirection) { + EXPECT_EQ(child_value_of("SELECT a FROM t ORDER BY a desc", + NodeType::NODE_ORDER_BY_ITEM, 1), "DESC"); + EXPECT_EQ(child_value_of("SELECT a FROM t ORDER BY a asc", + NodeType::NODE_ORDER_BY_ITEM, 1), "ASC"); +} + +TEST_F(MySQLKeywordCanonicalizationTest, SelectOptions) { + EXPECT_EQ(child_value_of("SELECT distinct a FROM t", + NodeType::NODE_SELECT_OPTIONS, 0), "DISTINCT"); +} + +TEST_F(MySQLKeywordCanonicalizationTest, LockStrength) { + EXPECT_EQ(child_value_of("SELECT a FROM t for update", + NodeType::NODE_LOCKING_CLAUSE, 0), "UPDATE"); +} + +// ========== Function names ========== + +TEST_F(MySQLKeywordCanonicalizationTest, FunctionNames) { + EXPECT_EQ(value_of("SELECT count(*) FROM t", NodeType::NODE_FUNCTION_CALL), "COUNT"); + EXPECT_EQ(value_of("SELECT MaX(a) FROM t", NodeType::NODE_FUNCTION_CALL), "MAX"); +} + +TEST_F(PgSQLKeywordCanonicalizationTest, UndelimitedFunctionNamesFoldDown) { + EXPECT_EQ(value_of("SELECT MYFUNC(a) FROM t", NodeType::NODE_FUNCTION_CALL), "myfunc"); + EXPECT_EQ(value_of("SELECT MyFunc(a) FROM t", NodeType::NODE_FUNCTION_CALL), "myfunc"); +} + +TEST_F(PgSQLKeywordCanonicalizationTest, DelimitedFunctionNameKeepsItsOwnSpelling) { + // PostgreSQL folds undelimited names down, so "MYFUNC" is a different function. + EXPECT_EQ(value_of("SELECT \"MYFUNC\"(a) FROM t", NodeType::NODE_FUNCTION_CALL), "MYFUNC"); +} + +// ========== Identifiers are not keywords ========== + +TEST_F(MySQLKeywordCanonicalizationTest, IdentifiersKeepTheirCase) { + // Table and column names are case-sensitive wherever the filesystem is. + EXPECT_EQ(value_of("SELECT a FROM MyTable", NodeType::NODE_IDENTIFIER), "MyTable"); + EXPECT_EQ(value_of("SELECT MyCol FROM t", NodeType::NODE_COLUMN_REF), "MyCol"); +}