diff --git a/pkg/formatter/dialect_render_test.go b/pkg/formatter/dialect_render_test.go new file mode 100644 index 00000000..48324965 --- /dev/null +++ b/pkg/formatter/dialect_render_test.go @@ -0,0 +1,253 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); + +package formatter + +import ( + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" +) + +func TestDialectRenderSelect_TopClause(t *testing.T) { + // Parsed TOP clause should always render, even without a dialect. + limit := 10 + stmt := &ast.SelectStatement{ + Top: &ast.TopClause{ + Count: &ast.LiteralValue{Value: 100, Type: "int"}, + }, + Columns: []ast.Expression{&ast.Identifier{Name: "*"}}, + From: []ast.TableReference{{Name: "users"}}, + Limit: &limit, // Both TOP and LIMIT present + } + + got := FormatStatement(stmt, ast.CompactStyle()) + if !strings.Contains(got, "TOP 100") { + t.Errorf("expected TOP 100 in output, got: %s", got) + } + // LIMIT should also render since no dialect normalization removes it + if !strings.Contains(got, "LIMIT 10") { + t.Errorf("expected LIMIT 10 in output, got: %s", got) + } +} + +func TestDialectRenderSelect_TopPercent(t *testing.T) { + stmt := &ast.SelectStatement{ + Top: &ast.TopClause{ + Count: &ast.LiteralValue{Value: 10, Type: "int"}, + IsPercent: true, + WithTies: true, + }, + Columns: []ast.Expression{&ast.Identifier{Name: "*"}}, + From: []ast.TableReference{{Name: "orders"}}, + } + + got := FormatStatement(stmt, ast.CompactStyle()) + if !strings.Contains(got, "TOP 10 PERCENT WITH TIES") { + t.Errorf("expected TOP 10 PERCENT WITH TIES, got: %s", got) + } +} + +func TestDialectRenderSelect_LimitToTop(t *testing.T) { + // SQL Server dialect should convert LIMIT to TOP + limit := 50 + stmt := &ast.SelectStatement{ + Columns: []ast.Expression{&ast.Identifier{Name: "*"}}, + From: []ast.TableReference{{Name: "users"}}, + Limit: &limit, + } + + opts := ast.CompactStyle() + opts.Dialect = "sqlserver" + got := FormatStatement(stmt, opts) + + if !strings.Contains(got, "TOP 50") { + t.Errorf("sqlserver: expected TOP 50, got: %s", got) + } + if strings.Contains(got, "LIMIT") { + t.Errorf("sqlserver: should not contain LIMIT, got: %s", got) + } +} + +func TestDialectRenderSelect_LimitToFetch(t *testing.T) { + // Oracle dialect should convert LIMIT to FETCH FIRST + limit := 100 + stmt := &ast.SelectStatement{ + Columns: []ast.Expression{&ast.Identifier{Name: "*"}}, + From: []ast.TableReference{{Name: "users"}}, + Limit: &limit, + } + + opts := ast.CompactStyle() + opts.Dialect = "oracle" + got := FormatStatement(stmt, opts) + + if !strings.Contains(got, "FETCH FIRST 100 ROWS ONLY") { + t.Errorf("oracle: expected FETCH FIRST 100 ROWS ONLY, got: %s", got) + } + if strings.Contains(got, "LIMIT") { + t.Errorf("oracle: should not contain LIMIT, got: %s", got) + } +} + +func TestDialectRenderSelect_LimitOffsetOracle(t *testing.T) { + // Oracle: LIMIT + OFFSET -> OFFSET n ROWS FETCH FIRST m ROWS ONLY + limit := 10 + offset := 20 + stmt := &ast.SelectStatement{ + Columns: []ast.Expression{&ast.Identifier{Name: "*"}}, + From: []ast.TableReference{{Name: "users"}}, + Limit: &limit, + Offset: &offset, + } + + opts := ast.CompactStyle() + opts.Dialect = "oracle" + got := FormatStatement(stmt, opts) + + if !strings.Contains(got, "OFFSET 20 ROWS") { + t.Errorf("oracle: expected OFFSET 20 ROWS, got: %s", got) + } + if !strings.Contains(got, "FETCH FIRST 10 ROWS ONLY") { + t.Errorf("oracle: expected FETCH FIRST 10 ROWS ONLY, got: %s", got) + } + if strings.Contains(got, "LIMIT") { + t.Errorf("oracle: should not contain LIMIT, got: %s", got) + } +} + +func TestDialectRenderSelect_LimitOffsetSQLServer(t *testing.T) { + // SQL Server with OFFSET uses OFFSET/FETCH NEXT syntax + limit := 10 + offset := 20 + stmt := &ast.SelectStatement{ + Columns: []ast.Expression{&ast.Identifier{Name: "*"}}, + From: []ast.TableReference{{Name: "users"}}, + OrderBy: []ast.OrderByExpression{ + {Expression: &ast.Identifier{Name: "id"}, Ascending: true}, + }, + Limit: &limit, + Offset: &offset, + } + + opts := ast.CompactStyle() + opts.Dialect = "sqlserver" + got := FormatStatement(stmt, opts) + + if !strings.Contains(got, "OFFSET 20 ROWS") { + t.Errorf("sqlserver: expected OFFSET 20 ROWS, got: %s", got) + } + if !strings.Contains(got, "FETCH NEXT 10 ROWS ONLY") { + t.Errorf("sqlserver: expected FETCH NEXT 10 ROWS ONLY, got: %s", got) + } + if strings.Contains(got, "TOP") { + t.Errorf("sqlserver with offset: should not contain TOP, got: %s", got) + } + if strings.Contains(got, "LIMIT") { + t.Errorf("sqlserver: should not contain LIMIT, got: %s", got) + } +} + +func TestDialectRenderSelect_PostgreSQLUnchanged(t *testing.T) { + // PostgreSQL should keep LIMIT/OFFSET as-is + limit := 10 + offset := 5 + stmt := &ast.SelectStatement{ + Columns: []ast.Expression{&ast.Identifier{Name: "id"}, &ast.Identifier{Name: "name"}}, + From: []ast.TableReference{{Name: "users"}}, + Limit: &limit, + Offset: &offset, + } + + opts := ast.CompactStyle() + opts.Dialect = "postgresql" + got := FormatStatement(stmt, opts) + + if !strings.Contains(got, "LIMIT 10") { + t.Errorf("postgresql: expected LIMIT 10, got: %s", got) + } + if !strings.Contains(got, "OFFSET 5") { + t.Errorf("postgresql: expected OFFSET 5, got: %s", got) + } +} + +func TestDialectRenderSelect_GenericPreservesExistingTop(t *testing.T) { + // When no dialect is set, a parsed TopClause should still render. + stmt := &ast.SelectStatement{ + Top: &ast.TopClause{ + Count: &ast.LiteralValue{Value: 5, Type: "int"}, + }, + Columns: []ast.Expression{&ast.Identifier{Name: "*"}}, + From: []ast.TableReference{{Name: "t"}}, + } + + got := FormatStatement(stmt, ast.CompactStyle()) + if !strings.Contains(got, "TOP 5") { + t.Errorf("generic: expected TOP 5 in output, got: %s", got) + } +} + +func TestDialectRenderSelect_GenericPreservesExistingFetch(t *testing.T) { + fetchVal := int64(25) + stmt := &ast.SelectStatement{ + Columns: []ast.Expression{&ast.Identifier{Name: "*"}}, + From: []ast.TableReference{{Name: "t"}}, + Fetch: &ast.FetchClause{ + FetchValue: &fetchVal, + FetchType: "FIRST", + }, + } + + got := FormatStatement(stmt, ast.CompactStyle()) + if !strings.Contains(got, "FETCH FIRST 25 ROWS ONLY") { + t.Errorf("generic: expected FETCH FIRST, got: %s", got) + } +} + +func TestDialectRenderSelect_KeywordCasing(t *testing.T) { + limit := 10 + stmt := &ast.SelectStatement{ + Columns: []ast.Expression{&ast.Identifier{Name: "*"}}, + From: []ast.TableReference{{Name: "users"}}, + Limit: &limit, + } + + opts := ast.FormatOptions{ + KeywordCase: ast.KeywordUpper, + Dialect: "sqlserver", + } + got := FormatStatement(stmt, opts) + if !strings.Contains(got, "SELECT TOP 10") { + t.Errorf("expected uppercase SELECT TOP, got: %s", got) + } + + opts.KeywordCase = ast.KeywordLower + got = FormatStatement(stmt, opts) + if !strings.Contains(got, "select top 10") { + t.Errorf("expected lowercase select top, got: %s", got) + } +} + +func TestDialectRenderSelect_OriginalASTNotMutated(t *testing.T) { + // Verify that dialect normalization does not mutate the original AST. + limit := 50 + stmt := &ast.SelectStatement{ + Columns: []ast.Expression{&ast.Identifier{Name: "*"}}, + From: []ast.TableReference{{Name: "users"}}, + Limit: &limit, + } + + opts := ast.CompactStyle() + opts.Dialect = "sqlserver" + _ = FormatStatement(stmt, opts) + + // Original should still have Limit set and Top nil + if stmt.Limit == nil { + t.Error("original AST Limit was mutated to nil") + } + if stmt.Top != nil { + t.Error("original AST Top was mutated (should remain nil)") + } +} diff --git a/pkg/formatter/formatter.go b/pkg/formatter/formatter.go index 0ba2d61f..ed7e9c1f 100644 --- a/pkg/formatter/formatter.go +++ b/pkg/formatter/formatter.go @@ -101,9 +101,10 @@ import ( // Options configures SQL formatting behaviour. type Options struct { - IndentSize int // spaces per indent level (default 2) - Uppercase bool // uppercase SQL keywords - Compact bool // single-line output + IndentSize int // spaces per indent level (default 2) + Uppercase bool // uppercase SQL keywords + Compact bool // single-line output + Dialect string // target SQL dialect (empty = generic) } // Formatter formats SQL strings. @@ -165,6 +166,7 @@ func (f *Formatter) Format(sql string) (string, error) { if f.opts.Uppercase { style.KeywordCase = ast.KeywordUpper } + style.Dialect = f.opts.Dialect return FormatAST(parsedAST, style), nil } diff --git a/pkg/formatter/render.go b/pkg/formatter/render.go index 29669012..710e4d7b 100644 --- a/pkg/formatter/render.go +++ b/pkg/formatter/render.go @@ -198,6 +198,12 @@ func renderSelect(s *ast.SelectStatement, opts ast.FormatOptions) string { if s == nil { return "" } + + // Dialect normalization: work on a shallow copy so the original AST is not mutated. + stmt := *s + s = &stmt + normalizeSelectForDialect(s, opts.Dialect) + f := newNodeFormatter(opts) sb := f.sb @@ -219,6 +225,22 @@ func renderSelect(s *ast.SelectStatement, opts ast.FormatOptions) string { sb.WriteString(" ") } + // Render TOP clause (SQL Server). Emitted between SELECT [DISTINCT] and columns. + if s.Top != nil { + sb.WriteString(f.kw("TOP")) + sb.WriteString(" ") + sb.WriteString(FormatExpression(s.Top.Count, opts)) + if s.Top.IsPercent { + sb.WriteString(" ") + sb.WriteString(f.kw("PERCENT")) + } + if s.Top.WithTies { + sb.WriteString(" ") + sb.WriteString(f.kw("WITH TIES")) + } + sb.WriteString(" ") + } + sb.WriteString(exprListSQL(s.Columns)) if len(s.From) > 0 { @@ -293,7 +315,7 @@ func renderSelect(s *ast.SelectStatement, opts ast.FormatOptions) string { } if s.Fetch != nil { - sb.WriteString(fetchSQL(s.Fetch)) + sb.WriteString(fetchSQL(s.Fetch, f)) } if s.For != nil { @@ -307,6 +329,55 @@ func renderSelect(s *ast.SelectStatement, opts ast.FormatOptions) string { return f.result() } +// normalizeSelectForDialect converts generic LIMIT/OFFSET fields into +// dialect-specific AST fields (TOP for SQL Server, FETCH for Oracle) on a +// shallow copy of the statement. This keeps the rendering code simple — +// each clause renderer only handles its own field. +func normalizeSelectForDialect(s *ast.SelectStatement, dialect string) { + switch dialect { + case "sqlserver": + if s.Top == nil && s.Limit != nil { + if s.Offset != nil || len(s.OrderBy) > 0 { + // SQL Server 2012+ OFFSET/FETCH syntax (requires ORDER BY in practice, + // but we emit it faithfully and let the database validate). + fetchVal := int64(*s.Limit) + s.Fetch = &ast.FetchClause{ + FetchValue: &fetchVal, + FetchType: "NEXT", + } + offsetVal := int64(0) + if s.Offset != nil { + offsetVal = int64(*s.Offset) + } + s.Fetch.OffsetValue = &offsetVal + s.Limit = nil + s.Offset = nil + } else { + // Simple TOP N + s.Top = &ast.TopClause{ + Count: &ast.LiteralValue{Value: *s.Limit, Type: "int"}, + } + s.Limit = nil + } + } + + case "oracle": + if s.Fetch == nil && s.Limit != nil { + fetchVal := int64(*s.Limit) + s.Fetch = &ast.FetchClause{ + FetchValue: &fetchVal, + FetchType: "FIRST", + } + if s.Offset != nil { + offsetVal := int64(*s.Offset) + s.Fetch.OffsetValue = &offsetVal + s.Offset = nil + } + s.Limit = nil + } + } +} + func renderInsert(i *ast.InsertStatement, opts ast.FormatOptions) string { if i == nil { return "" @@ -1299,24 +1370,32 @@ func windowFrameSQL(f *ast.WindowFrame) string { return fmt.Sprintf("%s %s", f.Type, f.Start.Type) } -// fetchSQL renders a FETCH clause. -func fetchSQL(f *ast.FetchClause) string { +// fetchSQL renders a FETCH clause, respecting keyword casing from the nodeFormatter. +func fetchSQL(fc *ast.FetchClause, f *nodeFormatter) string { var sb strings.Builder - if f.OffsetValue != nil { - fmt.Fprintf(&sb, " OFFSET %d ROWS", *f.OffsetValue) + if fc.OffsetValue != nil { + fmt.Fprintf(&sb, " %s %d %s", f.kw("OFFSET"), *fc.OffsetValue, f.kw("ROWS")) } - fmt.Fprintf(&sb, " FETCH %s", f.FetchType) - if f.FetchValue != nil { - fmt.Fprintf(&sb, " %d", *f.FetchValue) + fetchType := fc.FetchType + if fetchType == "" { + fetchType = "FIRST" } - if f.IsPercent { - sb.WriteString(" PERCENT") + fmt.Fprintf(&sb, " %s %s", f.kw("FETCH"), f.kw(fetchType)) + if fc.FetchValue != nil { + fmt.Fprintf(&sb, " %d", *fc.FetchValue) + } + if fc.IsPercent { + sb.WriteString(" ") + sb.WriteString(f.kw("PERCENT")) } - sb.WriteString(" ROWS") - if f.WithTies { - sb.WriteString(" WITH TIES") + sb.WriteString(" ") + sb.WriteString(f.kw("ROWS")) + if fc.WithTies { + sb.WriteString(" ") + sb.WriteString(f.kw("WITH TIES")) } else { - sb.WriteString(" ONLY") + sb.WriteString(" ") + sb.WriteString(f.kw("ONLY")) } return sb.String() } diff --git a/pkg/sql/ast/format.go b/pkg/sql/ast/format.go index 2f5bfca2..3a101528 100644 --- a/pkg/sql/ast/format.go +++ b/pkg/sql/ast/format.go @@ -55,6 +55,11 @@ type FormatOptions struct { NewlinePerClause bool // AddSemicolon appends a semicolon to each statement. AddSemicolon bool + // Dialect specifies the target SQL dialect for rendering. When empty, + // the formatter emits whatever AST fields are set (generic behavior). + // Valid values match keywords.SQLDialect constants: "postgresql", "mysql", + // "sqlserver", "oracle", "sqlite", "snowflake", "clickhouse", "mariadb". + Dialect string } // CompactStyle returns formatting options for minimal whitespace output. diff --git a/pkg/transform/dialect_test.go b/pkg/transform/dialect_test.go new file mode 100644 index 00000000..d3050dcd --- /dev/null +++ b/pkg/transform/dialect_test.go @@ -0,0 +1,186 @@ +// Copyright 2026 GoSQLX Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); + +package transform + +import ( + "strings" + "testing" + + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" +) + +func TestFormatSQLWithDialect_PostgreSQL(t *testing.T) { + tree, err := ParseSQL("SELECT * FROM users u") + if err != nil { + t.Fatalf("parse: %v", err) + } + stmt := tree.Statements[0] + if err := Apply(stmt, SetLimit(100)); err != nil { + t.Fatalf("apply: %v", err) + } + + got := FormatSQLWithDialect(stmt, keywords.DialectPostgreSQL) + if !strings.Contains(got, "LIMIT 100") { + t.Errorf("postgresql: expected LIMIT 100, got: %s", got) + } +} + +func TestFormatSQLWithDialect_SQLServer(t *testing.T) { + tree, err := ParseSQL("SELECT * FROM users u") + if err != nil { + t.Fatalf("parse: %v", err) + } + stmt := tree.Statements[0] + if err := Apply(stmt, SetLimit(100)); err != nil { + t.Fatalf("apply: %v", err) + } + + got := FormatSQLWithDialect(stmt, keywords.DialectSQLServer) + if !strings.Contains(got, "TOP 100") { + t.Errorf("sqlserver: expected TOP 100, got: %s", got) + } + if strings.Contains(got, "LIMIT") { + t.Errorf("sqlserver: should not contain LIMIT, got: %s", got) + } +} + +func TestFormatSQLWithDialect_Oracle(t *testing.T) { + tree, err := ParseSQL("SELECT * FROM users u") + if err != nil { + t.Fatalf("parse: %v", err) + } + stmt := tree.Statements[0] + if err := Apply(stmt, SetLimit(100)); err != nil { + t.Fatalf("apply: %v", err) + } + + got := FormatSQLWithDialect(stmt, keywords.DialectOracle) + if !strings.Contains(got, "FETCH FIRST 100 ROWS ONLY") { + t.Errorf("oracle: expected FETCH FIRST 100 ROWS ONLY, got: %s", got) + } + if strings.Contains(got, "LIMIT") { + t.Errorf("oracle: should not contain LIMIT, got: %s", got) + } +} + +func TestFormatSQLWithDialect_EmptyDialect(t *testing.T) { + tree, err := ParseSQL("SELECT * FROM users") + if err != nil { + t.Fatalf("parse: %v", err) + } + stmt := tree.Statements[0] + if err := Apply(stmt, SetLimit(10)); err != nil { + t.Fatalf("apply: %v", err) + } + + // Empty dialect = generic = LIMIT + got := FormatSQLWithDialect(stmt, "") + if !strings.Contains(got, "LIMIT 10") { + t.Errorf("generic: expected LIMIT 10, got: %s", got) + } +} + +func TestFormatSQLWithDialect_Pagination(t *testing.T) { + tests := []struct { + name string + dialect keywords.SQLDialect + limit int + offset int + want []string + reject []string + }{ + { + name: "postgresql pagination", + dialect: keywords.DialectPostgreSQL, + limit: 10, + offset: 20, + want: []string{"LIMIT 10", "OFFSET 20"}, + }, + { + name: "oracle pagination", + dialect: keywords.DialectOracle, + limit: 10, + offset: 20, + want: []string{"OFFSET 20 ROWS", "FETCH FIRST 10 ROWS ONLY"}, + reject: []string{"LIMIT"}, + }, + { + name: "mysql pagination", + dialect: keywords.DialectMySQL, + limit: 10, + offset: 20, + want: []string{"LIMIT 10", "OFFSET 20"}, + }, + { + name: "snowflake pagination", + dialect: keywords.DialectSnowflake, + limit: 10, + offset: 20, + want: []string{"LIMIT 10", "OFFSET 20"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tree, err := ParseSQL("SELECT * FROM users ORDER BY id") + if err != nil { + t.Fatalf("parse: %v", err) + } + stmt := tree.Statements[0] + if err := Apply(stmt, SetLimit(tt.limit), SetOffset(tt.offset)); err != nil { + t.Fatalf("apply: %v", err) + } + + got := FormatSQLWithDialect(stmt, tt.dialect) + for _, w := range tt.want { + if !strings.Contains(got, w) { + t.Errorf("expected %q in output, got: %s", w, got) + } + } + for _, r := range tt.reject { + if strings.Contains(got, r) { + t.Errorf("should not contain %q, got: %s", r, got) + } + } + }) + } +} + +func TestParseSQLWithDialect_SQLServerTop(t *testing.T) { + tree, err := ParseSQLWithDialect("SELECT TOP 10 * FROM users", keywords.DialectSQLServer) + if err != nil { + t.Fatalf("parse: %v", err) + } + if len(tree.Statements) == 0 { + t.Fatal("no statements parsed") + } + + got := FormatSQLWithDialect(tree.Statements[0], keywords.DialectSQLServer) + if !strings.Contains(got, "TOP 10") { + t.Errorf("expected TOP 10 preserved, got: %s", got) + } +} + +func TestFormatSQLWithDialect_SQLServerOffset(t *testing.T) { + tree, err := ParseSQL("SELECT * FROM users ORDER BY id") + if err != nil { + t.Fatalf("parse: %v", err) + } + stmt := tree.Statements[0] + if err := Apply(stmt, SetLimit(10), SetOffset(20)); err != nil { + t.Fatalf("apply: %v", err) + } + + got := FormatSQLWithDialect(stmt, keywords.DialectSQLServer) + if !strings.Contains(got, "OFFSET 20 ROWS") { + t.Errorf("sqlserver: expected OFFSET 20 ROWS, got: %s", got) + } + if !strings.Contains(got, "FETCH NEXT 10 ROWS ONLY") { + t.Errorf("sqlserver: expected FETCH NEXT 10 ROWS ONLY, got: %s", got) + } + if strings.Contains(got, "LIMIT") { + t.Errorf("sqlserver: should not contain LIMIT, got: %s", got) + } +} diff --git a/pkg/transform/transform.go b/pkg/transform/transform.go index 945856a5..699108c0 100644 --- a/pkg/transform/transform.go +++ b/pkg/transform/transform.go @@ -19,6 +19,7 @@ import ( "github.com/ajitpratap0/GoSQLX/pkg/formatter" "github.com/ajitpratap0/GoSQLX/pkg/sql/ast" + "github.com/ajitpratap0/GoSQLX/pkg/sql/keywords" "github.com/ajitpratap0/GoSQLX/pkg/sql/parser" "github.com/ajitpratap0/GoSQLX/pkg/sql/tokenizer" ) @@ -226,3 +227,54 @@ func ParseSQL(sql string) (*ast.AST, error) { func FormatSQL(stmt ast.Statement) string { return formatter.FormatStatement(stmt, ast.CompactStyle()) } + +// FormatSQLWithDialect converts an AST statement back into a compact SQL string +// using dialect-specific syntax for row-limiting clauses (TOP for SQL Server, +// FETCH FIRST for Oracle, LIMIT for PostgreSQL/MySQL/etc.). +// +// Pass keywords.DialectGeneric or an empty SQLDialect for generic behavior +// identical to FormatSQL. +// +// Example: +// +// sql := transform.FormatSQLWithDialect(stmt, keywords.DialectSQLServer) +// // "SELECT TOP 100 * FROM users" +func FormatSQLWithDialect(stmt ast.Statement, dialect keywords.SQLDialect) string { + opts := ast.CompactStyle() + opts.Dialect = string(dialect) + return formatter.FormatStatement(stmt, opts) +} + +// ParseSQLWithDialect parses a SQL string using dialect-specific tokenization and +// parsing rules. This enables correct handling of dialect-specific syntax such as +// SQL Server TOP, MySQL backtick identifiers, and Snowflake QUALIFY. +// +// Use this when the input SQL uses dialect-specific constructs that the generic +// parser would reject or misinterpret. +// +// Example: +// +// tree, err := transform.ParseSQLWithDialect("SELECT TOP 10 * FROM users", keywords.DialectSQLServer) +func ParseSQLWithDialect(sql string, dialect keywords.SQLDialect) (*ast.AST, error) { + tkz := tokenizer.GetTokenizer() + defer tokenizer.PutTokenizer(tkz) + + if dialect != "" { + tkz.SetDialect(dialect) + } + + tokens, err := tkz.Tokenize([]byte(sql)) + if err != nil { + return nil, fmt.Errorf("tokenize: %w", err) + } + + p := parser.NewParser(parser.WithDialect(string(dialect))) + defer p.Release() + + tree, err := p.ParseFromModelTokens(tokens) + if err != nil { + return nil, fmt.Errorf("parse: %w", err) + } + + return tree, nil +}