diff --git a/pkg/sql/ast/ast_clauses.go b/pkg/sql/ast/ast_clauses.go index a1ea46c1..392870a0 100644 --- a/pkg/sql/ast/ast_clauses.go +++ b/pkg/sql/ast/ast_clauses.go @@ -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() {} diff --git a/pkg/sql/ast/sql.go b/pkg/sql/ast/sql.go index 9569cfe3..efb7ac0a 100644 --- a/pkg/sql/ast/sql.go +++ b/pkg/sql/ast/sql.go @@ -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)) @@ -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) @@ -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() +} diff --git a/pkg/sql/parser/clickhouse_sample_withfill_test.go b/pkg/sql/parser/clickhouse_sample_withfill_test.go new file mode 100644 index 00000000..c612e155 --- /dev/null +++ b/pkg/sql/parser/clickhouse_sample_withfill_test.go @@ -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) + } + }) + } +} diff --git a/pkg/sql/parser/select_clauses.go b/pkg/sql/parser/select_clauses.go index d1b1b2a2..0d6cbd88 100644 --- a/pkg/sql/parser/select_clauses.go +++ b/pkg/sql/parser/select_clauses.go @@ -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) @@ -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 } } }