Skip to content

Commit 47d1559

Browse files
authored
Merge branch 'main' into feat/clickhouse-parametric-aggregates-482
2 parents 1c4ae55 + 6d5182c commit 47d1559

6 files changed

Lines changed: 218 additions & 15 deletions

File tree

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// Copyright 2026 GoSQLX Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
9+
package parser_test
10+
11+
import (
12+
"testing"
13+
14+
"github.com/ajitpratap0/GoSQLX/pkg/gosqlx"
15+
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
16+
)
17+
18+
// TestClickHouseTableAsIdentifier verifies that the ClickHouse dialect accepts
19+
// `table` as a column identifier in SELECT lists, function arguments, and
20+
// GROUP BY clauses. ClickHouse system tables (system.replicas, system.tables,
21+
// system.parts) all expose a `table` column, so this is a common real-world
22+
// pattern. Regression test for issue #480.
23+
func TestClickHouseTableAsIdentifier(t *testing.T) {
24+
queries := map[string]string{
25+
"replicas_with_table_column": `SELECT
26+
database,
27+
table,
28+
is_leader,
29+
is_readonly,
30+
is_session_expired,
31+
parts_to_check,
32+
queue_size,
33+
inserts_in_queue,
34+
merges_in_queue,
35+
absolute_delay,
36+
last_queue_update,
37+
zookeeper_path
38+
FROM system.replicas
39+
ORDER BY absolute_delay DESC`,
40+
41+
"tables_with_bytes_on_disk": `SELECT
42+
database,
43+
table,
44+
engine,
45+
formatReadableSize(bytes_on_disk) AS size,
46+
parts,
47+
active_parts
48+
FROM system.tables
49+
WHERE engine LIKE '%MergeTree%'
50+
AND is_temporary = 0
51+
ORDER BY bytes_on_disk DESC
52+
LIMIT 10`,
53+
54+
"tables_with_total_bytes": `SELECT
55+
database,
56+
table,
57+
engine,
58+
formatReadableSize(total_bytes) AS size,
59+
parts,
60+
active_parts
61+
FROM system.tables
62+
WHERE engine LIKE '%MergeTree%'
63+
AND is_temporary = 0
64+
ORDER BY total_bytes DESC
65+
LIMIT 10`,
66+
67+
"parts_with_concat_table": `SELECT
68+
concat(database, '.' ,table) AS table_name,
69+
count() AS part_count,
70+
max(partition) AS latest_partition,
71+
formatReadableSize(sum(bytes_on_disk)) AS total_size
72+
FROM system.parts
73+
WHERE active = 1
74+
AND database NOT IN ('system')
75+
GROUP BY database, table
76+
ORDER BY part_count DESC
77+
LIMIT 10`,
78+
79+
"parts_having_count": `SELECT
80+
database,
81+
table,
82+
count() AS parts,
83+
formatReadableSize(sum(bytes_on_disk)) AS size
84+
FROM system.parts
85+
WHERE active = 1
86+
AND database NOT IN ('system')
87+
GROUP BY database, table
88+
HAVING parts > 300
89+
ORDER BY parts DESC`,
90+
91+
// system.tables exposes a `rows` column. ROWS is a reserved keyword
92+
// (used in window frames: ROWS BETWEEN ...), but in ClickHouse it is
93+
// a valid column name in system tables.
94+
"tables_with_rows_column": `SELECT
95+
database,
96+
table,
97+
rows,
98+
total_bytes
99+
FROM system.tables
100+
WHERE database = 'default'
101+
ORDER BY rows DESC`,
102+
}
103+
104+
for name, query := range queries {
105+
query := query
106+
t.Run(name, func(t *testing.T) {
107+
parsed, err := gosqlx.ParseWithDialect(query, keywords.DialectClickHouse)
108+
if err != nil {
109+
t.Fatalf("ParseWithDialect failed: %v", err)
110+
}
111+
if parsed == nil {
112+
t.Fatal("expected non-nil AST")
113+
}
114+
})
115+
}
116+
}

pkg/sql/parser/expressions_literal.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ func (p *Parser) parsePrimaryExpression() (ast.Expression, error) {
103103
return funcCall, nil
104104
}
105105

106-
if p.isType(models.TokenTypeIdentifier) || p.isType(models.TokenTypeDoubleQuotedString) || (p.dialect == string(keywords.DialectSQLServer) && p.isNonReservedKeyword()) {
106+
if p.isType(models.TokenTypeIdentifier) || p.isType(models.TokenTypeDoubleQuotedString) || ((p.dialect == string(keywords.DialectSQLServer) || p.dialect == string(keywords.DialectClickHouse)) && p.isNonReservedKeyword()) {
107107
// Handle identifiers and function calls
108108
// Double-quoted strings are treated as identifiers in SQL (e.g., "column_name")
109109
// Non-reserved keywords (TARGET, SOURCE, etc.) can also be used as identifiers

pkg/sql/parser/expressions_operators.go

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -92,14 +92,21 @@ func (p *Parser) parseComparisonExpression() (ast.Expression, error) {
9292
// Check for LIKE/ILIKE operator
9393
if p.isType(models.TokenTypeLike) || strings.EqualFold(p.currentToken.Token.Value, "ILIKE") {
9494
operator := p.currentToken.Token.Value
95-
// Reject ILIKE in non-PostgreSQL dialects - it is a PostgreSQL extension.
96-
if strings.EqualFold(operator, "ILIKE") &&
97-
p.dialect != "" &&
98-
p.dialect != string(keywords.DialectPostgreSQL) {
99-
return nil, fmt.Errorf(
100-
"ILIKE is a PostgreSQL-specific operator and is not supported in %s; "+
101-
"use LIKE or LOWER() for case-insensitive matching", p.dialect,
102-
)
95+
// ILIKE is supported by PostgreSQL, Snowflake, and ClickHouse natively.
96+
// Reject in other dialects (e.g. MySQL, SQL Server, SQLite, Oracle) where
97+
// it is not a recognized operator.
98+
if strings.EqualFold(operator, "ILIKE") && p.dialect != "" {
99+
switch p.dialect {
100+
case string(keywords.DialectPostgreSQL),
101+
string(keywords.DialectSnowflake),
102+
string(keywords.DialectClickHouse):
103+
// supported
104+
default:
105+
return nil, fmt.Errorf(
106+
"ILIKE is not supported in %s; "+
107+
"use LIKE or LOWER() for case-insensitive matching", p.dialect,
108+
)
109+
}
103110
}
104111
p.advance() // Consume LIKE/ILIKE
105112

pkg/sql/parser/parser.go

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -958,14 +958,18 @@ func (p *Parser) isNonReservedKeyword() bool {
958958
case models.TokenTypeTarget, models.TokenTypeSource, models.TokenTypeMatched:
959959
return true
960960
case models.TokenTypeTable, models.TokenTypeIndex, models.TokenTypeView,
961-
models.TokenTypeKey, models.TokenTypeColumn, models.TokenTypeDatabase:
961+
models.TokenTypeKey, models.TokenTypeColumn, models.TokenTypeDatabase,
962+
models.TokenTypePartition, models.TokenTypeRows:
962963
// DDL keywords that are commonly used as quoted identifiers in MySQL (backtick)
963-
// and SQL Server (bracket) dialects.
964+
// and SQL Server (bracket) dialects, and as plain column names in ClickHouse
965+
// system tables (system.parts.partition, system.replicas.table,
966+
// system.tables.rows, etc).
964967
return true
965968
case models.TokenTypeKeyword:
966969
// Token may have generic Type; check value for specific keywords
967970
switch strings.ToUpper(p.currentToken.Token.Value) {
968-
case "TARGET", "SOURCE", "MATCHED", "VALUE", "NAME", "TYPE", "STATUS":
971+
case "TARGET", "SOURCE", "MATCHED", "VALUE", "NAME", "TYPE", "STATUS",
972+
"TABLES":
969973
return true
970974
}
971975
}

pkg/sql/parser/pivot.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,13 @@ func (p *Parser) parsePivotAlias(ref *ast.TableReference) {
6565
}
6666

6767
// pivotDialectAllowed reports whether PIVOT/UNPIVOT is a recognized clause
68-
// for the parser's current dialect. PIVOT/UNPIVOT are SQL Server / Oracle
69-
// extensions; in other dialects the words must remain valid identifiers.
68+
// for the parser's current dialect. PIVOT/UNPIVOT are SQL Server, Oracle,
69+
// and Snowflake extensions; in other dialects the words must remain valid
70+
// identifiers.
7071
func (p *Parser) pivotDialectAllowed() bool {
7172
return p.dialect == string(keywords.DialectSQLServer) ||
72-
p.dialect == string(keywords.DialectOracle)
73+
p.dialect == string(keywords.DialectOracle) ||
74+
p.dialect == string(keywords.DialectSnowflake)
7375
}
7476

7577
// isPivotKeyword returns true if the current token is the contextual PIVOT
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
// Copyright 2026 GoSQLX Authors
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
5+
package parser_test
6+
7+
import (
8+
"strings"
9+
"testing"
10+
11+
"github.com/ajitpratap0/GoSQLX/pkg/gosqlx"
12+
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
13+
)
14+
15+
// TestSnowflakeILIKE verifies that ILIKE is accepted in the Snowflake dialect.
16+
// Snowflake natively supports ILIKE; the parser previously rejected it with a
17+
// "PostgreSQL-specific" error. Regression for #483.
18+
func TestSnowflakeILIKE(t *testing.T) {
19+
queries := []string{
20+
`SELECT * FROM users WHERE name ILIKE 'alice%'`,
21+
`SELECT * FROM users WHERE name NOT ILIKE 'alice%'`,
22+
}
23+
for _, q := range queries {
24+
t.Run(q, func(t *testing.T) {
25+
if _, err := gosqlx.ParseWithDialect(q, keywords.DialectSnowflake); err != nil {
26+
t.Fatalf("ParseWithDialect(Snowflake) failed: %v", err)
27+
}
28+
})
29+
}
30+
}
31+
32+
// TestClickHouseILIKE verifies ILIKE is accepted in the ClickHouse dialect.
33+
func TestClickHouseILIKE(t *testing.T) {
34+
q := `SELECT * FROM events WHERE message ILIKE '%error%'`
35+
if _, err := gosqlx.ParseWithDialect(q, keywords.DialectClickHouse); err != nil {
36+
t.Fatalf("ParseWithDialect(ClickHouse) failed: %v", err)
37+
}
38+
}
39+
40+
// TestMySQLILIKERejected verifies ILIKE is still rejected in dialects that
41+
// do not natively support it.
42+
func TestMySQLILIKERejected(t *testing.T) {
43+
q := `SELECT * FROM users WHERE name ILIKE 'alice%'`
44+
_, err := gosqlx.ParseWithDialect(q, keywords.DialectMySQL)
45+
if err == nil {
46+
t.Fatal("expected MySQL ILIKE to be rejected")
47+
}
48+
if !strings.Contains(err.Error(), "ILIKE is not supported") {
49+
t.Fatalf("unexpected error message: %v", err)
50+
}
51+
}
52+
53+
// TestSnowflakePivot verifies PIVOT is parsed in the Snowflake dialect, where
54+
// it was previously gated to SQL Server / Oracle only. Regression for #483.
55+
func TestSnowflakePivot(t *testing.T) {
56+
q := `SELECT *
57+
FROM monthly_sales
58+
PIVOT (SUM(amount) FOR month IN ('JAN', 'FEB', 'MAR'))
59+
AS p`
60+
if _, err := gosqlx.ParseWithDialect(q, keywords.DialectSnowflake); err != nil {
61+
t.Fatalf("Snowflake PIVOT parse failed: %v", err)
62+
}
63+
}
64+
65+
// TestSnowflakeUnpivot verifies UNPIVOT is parsed in the Snowflake dialect.
66+
func TestSnowflakeUnpivot(t *testing.T) {
67+
q := `SELECT *
68+
FROM monthly_sales
69+
UNPIVOT (amount FOR month IN (jan, feb, mar))
70+
AS u`
71+
if _, err := gosqlx.ParseWithDialect(q, keywords.DialectSnowflake); err != nil {
72+
t.Fatalf("Snowflake UNPIVOT parse failed: %v", err)
73+
}
74+
}

0 commit comments

Comments
 (0)