Skip to content

Commit a62343c

Browse files
committed
feat: SELECT * EXCEPT/REPLACE column modifiers (#20)
Add BigQuery-style star modifier syntax for both MySQL and PostgreSQL dialects: SELECT * EXCEPT(col1, col2) and SELECT * REPLACE(expr AS col). Supports table-qualified stars (e.g. t.* EXCEPT(...)) and multiple replacement items. Includes parser, emitter round-trip, and 7 new tests.
1 parent bdf10fc commit a62343c

7 files changed

Lines changed: 282 additions & 1 deletion

File tree

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ TEST_SRCS = $(TEST_DIR)/test_main.cpp \
8383
$(TEST_DIR)/test_datetime_format.cpp \
8484
$(TEST_DIR)/test_datetime_funcs.cpp \
8585
$(TEST_DIR)/test_result_set.cpp \
86-
$(TEST_DIR)/test_ssl_config.cpp
86+
$(TEST_DIR)/test_ssl_config.cpp \
87+
$(TEST_DIR)/test_star_modifiers.cpp
8788
TEST_OBJS = $(TEST_SRCS:.cpp=.o)
8889
TEST_TARGET = $(PROJECT_ROOT)/run_tests
8990

include/sql_parser/common.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,11 @@ enum class NodeType : uint16_t {
204204
NODE_CTE, // WITH clause wrapper
205205
NODE_CTE_DEFINITION, // name AS (SELECT ...)
206206

207+
// Star modifiers (BigQuery-style)
208+
NODE_STAR_EXCEPT, // SELECT * EXCEPT(col1, col2)
209+
NODE_STAR_REPLACE, // SELECT * REPLACE(expr AS col)
210+
NODE_REPLACE_ITEM, // single expr AS col inside REPLACE
211+
207212
// Shared
208213
NODE_STMT_OPTIONS, // LOW_PRIORITY, IGNORE, QUICK, DELAYED, etc.
209214
NODE_UPDATE_SET_ITEM, // single col=expr pair (shared by INSERT SET and UPDATE SET)

include/sql_parser/emitter.h

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ class Emitter {
7272
case NodeType::NODE_STMT_OPTIONS: emit_stmt_options(node); break;
7373
case NodeType::NODE_UPDATE_SET_ITEM: emit_update_set_item(node); break;
7474

75+
// ---- Star modifiers ----
76+
case NodeType::NODE_STAR_EXCEPT: emit_star_except(node); break;
77+
case NodeType::NODE_STAR_REPLACE: emit_star_replace(node); break;
78+
case NodeType::NODE_REPLACE_ITEM: emit_replace_item(node); break;
79+
7580
// ---- Compound query ----
7681
case NodeType::NODE_COMPOUND_QUERY: emit_compound_query(node); break;
7782
case NodeType::NODE_SET_OPERATION: emit_set_operation(node); break;
@@ -1164,6 +1169,44 @@ class Emitter {
11641169
sb_.append(").");
11651170
if (field) emit_node(field);
11661171
}
1172+
1173+
void emit_star_except(const AstNode* node) {
1174+
const AstNode* star = node->first_child;
1175+
if (star) emit_node(star);
1176+
sb_.append(" EXCEPT(");
1177+
bool first = true;
1178+
for (const AstNode* col = star ? star->next_sibling : node->first_child;
1179+
col; col = col->next_sibling) {
1180+
if (!first) sb_.append(", ");
1181+
first = false;
1182+
emit_node(col);
1183+
}
1184+
sb_.append_char(')');
1185+
}
1186+
1187+
void emit_star_replace(const AstNode* node) {
1188+
const AstNode* star = node->first_child;
1189+
if (star) emit_node(star);
1190+
sb_.append(" REPLACE(");
1191+
bool first = true;
1192+
for (const AstNode* item = star ? star->next_sibling : node->first_child;
1193+
item; item = item->next_sibling) {
1194+
if (!first) sb_.append(", ");
1195+
first = false;
1196+
emit_node(item);
1197+
}
1198+
sb_.append_char(')');
1199+
}
1200+
1201+
void emit_replace_item(const AstNode* node) {
1202+
const AstNode* expr = node->first_child;
1203+
const AstNode* col = expr ? expr->next_sibling : nullptr;
1204+
if (expr) emit_node(expr);
1205+
if (col) {
1206+
sb_.append(" AS ");
1207+
emit_node(col);
1208+
}
1209+
}
11671210
};
11681211

11691212
} // namespace sql_parser

include/sql_parser/expression_parser.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -676,6 +676,7 @@ class ExpressionParser {
676676
case TokenType::TK_LAST_VALUE:
677677
case TokenType::TK_PARTITION:
678678
case TokenType::TK_RECURSIVE:
679+
case TokenType::TK_REPLACE:
679680
return true;
680681
default:
681682
return false;

include/sql_parser/select_parser.h

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,70 @@ class SelectParser {
160160

161161
AstNode* expr = expr_parser_.parse();
162162
if (!expr) return nullptr;
163+
164+
// Check for * EXCEPT(...) or * REPLACE(...)
165+
bool is_star = (expr->type == NodeType::NODE_ASTERISK);
166+
if (!is_star && expr->type == NodeType::NODE_QUALIFIED_NAME) {
167+
for (const AstNode* c = expr->first_child; c; c = c->next_sibling) {
168+
if (!c->next_sibling) {
169+
// table.* produces NODE_IDENTIFIER with value "*"
170+
if (c->type == NodeType::NODE_ASTERISK) {
171+
is_star = true;
172+
} else if (c->type == NodeType::NODE_IDENTIFIER &&
173+
c->value_len == 1 && c->value_ptr[0] == '*') {
174+
is_star = true;
175+
}
176+
}
177+
}
178+
}
179+
180+
if (is_star) {
181+
Token next = tok_.peek();
182+
if (next.type == TokenType::TK_EXCEPT) {
183+
tok_.skip();
184+
AstNode* except_node = make_node(arena_, NodeType::NODE_STAR_EXCEPT);
185+
except_node->add_child(expr);
186+
if (tok_.peek().type == TokenType::TK_LPAREN) {
187+
tok_.skip();
188+
while (tok_.peek().type != TokenType::TK_RPAREN &&
189+
tok_.peek().type != TokenType::TK_EOF) {
190+
Token col = tok_.next_token();
191+
AstNode* col_node = make_node(arena_, NodeType::NODE_IDENTIFIER, col.text);
192+
except_node->add_child(col_node);
193+
if (tok_.peek().type == TokenType::TK_COMMA) tok_.skip();
194+
}
195+
if (tok_.peek().type == TokenType::TK_RPAREN) tok_.skip();
196+
}
197+
item->add_child(except_node);
198+
return item;
199+
}
200+
if (next.type == TokenType::TK_REPLACE) {
201+
tok_.skip();
202+
AstNode* replace_node = make_node(arena_, NodeType::NODE_STAR_REPLACE);
203+
replace_node->add_child(expr);
204+
if (tok_.peek().type == TokenType::TK_LPAREN) {
205+
tok_.skip();
206+
while (tok_.peek().type != TokenType::TK_RPAREN &&
207+
tok_.peek().type != TokenType::TK_EOF) {
208+
AstNode* replace_expr = expr_parser_.parse();
209+
AstNode* replace_item = make_node(arena_, NodeType::NODE_REPLACE_ITEM);
210+
if (replace_expr) replace_item->add_child(replace_expr);
211+
if (tok_.peek().type == TokenType::TK_AS) {
212+
tok_.skip();
213+
Token col = tok_.next_token();
214+
AstNode* col_node = make_node(arena_, NodeType::NODE_IDENTIFIER, col.text);
215+
replace_item->add_child(col_node);
216+
}
217+
replace_node->add_child(replace_item);
218+
if (tok_.peek().type == TokenType::TK_COMMA) tok_.skip();
219+
}
220+
if (tok_.peek().type == TokenType::TK_RPAREN) tok_.skip();
221+
}
222+
item->add_child(replace_node);
223+
return item;
224+
}
225+
}
226+
163227
item->add_child(expr);
164228

165229
// Optional alias: AS name, or just name (implicit alias)

include/sql_parser/table_ref_parser.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ class TableRefParser {
237237
case TokenType::TK_OVER:
238238
case TokenType::TK_WITH:
239239
case TokenType::TK_PARTITION:
240+
case TokenType::TK_REPLACE:
240241
return false;
241242
default:
242243
return true; // Keywords not in the blocklist can be implicit aliases

tests/test_star_modifiers.cpp

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#include <gtest/gtest.h>
2+
#include "sql_parser/parser.h"
3+
#include "sql_parser/emitter.h"
4+
5+
using namespace sql_parser;
6+
7+
class MySQLStarModifiersTest : public ::testing::Test {
8+
protected:
9+
Parser<Dialect::MySQL> parser;
10+
11+
const AstNode* find_child(const AstNode* node, NodeType type) {
12+
for (const AstNode* c = node->first_child; c; c = c->next_sibling) {
13+
if (c->type == type) return c;
14+
}
15+
return nullptr;
16+
}
17+
18+
std::string round_trip(const char* sql) {
19+
auto r = parser.parse(sql, strlen(sql));
20+
if (!r.ast) return "[PARSE_FAILED]";
21+
Emitter<Dialect::MySQL> emitter(parser.arena());
22+
emitter.emit(r.ast);
23+
StringRef result = emitter.result();
24+
return std::string(result.ptr, result.len);
25+
}
26+
};
27+
28+
class PgSQLStarModifiersTest : public ::testing::Test {
29+
protected:
30+
Parser<Dialect::PostgreSQL> parser;
31+
32+
std::string round_trip(const char* sql) {
33+
auto r = parser.parse(sql, strlen(sql));
34+
if (!r.ast) return "[PARSE_FAILED]";
35+
Emitter<Dialect::PostgreSQL> emitter(parser.arena());
36+
emitter.emit(r.ast);
37+
StringRef result = emitter.result();
38+
return std::string(result.ptr, result.len);
39+
}
40+
};
41+
42+
// ========== EXCEPT ==========
43+
44+
TEST_F(MySQLStarModifiersTest, SelectStarExcept) {
45+
const char* sql = "SELECT * EXCEPT(id, created_at) FROM users";
46+
auto r = parser.parse(sql, strlen(sql));
47+
EXPECT_EQ(r.status, ParseResult::OK);
48+
EXPECT_EQ(r.stmt_type, StmtType::SELECT);
49+
ASSERT_NE(r.ast, nullptr);
50+
51+
// Find SELECT_ITEM_LIST -> SELECT_ITEM -> STAR_EXCEPT
52+
auto* items = find_child(r.ast, NodeType::NODE_SELECT_ITEM_LIST);
53+
ASSERT_NE(items, nullptr);
54+
auto* item = items->first_child;
55+
ASSERT_NE(item, nullptr);
56+
auto* except_node = find_child(item, NodeType::NODE_STAR_EXCEPT);
57+
ASSERT_NE(except_node, nullptr);
58+
59+
// First child is the asterisk, then the excluded columns
60+
auto* star = except_node->first_child;
61+
ASSERT_NE(star, nullptr);
62+
EXPECT_EQ(star->type, NodeType::NODE_ASTERISK);
63+
64+
auto* col1 = star->next_sibling;
65+
ASSERT_NE(col1, nullptr);
66+
EXPECT_EQ(col1->type, NodeType::NODE_IDENTIFIER);
67+
68+
auto* col2 = col1->next_sibling;
69+
ASSERT_NE(col2, nullptr);
70+
EXPECT_EQ(col2->type, NodeType::NODE_IDENTIFIER);
71+
}
72+
73+
TEST_F(MySQLStarModifiersTest, SelectQualifiedStarExcept) {
74+
const char* sql = "SELECT users.* EXCEPT(password) FROM users";
75+
auto r = parser.parse(sql, strlen(sql));
76+
EXPECT_EQ(r.status, ParseResult::OK);
77+
EXPECT_EQ(r.stmt_type, StmtType::SELECT);
78+
ASSERT_NE(r.ast, nullptr);
79+
80+
auto* items = find_child(r.ast, NodeType::NODE_SELECT_ITEM_LIST);
81+
ASSERT_NE(items, nullptr);
82+
auto* item = items->first_child;
83+
ASSERT_NE(item, nullptr);
84+
auto* except_node = find_child(item, NodeType::NODE_STAR_EXCEPT);
85+
ASSERT_NE(except_node, nullptr);
86+
87+
// First child is qualified name (users.*), then excluded column
88+
auto* qname = except_node->first_child;
89+
ASSERT_NE(qname, nullptr);
90+
EXPECT_EQ(qname->type, NodeType::NODE_QUALIFIED_NAME);
91+
}
92+
93+
// ========== REPLACE ==========
94+
95+
TEST_F(MySQLStarModifiersTest, SelectStarReplace) {
96+
const char* sql = "SELECT * REPLACE(UPPER(name) AS name) FROM users";
97+
auto r = parser.parse(sql, strlen(sql));
98+
EXPECT_EQ(r.status, ParseResult::OK);
99+
EXPECT_EQ(r.stmt_type, StmtType::SELECT);
100+
ASSERT_NE(r.ast, nullptr);
101+
102+
auto* items = find_child(r.ast, NodeType::NODE_SELECT_ITEM_LIST);
103+
ASSERT_NE(items, nullptr);
104+
auto* item = items->first_child;
105+
ASSERT_NE(item, nullptr);
106+
auto* replace_node = find_child(item, NodeType::NODE_STAR_REPLACE);
107+
ASSERT_NE(replace_node, nullptr);
108+
109+
// First child is asterisk, then REPLACE_ITEM(s)
110+
auto* star = replace_node->first_child;
111+
ASSERT_NE(star, nullptr);
112+
EXPECT_EQ(star->type, NodeType::NODE_ASTERISK);
113+
114+
auto* ritem = star->next_sibling;
115+
ASSERT_NE(ritem, nullptr);
116+
EXPECT_EQ(ritem->type, NodeType::NODE_REPLACE_ITEM);
117+
}
118+
119+
// ========== Round-trip ==========
120+
121+
TEST_F(MySQLStarModifiersTest, RoundTripExcept) {
122+
std::string out = round_trip("SELECT * EXCEPT(id, created_at) FROM users");
123+
EXPECT_NE(out.find("EXCEPT"), std::string::npos);
124+
EXPECT_NE(out.find("id"), std::string::npos);
125+
EXPECT_NE(out.find("created_at"), std::string::npos);
126+
}
127+
128+
TEST_F(MySQLStarModifiersTest, RoundTripReplace) {
129+
std::string out = round_trip("SELECT * REPLACE(UPPER(name) AS name) FROM users");
130+
EXPECT_NE(out.find("REPLACE"), std::string::npos);
131+
EXPECT_NE(out.find("UPPER"), std::string::npos);
132+
EXPECT_NE(out.find("name"), std::string::npos);
133+
}
134+
135+
// ========== PostgreSQL dialect ==========
136+
137+
TEST_F(PgSQLStarModifiersTest, SelectStarExceptPgSQL) {
138+
const char* sql = "SELECT * EXCEPT(id) FROM t1";
139+
auto r = parser.parse(sql, strlen(sql));
140+
EXPECT_EQ(r.status, ParseResult::OK);
141+
EXPECT_EQ(r.stmt_type, StmtType::SELECT);
142+
}
143+
144+
// ========== Multiple replacements ==========
145+
146+
TEST_F(MySQLStarModifiersTest, MultipleReplacements) {
147+
const char* sql = "SELECT * REPLACE(1 + 2 AS x, 'hello' AS y) FROM t1";
148+
auto r = parser.parse(sql, strlen(sql));
149+
EXPECT_EQ(r.status, ParseResult::OK);
150+
EXPECT_EQ(r.stmt_type, StmtType::SELECT);
151+
ASSERT_NE(r.ast, nullptr);
152+
153+
auto* items = find_child(r.ast, NodeType::NODE_SELECT_ITEM_LIST);
154+
ASSERT_NE(items, nullptr);
155+
auto* item = items->first_child;
156+
ASSERT_NE(item, nullptr);
157+
auto* replace_node = find_child(item, NodeType::NODE_STAR_REPLACE);
158+
ASSERT_NE(replace_node, nullptr);
159+
160+
// Count REPLACE_ITEM children (skip the first child which is the asterisk)
161+
int replace_items = 0;
162+
for (const AstNode* c = replace_node->first_child; c; c = c->next_sibling) {
163+
if (c->type == NodeType::NODE_REPLACE_ITEM) ++replace_items;
164+
}
165+
EXPECT_EQ(replace_items, 2);
166+
}

0 commit comments

Comments
 (0)