Skip to content
Merged
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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,16 @@ All notable changes to GoSQLX will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added
- ClickHouse SQL dialect (`DialectClickHouse = "clickhouse"`) with 30+ keywords: PREWHERE, FINAL, ENGINE, GLOBAL, ASOF, TTL, CODEC, FORMAT, SETTINGS, DISTRIBUTED, MergeTree family engines, ClickHouse-specific data types (FixedString, LowCardinality, Nullable, DateTime64, IPv4, IPv6)
- `PrewhereClause` field on `SelectStatement` AST node for ClickHouse's pre-filter optimization clause
- `Final` field on `TableReference` for ClickHouse's FINAL table modifier (forces MergeTree part merge before read)
- PREWHERE clause parsing in ClickHouse dialect mode
- FINAL modifier parsing in ClickHouse dialect mode
- GLOBAL IN / GLOBAL NOT IN expression parsing in ClickHouse dialect mode

## [1.12.1] - 2026-03-15 — Website Performance & Mobile Optimization

### Improved
Expand Down
5 changes: 5 additions & 0 deletions pkg/sql/ast/ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ type TableReference struct {
Subquery *SelectStatement // For derived tables: (SELECT ...) AS alias
Lateral bool // LATERAL keyword for correlated subqueries (PostgreSQL)
TableHints []string // SQL Server table hints: WITH (NOLOCK), WITH (ROWLOCK, UPDLOCK), etc.
Final bool // ClickHouse FINAL modifier: forces MergeTree part merge
}

func (t *TableReference) statementNode() {}
Expand Down Expand Up @@ -392,6 +393,7 @@ type SelectStatement struct {
From []TableReference
TableName string // Added for pool operations
Joins []JoinClause
PrewhereClause Expression // ClickHouse PREWHERE clause (applied before WHERE, before reading data)
Where Expression
GroupBy []Expression
Having Expression
Expand Down Expand Up @@ -492,6 +494,9 @@ func (s SelectStatement) Children() []Node {
join := join // G601: Create local copy to avoid memory aliasing
children = append(children, &join)
}
if s.PrewhereClause != nil {
children = append(children, s.PrewhereClause)
}
if s.Where != nil {
children = append(children, s.Where)
}
Expand Down
1 change: 1 addition & 0 deletions pkg/sql/ast/pool.go
Original file line number Diff line number Diff line change
Expand Up @@ -690,6 +690,7 @@ func PutSelectStatement(stmt *SelectStatement) {
stmt.OrderBy = stmt.OrderBy[:0]

stmt.TableName = ""
stmt.PrewhereClause = nil
stmt.Where = nil
stmt.Limit = nil
stmt.Offset = nil
Expand Down
8 changes: 8 additions & 0 deletions pkg/sql/ast/sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -560,6 +560,11 @@ func (s *SelectStatement) SQL() string {
sb.WriteString(joinSQL(&j))
}

if s.PrewhereClause != nil {
sb.WriteString(" PREWHERE ")
sb.WriteString(exprSQL(s.PrewhereClause))
}

if s.Where != nil {
sb.WriteString(" WHERE ")
sb.WriteString(exprSQL(s.Where))
Expand Down Expand Up @@ -1298,6 +1303,9 @@ func tableRefSQL(t *TableReference) string {
sb.WriteString(" ")
sb.WriteString(t.Alias)
}
if t.Final {
sb.WriteString(" FINAL")
}
return sb.String()
}

Expand Down
73 changes: 73 additions & 0 deletions pkg/sql/keywords/clickhouse.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright 2026 GoSQLX Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package keywords

import "github.com/ajitpratap0/GoSQLX/pkg/models"

// CLICKHOUSE_SPECIFIC contains ClickHouse-specific SQL keywords and extensions.
// These keywords are recognized when using DialectClickHouse.
//
// Note: PREWHERE and FINAL also appear in the tokenizer's hardcoded keywordTokenTypes
// map (tokenizer.go) to ensure they are emitted as TokenTypeKeyword rather than
// TokenTypeIdentifier. This is required for correct clause boundary detection during
// FROM clause parsing. All other keywords here are dialect-scoped only.
//
// Examples: PREWHERE, FINAL, ENGINE, MERGETREE, CODEC, TTL, DISTRIBUTED, GLOBAL, ASOF
var CLICKHOUSE_SPECIFIC = []Keyword{
// ClickHouse-specific query clauses
{Word: "PREWHERE", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: true},
{Word: "FINAL", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: true},
{Word: "SAMPLE", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: true},
{Word: "GLOBAL", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: true},
{Word: "ASOF", Type: models.TokenTypeKeyword, Reserved: true, ReservedForTableAlias: true},

// ClickHouse DDL — table engine and column options
{Word: "ENGINE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "CODEC", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "TTL", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "GRANULARITY", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "SETTINGS", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "FORMAT", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "ALIAS", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "MATERIALIZED", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "TUPLE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},

// MergeTree engine family
{Word: "MERGETREE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "REPLACINGMERGETREE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "AGGREGATINGMERGETREE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "COLLAPSINGMERGETREE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "SUMMINGMERGETREE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "REPLICATEDMERGETREE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "REPLICATED", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},

// Other table engines
{Word: "DISTRIBUTED", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "MEMORY", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "LOG", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "TINYLOG", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "STRIPELOG", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},

// ClickHouse data types (as keywords)
{Word: "FIXEDSTRING", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "LOWCARDINALITY", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "NULLABLE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "DATETIME64", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "IPV4", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
{Word: "IPV6", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},

// JOIN modifiers
{Word: "PASTE", Type: models.TokenTypeKeyword, Reserved: false, ReservedForTableAlias: false},
}
57 changes: 57 additions & 0 deletions pkg/sql/keywords/clickhouse_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright 2026 GoSQLX Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package keywords_test

import (
"testing"

"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
)

func TestClickHouseDialectKeywords(t *testing.T) {
kws := keywords.DialectKeywords(keywords.DialectClickHouse)
if len(kws) == 0 {
t.Fatal("expected ClickHouse keywords, got none")
}
found := map[string]bool{}
for _, kw := range kws {
found[kw.Word] = true
}
required := []string{"PREWHERE", "FINAL", "ENGINE", "GLOBAL", "ASOF", "TTL", "FORMAT"}
for _, w := range required {
if !found[w] {
t.Errorf("missing expected ClickHouse keyword: %s", w)
}
}
}

func TestClickHouseInAllDialects(t *testing.T) {
found := false
for _, d := range keywords.AllDialects() {
if d == keywords.DialectClickHouse {
found = true
break
}
}
if !found {
t.Error("DialectClickHouse not in AllDialects()")
}
}

func TestIsValidDialectClickHouse(t *testing.T) {
if !keywords.IsValidDialect("clickhouse") {
t.Error("IsValidDialect should return true for 'clickhouse'")
}
}
9 changes: 9 additions & 0 deletions pkg/sql/keywords/dialect.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ const (

// DialectRedshift represents Amazon Redshift-specific keywords and extensions
DialectRedshift SQLDialect = "redshift"

// DialectClickHouse represents ClickHouse-specific keywords and extensions.
// Includes ClickHouse-specific clauses (PREWHERE, FINAL, SAMPLE), engine
// definitions (ENGINE, CODEC, TTL), ClickHouse data types (FixedString,
// LowCardinality, Nullable, DateTime64), and replication keywords (ON CLUSTER, GLOBAL).
DialectClickHouse SQLDialect = "clickhouse"
)

// DialectKeywords returns the additional keywords for a specific dialect.
Expand Down Expand Up @@ -86,6 +92,8 @@ func DialectKeywords(dialect SQLDialect) []Keyword {
return SQLSERVER_SPECIFIC
case DialectOracle:
return ORACLE_SPECIFIC
case DialectClickHouse:
return CLICKHOUSE_SPECIFIC
default:
return nil
}
Expand Down Expand Up @@ -131,6 +139,7 @@ func AllDialects() []SQLDialect {
DialectSnowflake,
DialectBigQuery,
DialectRedshift,
DialectClickHouse,
}
}

Expand Down
2 changes: 2 additions & 0 deletions pkg/sql/keywords/keywords.go
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,8 @@ func New(dialect SQLDialect, ignoreCase bool) *Keywords {
k.addKeywordsWithCategory(SQLITE_SPECIFIC)
case DialectSnowflake:
k.addKeywordsWithCategory(SNOWFLAKE_SPECIFIC)
case DialectClickHouse:
k.addKeywordsWithCategory(CLICKHOUSE_SPECIFIC)
}

// Build O(1) lookup cache for compound keyword first-words
Expand Down
1 change: 1 addition & 0 deletions pkg/sql/keywords/snowflake_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,7 @@ func TestDialectRegistry(t *testing.T) {
DialectSnowflake: false,
DialectBigQuery: false,
DialectRedshift: false,
DialectClickHouse: false,
}

for _, d := range dialects {
Expand Down
Loading
Loading