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: 9 additions & 1 deletion Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,15 @@ tasks:
desc: Run tests with race detection (CRITICAL for production)
cmds:
- echo "Running tests with race detection..."
- go test -race -timeout 60s ./...
- go test -race -timeout 60s $(go list ./... | grep -v /cbinding)

test:cbinding:
desc: Test C binding package (requires CGO)
env:
CGO_ENABLED: '1'
cmds:
- echo "Running cbinding tests with CGO enabled..."
- go test -race -timeout 60s ./pkg/cbinding/...

test:pkg:
desc: Run tests for a specific package (use PKG=./pkg/sql/parser)
Expand Down
20 changes: 18 additions & 2 deletions pkg/sql/ast/ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ type CommonTableExpr struct {
Name string
Columns []string
Statement Statement
ScalarExpr Expression // ClickHouse: WITH <expr> AS <name> (scalar CTE, no subquery)
Materialized *bool // nil = default, true = MATERIALIZED, false = NOT MATERIALIZED
Pos models.Location // Source position of the CTE name (1-based line and column)
}
Expand Down Expand Up @@ -429,8 +430,9 @@ type SelectStatement struct {
From []TableReference
TableName string // Added for pool operations
Joins []JoinClause
PrewhereClause Expression // ClickHouse PREWHERE clause (applied before WHERE, before reading data)
Sample *SampleClause // ClickHouse SAMPLE clause (comes after FROM/FINAL, before PREWHERE)
ArrayJoin *ArrayJoinClause // ClickHouse ARRAY JOIN / LEFT ARRAY JOIN clause
PrewhereClause Expression // ClickHouse PREWHERE clause (applied before WHERE, before reading data)
Sample *SampleClause // ClickHouse SAMPLE clause (comes after FROM/FINAL, before PREWHERE)
Where Expression
GroupBy []Expression
Having Expression
Expand Down Expand Up @@ -2294,6 +2296,20 @@ func (c ConnectByClause) Children() []Node {
// via TABLESAMPLE, but this implementation targets SAMPLE).
// Value is stored as a raw string to preserve the original representation
// (e.g., "0.1", "1000", "1/10").
// ArrayJoinClause represents a ClickHouse ARRAY JOIN or LEFT ARRAY JOIN clause.
// Syntax: [LEFT] ARRAY JOIN expr [AS alias], expr [AS alias], ...
type ArrayJoinClause struct {
Left bool // true for LEFT ARRAY JOIN
Elements []ArrayJoinElement // One or more join elements
Pos models.Location
}

// ArrayJoinElement is a single expression in an ARRAY JOIN clause with an optional alias.
type ArrayJoinElement struct {
Expr Expression
Alias string
}

type SampleClause struct {
// Value is the sampling size/ratio as a raw token string (e.g., "0.1", "1000", "1/10").
Value string
Expand Down
101 changes: 101 additions & 0 deletions pkg/sql/ast/sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,23 @@ func (s *SelectStatement) SQL() string {
sb.WriteString(joinSQL(&j))
}

if s.ArrayJoin != nil {
if s.ArrayJoin.Left {
sb.WriteString(" LEFT ARRAY JOIN ")
} else {
sb.WriteString(" ARRAY JOIN ")
}
elems := make([]string, len(s.ArrayJoin.Elements))
for i, e := range s.ArrayJoin.Elements {
elemStr := exprSQL(e.Expr)
if e.Alias != "" {
elemStr += " AS " + e.Alias
}
elems[i] = elemStr
}
sb.WriteString(strings.Join(elems, ", "))
}

if s.PrewhereClause != nil {
sb.WriteString(" PREWHERE ")
sb.WriteString(exprSQL(s.PrewhereClause))
Expand Down Expand Up @@ -1406,6 +1423,13 @@ func forSQL(f *ForClause) string {
func cteSQL(cte *CommonTableExpr) string {
sb := getBuilder()
defer putBuilder(sb)
// ClickHouse scalar CTE: WITH <expr> AS <name>
if cte.ScalarExpr != nil {
sb.WriteString(exprSQL(cte.ScalarExpr))
sb.WriteString(" AS ")
sb.WriteString(cte.Name)
return sb.String()
}
sb.WriteString(cte.Name)
if len(cte.Columns) > 0 {
sb.WriteString(" (")
Expand Down Expand Up @@ -1740,6 +1764,83 @@ func (p *PeriodDefinition) SQL() string {
return b.String()
}

// SQL returns the SQL string for a PRAGMA statement (SQLite).
func (p *PragmaStatement) SQL() string {
if p == nil {
return ""
}
sb := getBuilder()
defer putBuilder(sb)
sb.WriteString("PRAGMA ")
sb.WriteString(p.Name)
if p.Arg != "" {
sb.WriteString("(")
sb.WriteString(p.Arg)
sb.WriteString(")")
} else if p.Value != "" {
sb.WriteString(" = ")
sb.WriteString(p.Value)
}
return sb.String()
}

// SQL returns the SQL string for a SHOW statement (MySQL).
func (s *ShowStatement) SQL() string {
if s == nil {
return ""
}
sb := getBuilder()
defer putBuilder(sb)
sb.WriteString("SHOW ")
sb.WriteString(s.ShowType)
if s.ObjectName != "" {
sb.WriteString(" ")
sb.WriteString(s.ObjectName)
}
if s.From != "" {
sb.WriteString(" FROM ")
sb.WriteString(s.From)
}
return sb.String()
}

// SQL returns the SQL string for a DESCRIBE statement (MySQL).
func (d *DescribeStatement) SQL() string {
if d == nil {
return ""
}
return "DESCRIBE " + d.TableName
}

// SQL returns the SQL string for a REPLACE statement (MySQL).
func (r *ReplaceStatement) SQL() string {
if r == nil {
return ""
}
sb := getBuilder()
defer putBuilder(sb)
sb.WriteString("REPLACE INTO ")
sb.WriteString(r.TableName)
if len(r.Columns) > 0 {
sb.WriteString(" (")
sb.WriteString(exprListSQL(r.Columns))
sb.WriteString(")")
}
if len(r.Values) > 0 {
sb.WriteString(" VALUES ")
rows := make([]string, len(r.Values))
for idx, row := range r.Values {
vals := make([]string, len(row))
for j, v := range row {
vals[j] = exprSQL(v)
}
rows[idx] = "(" + strings.Join(vals, ", ") + ")"
}
sb.WriteString(strings.Join(rows, ", "))
}
return sb.String()
}

// ToSQL returns the SQL string for a CONNECT BY clause (MariaDB 10.2+).
func (c *ConnectByClause) ToSQL() string {
var b strings.Builder
Expand Down
26 changes: 26 additions & 0 deletions pkg/sql/parser/cte.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
goerrors "github.com/ajitpratap0/GoSQLX/pkg/errors"
"github.com/ajitpratap0/GoSQLX/pkg/models"
"github.com/ajitpratap0/GoSQLX/pkg/sql/ast"
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
)

// WITH summary(region, total) AS (SELECT region, SUM(amount) FROM sales GROUP BY region) SELECT * FROM summary
Expand Down Expand Up @@ -124,6 +125,31 @@ func (p *Parser) parseCommonTableExpr() (*ast.CommonTableExpr, error) {
)
}

// ClickHouse scalar CTE: WITH <expr> AS <name>, ...
// Detected when the token after WITH is not an identifier, or is an
// identifier not followed by AS/( (which would be a standard CTE).
if p.dialect == string(keywords.DialectClickHouse) && !p.isIdentifier() {
scalarExpr, err := p.parseExpression()
if err != nil {
return nil, err
}
if !p.isType(models.TokenTypeAs) {
return nil, p.expectedError("AS after scalar CTE expression")
}
p.advance() // Consume AS
if !p.isIdentifier() {
return nil, p.expectedError("name after AS in scalar CTE")
}
scalarName := p.currentToken.Token.Value
scalarPos := p.currentLocation()
p.advance()
return &ast.CommonTableExpr{
Name: scalarName,
ScalarExpr: scalarExpr,
Pos: scalarPos,
}, nil
}

// Parse CTE name (supports double-quoted identifiers)
if !p.isIdentifier() {
return nil, p.expectedError("CTE name")
Expand Down
11 changes: 11 additions & 0 deletions pkg/sql/parser/ddl.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ func (p *Parser) parseCreateTable(temporary bool) (*ast.CreateTableStatement, er
}

// CREATE TABLE ... AS SELECT — no column list, just a query.
// ClickHouse also: CREATE TABLE t AS source_table ENGINE = ...
if p.isType(models.TokenTypeAs) {
p.advance() // AS
if p.isType(models.TokenTypeSelect) || p.isType(models.TokenTypeWith) {
Expand All @@ -210,6 +211,16 @@ func (p *Parser) parseCreateTable(temporary bool) (*ast.CreateTableStatement, er
_ = query // CTAS query not modeled on CreateTableStatement yet
return stmt, nil
}
// ClickHouse: CREATE TABLE t AS <source_table> ENGINE = ...
// The identifier is the source table; consume remaining clauses.
if p.dialect == string(keywords.DialectClickHouse) && p.isIdentifier() {
p.advance() // Consume source table name
// Consume ENGINE and trailing clauses
for !p.isType(models.TokenTypeEOF) && !p.isType(models.TokenTypeSemicolon) {
p.advance()
}
return stmt, nil
}
return nil, p.expectedError("SELECT after AS")
}

Expand Down
21 changes: 21 additions & 0 deletions pkg/sql/parser/ddl_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
goerrors "github.com/ajitpratap0/GoSQLX/pkg/errors"
"github.com/ajitpratap0/GoSQLX/pkg/models"
"github.com/ajitpratap0/GoSQLX/pkg/sql/ast"
"github.com/ajitpratap0/GoSQLX/pkg/sql/keywords"
)

// parseCreateView parses CREATE [OR REPLACE] [TEMPORARY] VIEW statement
Expand Down Expand Up @@ -187,6 +188,26 @@ func (p *Parser) parseCreateMaterializedView() (*ast.CreateMaterializedViewState
p.advance()
}

// ClickHouse: optional TO <table> before ENGINE/AS
if p.dialect == string(keywords.DialectClickHouse) && p.isType(models.TokenTypeTo) {
p.advance() // Consume TO
toName, toErr := p.parseQualifiedName()
if toErr != nil {
return nil, p.expectedError("target table after TO")
}
stmt.Tablespace = toName // reuse Tablespace for ClickHouse TO
}

// ClickHouse: optional ENGINE = ... ORDER BY ... before AS SELECT
if p.dialect == string(keywords.DialectClickHouse) {
for p.isTokenMatch("ENGINE") || p.isType(models.TokenTypeOrder) || p.isTokenMatch("PRIMARY") || p.isTokenMatch("PARTITION") || p.isTokenMatch("SETTINGS") {
// Consume all engine clauses token-by-token until AS
for !p.isType(models.TokenTypeAs) && !p.isType(models.TokenTypeEOF) && !p.isType(models.TokenTypeSemicolon) {
p.advance()
}
}
}

// Expect AS
if !p.isType(models.TokenTypeAs) {
return nil, p.expectedError("AS")
Expand Down
6 changes: 4 additions & 2 deletions pkg/sql/parser/dml_insert.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,11 @@ func (p *Parser) parseInsertStatement() (ast.Statement, error) {
p.advance() // Consume )
}

// Parse SQL Server OUTPUT clause (between column list and VALUES)
// Parse SQL Server OUTPUT clause (between column list and VALUES).
// Accept OUTPUT regardless of dialect — the keyword is unambiguous here
// and allows dialect-agnostic parsing of T-SQL INSERT statements.
var outputCols []ast.Expression
if p.dialect == string(keywords.DialectSQLServer) && strings.ToUpper(p.currentToken.Token.Value) == "OUTPUT" {
if strings.ToUpper(p.currentToken.Token.Value) == "OUTPUT" {
p.advance() // Consume OUTPUT
var err error
outputCols, err = p.parseOutputColumns()
Expand Down
23 changes: 23 additions & 0 deletions pkg/sql/parser/expressions_literal.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,29 @@ func (p *Parser) parsePrimaryExpression() (ast.Expression, error) {
return p.parseExtractExpression()
}

// Oracle/MariaDB pseudo-columns: ROWNUM, ROWID, LEVEL, SYSDATE, SYSTIMESTAMP.
// These are tokenized as keywords but act as column-like expressions.
// We return them as zero-argument FunctionCall nodes so that implicit
// aliasing works naturally (SELECT ROWNUM rn → AliasedExpression) and
// they don't collide with the bare-Identifier alias guard.
if p.isType(models.TokenTypeKeyword) && p.isOraclePseudoColumn() {
identPos := p.currentLocation()
identName := p.currentToken.Token.Value
p.advance()
// SYSDATE() / SYSTIMESTAMP() — some drivers allow parens
if p.isType(models.TokenTypeLParen) {
funcCall, err := p.parseFunctionCall(identName)
if err != nil {
return nil, err
}
if funcCall.Pos.IsZero() {
funcCall.Pos = identPos
}
return funcCall, nil
}
return &ast.FunctionCall{Name: identName, Pos: identPos}, nil
}

if p.isType(models.TokenTypeIdentifier) || p.isType(models.TokenTypeDoubleQuotedString) || ((p.dialect == string(keywords.DialectSQLServer) || p.dialect == string(keywords.DialectClickHouse)) && p.isNonReservedKeyword()) {
// Handle identifiers and function calls
// Double-quoted strings are treated as identifiers in SQL (e.g., "column_name")
Expand Down
Loading
Loading