diff --git a/pkg/sql/parser/clickhouse_settings_ttl_format_test.go b/pkg/sql/parser/clickhouse_settings_ttl_format_test.go new file mode 100644 index 00000000..7f00b8ff --- /dev/null +++ b/pkg/sql/parser/clickhouse_settings_ttl_format_test.go @@ -0,0 +1,56 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); + +package parser_test + +import ( + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/gosqlx" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +// TestClickHouseSettingsTTLFormat verifies ClickHouse tail clauses parse for +// both CREATE TABLE and SELECT/INSERT. Regression for #482. +func TestClickHouseSettingsTTLFormat(t *testing.T) { + queries := map[string]string{ + "create_table_with_settings": `CREATE TABLE events ( + id UInt64, + ts DateTime + ) ENGINE = MergeTree() + ORDER BY ts + SETTINGS index_granularity = 8192, storage_policy = 'default'`, + + "create_table_with_ttl": `CREATE TABLE logs ( + event_date Date, + message String + ) ENGINE = MergeTree() + ORDER BY event_date + TTL event_date + INTERVAL 90 DAY`, + + "create_table_ttl_then_settings": `CREATE TABLE logs ( + event_date Date, + message String + ) ENGINE = MergeTree() + ORDER BY event_date + TTL event_date + INTERVAL 90 DAY + SETTINGS index_granularity = 8192`, + + "select_with_settings": `SELECT * FROM events + WHERE id > 100 + SETTINGS max_threads = 4, distributed_aggregation_memory_efficient = 1`, + + "insert_format_values": `INSERT INTO events (id, name) VALUES (1, 'a') FORMAT Values`, + + "insert_format_json_each_row": `INSERT INTO events FORMAT JSONEachRow`, + } + for name, q := range queries { + q := q + t.Run(name, func(t *testing.T) { + if _, err := gosqlx.ParseWithDialect(q, keywords.DialectClickHouse); err != nil { + t.Fatalf("parse failed: %v", err) + } + }) + } +} diff --git a/pkg/sql/parser/ddl.go b/pkg/sql/parser/ddl.go index 52dbe355..879070ed 100644 --- a/pkg/sql/parser/ddl.go +++ b/pkg/sql/parser/ddl.go @@ -297,6 +297,31 @@ func (p *Parser) parseCreateTable(temporary bool) (*ast.CreateTableStatement, er } continue } + if p.isTokenMatch("TTL") { + p.advance() + if err := p.skipClickHouseClauseExpr(); err != nil { + return nil, err + } + continue + } + if p.isTokenMatch("SETTINGS") { + p.advance() + // SETTINGS is a comma-separated list of name=value assignments. + // Consume each k=v pair until the next clause, EOF, or ';'. + for { + t := p.currentToken.Token.Type + val := strings.ToUpper(p.currentToken.Token.Value) + if t == models.TokenTypeEOF || t == models.TokenTypeSemicolon { + break + } + if val == "ORDER" || val == "PARTITION" || val == "PRIMARY" || + val == "SAMPLE" || val == "TTL" { + break + } + p.advance() + } + continue + } break } diff --git a/pkg/sql/parser/dml_insert.go b/pkg/sql/parser/dml_insert.go index fd72261d..2290ec47 100644 --- a/pkg/sql/parser/dml_insert.go +++ b/pkg/sql/parser/dml_insert.go @@ -83,6 +83,21 @@ func (p *Parser) parseInsertStatement() (ast.Statement, error) { var values [][]ast.Expression var query ast.QueryExpression + // ClickHouse-only: INSERT INTO t [(cols)] FORMAT with the data + // payload being external (network / file). Short-circuit here — there is + // no VALUES or SELECT to follow. + if p.dialect == string(keywords.DialectClickHouse) && + strings.EqualFold(p.currentToken.Token.Value, "FORMAT") { + p.advance() // Consume FORMAT + if p.isIdentifier() || p.isType(models.TokenTypeKeyword) { + p.advance() // Consume format name + } + return &ast.InsertStatement{ + TableName: tableName, + Columns: columns, + }, nil + } + switch { case p.isType(models.TokenTypeSelect): // INSERT ... SELECT syntax @@ -190,6 +205,17 @@ func (p *Parser) parseInsertStatement() (ast.Statement, error) { } } + // ClickHouse INSERT ... VALUES (...) FORMAT trailing format hint. + // Parse-only; the trailing payload is not consumed. + if p.dialect == string(keywords.DialectClickHouse) && + strings.EqualFold(p.currentToken.Token.Value, "FORMAT") { + p.advance() // Consume FORMAT + if p.isIdentifier() || p.isType(models.TokenTypeKeyword) || + p.isType(models.TokenTypeValues) { + p.advance() // Consume format name (may tokenize as VALUES keyword) + } + } + // Create INSERT statement return &ast.InsertStatement{ TableName: tableName, diff --git a/pkg/sql/parser/parser.go b/pkg/sql/parser/parser.go index 67eab498..f1865571 100644 --- a/pkg/sql/parser/parser.go +++ b/pkg/sql/parser/parser.go @@ -591,6 +591,19 @@ func (p *Parser) parseStatement() (ast.Statement, error) { ss.Pos = stmtPos } } + // ClickHouse trailing SETTINGS k=v [, k=v]... on SELECT. Parse-only; + // the settings are consumed but not modeled on the AST. + if p.dialect == string(keywords.DialectClickHouse) && p.isTokenMatch("SETTINGS") { + p.advance() // SETTINGS + for { + t := p.currentToken.Token.Type + if t == models.TokenTypeEOF || t == models.TokenTypeSemicolon || + t == models.TokenTypeRParen { + break + } + p.advance() + } + } return stmt, nil case models.TokenTypeInsert: stmtPos := p.currentLocation()