Skip to content
Open
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
27 changes: 27 additions & 0 deletions pkg/sql/ast/ast_clauses.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,33 @@ type OrderByExpression struct {
Expression Expression // The expression to order by
Ascending bool // true for ASC (default), false for DESC
NullsFirst *bool // nil = default behavior, true = NULLS FIRST, false = NULLS LAST
// WithFill — ClickHouse "WITH FILL FROM x TO y STEP z" modifier.
// Fields are nil when the corresponding part is absent.
WithFill *WithFillClause
}

// WithFillClause — ClickHouse "WITH FILL [FROM x] [TO y] [STEP z]".
type WithFillClause struct {
From Expression
To Expression
Step Expression
Pos models.Location
}

func (w *WithFillClause) expressionNode() {}
func (w WithFillClause) TokenLiteral() string { return "WITH FILL" }
func (w WithFillClause) Children() []Node {
var out []Node
if w.From != nil {
out = append(out, w.From)
}
if w.To != nil {
out = append(out, w.To)
}
if w.Step != nil {
out = append(out, w.Step)
}
return out
}

func (*OrderByExpression) expressionNode() {}
Expand Down
55 changes: 55 additions & 0 deletions pkg/sql/ast/sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -579,6 +579,11 @@ func (s *SelectStatement) SQL() string {
sb.WriteString(strings.Join(elems, ", "))
}

if s.Sample != nil {
sb.WriteString(" ")
sb.WriteString(s.Sample.SQL())
}

if s.PrewhereClause != nil {
sb.WriteString(" PREWHERE ")
sb.WriteString(exprSQL(s.PrewhereClause))
Expand Down Expand Up @@ -1311,11 +1316,36 @@ func orderBySQL(orders []OrderByExpression) string {
s += " NULLS LAST"
}
}
if o.WithFill != nil {
s += withFillSQL(o.WithFill)
}
parts[i] = s
}
return strings.Join(parts, ", ")
}

// withFillSQL рендерит ClickHouse "WITH FILL [FROM x] [TO y] [STEP z]".
func withFillSQL(w *WithFillClause) string {
if w == nil {
return ""
}
var b strings.Builder
b.WriteString(" WITH FILL")
if w.From != nil {
b.WriteString(" FROM ")
b.WriteString(exprSQL(w.From))
}
if w.To != nil {
b.WriteString(" TO ")
b.WriteString(exprSQL(w.To))
}
if w.Step != nil {
b.WriteString(" STEP ")
b.WriteString(exprSQL(w.Step))
}
return b.String()
}

func tableRefSQL(t *TableReference) string {
sb := getBuilder()
defer putBuilder(sb)
Expand Down Expand Up @@ -1851,3 +1881,28 @@ func (c *ConnectByClause) ToSQL() string {
b.WriteString(exprSQL(c.Condition))
return b.String()
}

// SQL возвращает ClickHouse SAMPLE-секцию, например "SAMPLE 0.1" или
// "SAMPLE 1/10 OFFSET 2/10".
func (s *SampleClause) SQL() string {
if s == nil {
return ""
}
sb := getBuilder()
defer putBuilder(sb)
sb.WriteString("SAMPLE ")
v := s.Value
if s.Value != "" && s.Denominator != "" {
v = s.Value + "/" + s.Denominator
}
sb.WriteString(v)
if s.Offset != "" {
sb.WriteString(" OFFSET ")
o := s.Offset
if s.OffsetDenominator != "" {
o = s.Offset + "/" + s.OffsetDenominator
}
sb.WriteString(o)
}
return sb.String()
}
49 changes: 49 additions & 0 deletions pkg/sql/parser/clickhouse_sample_withfill_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// 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 parser_test

import (
"testing"

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

// Regression: ClickHouse SAMPLE and ORDER BY ... WITH FILL must round-trip.
// They were dropped on render (SAMPLE lost; WITH FILL skipped by the parser).
func TestClickHouseSampleAndWithFillRoundtrip(t *testing.T) {
tests := []struct {
name string
sql string
}{
{"sample_ratio", `SELECT category, count() FROM omi_reporting SAMPLE 0.1 GROUP BY category`},
{"sample_frac", `SELECT a FROM t SAMPLE 1/10`},
{"sample_offset", `SELECT a FROM t SAMPLE 1/10 OFFSET 2/10`},
{"with_fill_step", `SELECT id FROM t ORDER BY id WITH FILL STEP 1`},
{"with_fill_full", `SELECT day, count() FROM events GROUP BY day ORDER BY day WITH FILL FROM '2024-01-01' TO '2024-12-31' STEP 1`},
{"with_fill_desc", `SELECT id FROM t ORDER BY id DESC WITH FILL STEP 1`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tree, err := gosqlx.ParseWithDialect(tt.sql, keywords.DialectClickHouse)
if err != nil {
t.Fatalf("parse failed: %v", err)
}
if got := tree.SQL(); got != tt.sql {
t.Errorf("SQL() = %q, want %q", got, tt.sql)
}
})
}
}
54 changes: 36 additions & 18 deletions pkg/sql/parser/select_clauses.go
Original file line number Diff line number Diff line change
Expand Up @@ -563,13 +563,14 @@ func (p *Parser) parseOrderByClause() ([]ast.OrderByExpression, error) {

// ClickHouse WITH FILL per-entry tail:
// ORDER BY expr [ASC|DESC] WITH FILL [FROM x] [TO y] [STEP z]
// Consume permissively; the clause is not modeled on the AST yet.
if p.dialect == string(keywords.DialectClickHouse) &&
p.isType(models.TokenTypeWith) &&
strings.EqualFold(p.peekToken().Token.Value, "FILL") {
p.advance() // WITH
p.advance() // FILL
p.skipClickHouseWithFillTail()
fill, err := p.parseClickHouseWithFill()
if err != nil {
return nil, err
}
entry.WithFill = fill
}

orderByExprs = append(orderByExprs, entry)
Expand All @@ -582,22 +583,39 @@ func (p *Parser) parseOrderByClause() ([]ast.OrderByExpression, error) {
return orderByExprs, nil
}

// skipClickHouseWithFillTail consumes the optional FROM / TO / STEP arguments
// of a ClickHouse "ORDER BY expr WITH FILL" modifier. Each argument is a
// single expression (possibly an INTERVAL). The tail ends at the next comma
// (more ORDER BY items), next clause keyword, ';', or EOF.
func (p *Parser) skipClickHouseWithFillTail() {
// parseClickHouseWithFill consumes "WITH FILL [FROM x] [TO y] [STEP z]" and
// models it into a *ast.WithFillClause so it can round-trip.
func (p *Parser) parseClickHouseWithFill() (*ast.WithFillClause, error) {
pos := p.currentLocation()
p.advance() // WITH
p.advance() // FILL
fill := &ast.WithFillClause{Pos: pos}
for {
val := strings.ToUpper(p.currentToken.Token.Value)
if val != "FROM" && val != "TO" && val != "STEP" {
return
}
p.advance() // FROM / TO / STEP
// Consume one expression; ignore parse errors so unusual forms
// (INTERVAL '1 day', expressions with function calls, etc.) don't
// surface as parser errors for this permissive skip.
if _, err := p.parseExpression(); err != nil {
return
switch val {
case "FROM":
p.advance()
e, err := p.parseExpression()
if err != nil {
return nil, err
}
fill.From = e
case "TO":
p.advance()
e, err := p.parseExpression()
if err != nil {
return nil, err
}
fill.To = e
case "STEP":
p.advance()
e, err := p.parseExpression()
if err != nil {
return nil, err
}
fill.Step = e
default:
return fill, nil
}
}
}
Expand Down
Loading