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: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
tokens (`VACUUM t junk`, `LISTEN c extra`) are a parse error instead of being
folded into the statement text. MySQL / MariaDB / Oracle `ANALYZE TABLE` keeps
its as-written handling.
- SQL Server: a delimited `[top]` / `"top"` is a plain name (also in the select
list, where it was read as the TOP clause) and keeps its delimiters on output;
bare `TOP` is rejected as a table, DML target or alias name, as T-SQL reserves it.
- MariaDB `DELETE HISTORY FROM t [PARTITION (p, ...)] [BEFORE SYSTEM_TIME
[TIMESTAMP | TRANSACTION] expr]` parses and round-trips the partition list, the
unit keyword and any point expression (e.g. `NOW() - INTERVAL 1 DAY`), held in
the new `ast.DMLTargetFields.History` (`ast.DeleteHistoryOptions`).
- Watch now respects `--dialect`, re-validates changes and formats files in place
with the CLI safety checks; MCP parsing accepts a dialect and documents comment preservation.
- Correct transpile dialect help, avoid duplicate lint syntax errors, and rewrite
Expand Down Expand Up @@ -207,6 +214,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
longer read as MariaDB's `ANALYZE <statement>`.

### Behavior changes
- SQL Server: unquoted `top` (any case) is now a syntax error where a table
name, INSERT/UPDATE/DELETE/MERGE target, table alias or select-item alias is
expected (`SELECT * FROM top`, `INSERT INTO top ...`); quote it as `[top]`.
- Numeric words without whitespace no longer silently become a number plus a
column alias. Numeric spelling is retained. Differences from the previous lexer:

Expand Down
42 changes: 42 additions & 0 deletions pkg/formatter/mariadb_delete_history_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package formatter_test

import (
"testing"

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

func TestMariaDBDeleteHistoryFormatterRoundTrip(t *testing.T) {
for _, sql := range []string{
`DELETE HISTORY FROM t PARTITION (p0, p1) BEFORE SYSTEM_TIME TRANSACTION 42`,
`DELETE HISTORY FROM t PARTITION (p0) BEFORE SYSTEM_TIME TIMESTAMP NOW() - INTERVAL 1 DAY`,
"DELETE HISTORY FROM t PARTITION (`select`) BEFORE SYSTEM_TIME '2020-01-01' + INTERVAL 1 DAY",
} {
t.Run(sql, func(t *testing.T) {
tree, err := gosqlx.ParseWithDialect(sql, keywords.DialectMariaDB)
if err != nil {
t.Fatal(err)
}
defer ast.ReleaseAST(tree)
lower := ast.CompactStyle()
lower.KeywordCase = ast.KeywordLower
formatted, err := gosqlx.FormatWithDialect(sql, gosqlx.DefaultFormatOptions(), keywords.DialectMariaDB)
if err != nil {
t.Fatal(err)
}
for _, output := range []string{formatted, formatter.FormatAST(tree, ast.CompactStyle()), formatter.FormatAST(tree, ast.ReadableStyle()), formatter.FormatAST(tree, lower)} {
again, err := gosqlx.ParseWithDialect(output, keywords.DialectMariaDB)
if err != nil {
t.Fatalf("reparse %q: %v", output, err)
}
defer ast.ReleaseAST(again)
if got := again.SQL(); got != sql {
t.Fatalf("formatted %q reparsed to %q, want %q", output, got, sql)
}
}
})
}
}
20 changes: 20 additions & 0 deletions pkg/formatter/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -596,10 +596,30 @@ func renderDelete(d *ast.DeleteStatement, opts ast.FormatOptions) string {
sb.WriteString(" ")
}
writeDMLTargets(sb, f, x, d.TableName, d.TableNameQuote, d.Alias, d.AliasQuote)
var history ast.DeleteHistoryOptions
if x.History != nil {
history = *x.History
}
if len(history.Partitions) > 0 {
sb.WriteString(" ")
sb.WriteString(f.kw("PARTITION"))
sb.WriteString(" (")
for i, partition := range history.Partitions {
if i > 0 {
sb.WriteString(", ")
}
sb.WriteString(f.exprSQL(partition))
}
sb.WriteByte(')')
}
if d.BeforeSystemTime != nil {
sb.WriteString(" ")
sb.WriteString(f.kw("BEFORE SYSTEM_TIME"))
sb.WriteString(" ")
if history.BeforeSystemTimeUnit != "" {
sb.WriteString(f.kw(history.BeforeSystemTimeUnit))
sb.WriteString(" ")
}
sb.WriteString(f.exprSQL(d.BeforeSystemTime))
}
writeDMLOutput(sb, f, x.Output, x.OutputInto)
Expand Down
17 changes: 16 additions & 1 deletion pkg/sql/ast/ast_dml.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,21 @@ type DMLTargetFields struct {
Limit Expression
// ReturningInto holds the Oracle RETURNING ... INTO :bind targets.
ReturningInto []Expression
// History holds the MariaDB DELETE HISTORY partition list and BEFORE
// SYSTEM_TIME unit; nil for every other statement.
History *DeleteHistoryOptions
}

// DeleteHistoryOptions holds the optional parts of MariaDB's
// DELETE HISTORY FROM t [PARTITION (p, ...)] [BEFORE SYSTEM_TIME [unit] point]
// other than the point itself (DeleteStatement.BeforeSystemTime). It lives in
// DMLTargetFields so the rarely used fields do not grow DeleteStatement.
type DeleteHistoryOptions struct {
// Partitions are the PARTITION (...) names, in source order.
Partitions []*Identifier
// BeforeSystemTimeUnit is "TIMESTAMP" or "TRANSACTION" when written
// before the point, and "" otherwise.
BeforeSystemTimeUnit string
}

// dmlExtras returns a copy of x, or a zero value when it is nil, so renderers
Expand Down Expand Up @@ -91,7 +106,7 @@ func (d *DeleteStatement) EnsureExtras() *DMLTargetFields {
func (d *DMLTargetFields) IsZero() bool {
return len(d.Modifiers) == 0 && d.Top == nil && !d.Only && len(d.Tables) == 0 &&
len(d.Joins) == 0 && len(d.Output) == 0 && d.OutputInto == nil &&
len(d.OrderBy) == 0 && d.Limit == nil && len(d.ReturningInto) == 0
len(d.OrderBy) == 0 && d.Limit == nil && len(d.ReturningInto) == 0 && d.History == nil
}

// dmlTargetChildren appends the nodes of d to children.
Expand Down
7 changes: 7 additions & 0 deletions pkg/sql/ast/ast_statements.go
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,8 @@ type DeleteStatement struct {
// BeforeSystemTime is the point of MariaDB's DELETE HISTORY FROM t
// BEFORE SYSTEM_TIME point; nil when the statement has none. DELETE
// HISTORY itself is the HISTORY modifier.
// The PARTITION list and the TIMESTAMP / TRANSACTION unit of the point
// are in Extras.History.
BeforeSystemTime Expression
// TableNameQuote and AliasQuote record the delimiters of TableName and
// Alias when they must be kept (see NameQuote).
Expand All @@ -549,6 +551,11 @@ func (d DeleteStatement) Children() []Node {
for i := range d.From {
children = append(children, &d.From[i])
}
if d.Extras != nil && d.Extras.History != nil {
for _, partition := range d.Extras.History.Partitions {
children = append(children, partition)
}
}
if d.BeforeSystemTime != nil {
children = append(children, d.BeforeSystemTime)
}
Expand Down
5 changes: 5 additions & 0 deletions pkg/sql/ast/pool_dml_release.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,11 @@ func releaseDMLTargetFields(d *DMLTargetFields, mode releaseMode) {
for _, e := range d.ReturningInto {
releaseExpression(e, mode)
}
if d.History != nil {
for _, partition := range d.History.Partitions {
releaseExpression(partition, mode)
}
}
*d = DMLTargetFields{}
}

Expand Down
18 changes: 18 additions & 0 deletions pkg/sql/ast/sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -1578,8 +1578,26 @@ func (d *DeleteStatement) writeSQL(buf *bytes.Buffer) {
buf.WriteString("FROM ")
}
x.writeDMLTargets(buf, d.TableName, d.TableNameQuote, d.Alias, d.AliasQuote)
var history DeleteHistoryOptions
if x.History != nil {
history = *x.History
}
if len(history.Partitions) > 0 {
buf.WriteString(" PARTITION (")
for i, partition := range history.Partitions {
if i > 0 {
buf.WriteString(", ")
}
partition.writeSQL(buf)
}
buf.WriteByte(')')
}
if d.BeforeSystemTime != nil {
buf.WriteString(" BEFORE SYSTEM_TIME ")
if history.BeforeSystemTimeUnit != "" {
buf.WriteString(history.BeforeSystemTimeUnit)
buf.WriteByte(' ')
}
writeExpr(buf, d.BeforeSystemTime)
}
writeDMLOutput(buf, x.Output, x.OutputInto)
Expand Down
2 changes: 1 addition & 1 deletion pkg/sql/parser/ddl.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ func (p *Parser) parseCreateTable(temporary bool) (*ast.CreateTableStatement, er
}

// Parse table name (supports schema.table qualification and double-quoted identifiers)
createTableName, createTableQuote, err := p.parseQualifiedNameQuoted()
createTableName, createTableQuote, err := p.parseQualifiedNameWithTableRules(true)
if err != nil {
return nil, p.qualifiedNameError(err, "table name")
}
Expand Down
5 changes: 4 additions & 1 deletion pkg/sql/parser/dml_common.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,9 @@ func (p *Parser) isDMLOutputKeywordAt() bool {
// isDMLTargetAlias reports whether the current token is a bare alias after
// an UPDATE/DELETE target name rather than the clause that follows it.
func (p *Parser) isDMLTargetAlias() bool {
if p.isReservedTopName() {
return false
}
t := p.currentToken.Token.Type
return (t == models.TokenTypeIdentifier || t == models.TokenTypeDoubleQuotedString) && p.isDMLTargetAliasAt()
}
Expand Down Expand Up @@ -190,7 +193,7 @@ func (p *Parser) parseDMLTargetName(name *string, nameQuote *ast.NameQuote, alia
*name, *nameQuote = n, q
if p.isType(models.TokenTypeAs) {
p.advance() // Consume AS
if !p.isIdentifier() {
if !p.isIdentifier() || p.isReservedTopName() {
return p.expectedError("alias after AS")
}
*alias, *aliasQuote = p.currentToken.Token.Value, p.currentNameQuote()
Expand Down
35 changes: 33 additions & 2 deletions pkg/sql/parser/dml_delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,8 @@ func (p *Parser) parseDeleteSources(dst *[]ast.TableReference, joinsDst *[]ast.J
}

// parseDeleteHistory parses the rest of MariaDB's DELETE HISTORY FROM table
// [BEFORE SYSTEM_TIME point]; the current token is HISTORY. HISTORY is kept as
// [PARTITION (partition_list)] [BEFORE SYSTEM_TIME [TIMESTAMP | TRANSACTION] expr];
// the current token is HISTORY. HISTORY is kept as
// a statement modifier.
func (p *Parser) parseDeleteHistory(stmt *ast.DeleteStatement, x *ast.DMLTargetFields) (ast.Statement, error) {
x.Modifiers = append(x.Modifiers, "HISTORY")
Expand All @@ -190,13 +191,43 @@ func (p *Parser) parseDeleteHistory(stmt *ast.DeleteStatement, x *ast.DMLTargetF
return nil, err
}
stmt.TableName, stmt.TableNameQuote = name, quote
var history ast.DeleteHistoryOptions
if p.isType(models.TokenTypePartition) {
p.advance() // Consume PARTITION
if !p.matchType(models.TokenTypeLParen) {
return nil, p.expectedError("(")
}
for {
if !p.isIdentifier() && !p.isNonReservedKeyword() {
return nil, p.expectedError("partition name")
}
partition := p.parseColumnName()
if partition == nil {
return nil, p.expectedError("partition name")
}
history.Partitions = append(history.Partitions, partition)
if !p.matchType(models.TokenTypeComma) {
break
}
}
if !p.matchType(models.TokenTypeRParen) {
return nil, p.expectedError(")")
}
}
if p.isUnquotedWord("BEFORE") && p.peekValueIs(1, "SYSTEM_TIME") {
p.advance() // Consume BEFORE
p.advance() // Consume SYSTEM_TIME
if stmt.BeforeSystemTime, err = p.parseTemporalPointExpression(); err != nil {
if p.isUnquotedWord("TIMESTAMP") || p.isUnquotedWord("TRANSACTION") {
history.BeforeSystemTimeUnit = strings.ToUpper(p.currentToken.Token.Value)
p.advance()
}
if stmt.BeforeSystemTime, err = p.parseExpression(); err != nil {
return nil, err
}
}
if len(history.Partitions) > 0 || history.BeforeSystemTimeUnit != "" {
x.History = &history
}
stmt.Extras = finishDMLExtras(x)
return stmt, nil
}
4 changes: 2 additions & 2 deletions pkg/sql/parser/dml_merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ func (p *Parser) parseMergeStatement() (ast.Statement, error) {
// Parse optional target alias (AS alias or just alias)
if p.isType(models.TokenTypeAs) {
p.advance() // Consume AS
if !p.isIdentifier() && !p.isNonReservedKeyword() {
if p.isReservedTopName() || (!p.isIdentifier() && !p.isNonReservedKeyword()) {
return nil, p.expectedError("target alias after AS")
}
stmt.TargetAlias = p.currentToken.Token.Value
Expand Down Expand Up @@ -89,7 +89,7 @@ func (p *Parser) parseMergeStatement() (ast.Statement, error) {
// Parse optional source alias
if p.isType(models.TokenTypeAs) {
p.advance() // Consume AS
if !p.isIdentifier() && !p.isNonReservedKeyword() {
if p.isReservedTopName() || (!p.isIdentifier() && !p.isNonReservedKeyword()) {
return nil, p.expectedError("source alias after AS")
}
stmt.SourceAlias = p.currentToken.Token.Value
Expand Down
101 changes: 101 additions & 0 deletions pkg/sql/parser/mariadb_delete_history_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package parser_test

import (
"strings"
"testing"

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

func TestMariaDBDeleteHistoryRoundTrip(t *testing.T) {
cases := []string{
`DELETE HISTORY FROM t`,
`DELETE HISTORY FROM t BEFORE SYSTEM_TIME '2020-01-01 00:00:00'`,
`DELETE HISTORY FROM t BEFORE SYSTEM_TIME TIMESTAMP '2020-01-01 00:00:00'`,
`DELETE HISTORY FROM t BEFORE SYSTEM_TIME NOW() - INTERVAL 1 DAY`,
`DELETE HISTORY FROM t BEFORE SYSTEM_TIME CURRENT_TIMESTAMP - INTERVAL 1 DAY`,
`DELETE HISTORY FROM t BEFORE SYSTEM_TIME '2020-01-01' + INTERVAL 1 DAY`,
`DELETE HISTORY FROM t BEFORE SYSTEM_TIME TIMESTAMP NOW() - INTERVAL 1 DAY`,
`DELETE HISTORY FROM t BEFORE SYSTEM_TIME TIMESTAMP '2020-01-01' + INTERVAL 1 DAY`,
`DELETE HISTORY FROM t BEFORE SYSTEM_TIME TRANSACTION 42`,
`DELETE HISTORY FROM t BEFORE SYSTEM_TIME @ts`,
`DELETE HISTORY FROM db.t BEFORE SYSTEM_TIME '2020-01-01'`,
`DELETE HISTORY FROM t PARTITION (p0, p1)`,
`DELETE HISTORY FROM t PARTITION (p0) BEFORE SYSTEM_TIME '2020-01-01'`,
"DELETE HISTORY FROM t PARTITION (`select`)",
`delete history from t before system_time transaction 7`,
`DELETE FROM history WHERE a = 1`,
`SELECT * FROM t FOR SYSTEM_TIME AS OF TIMESTAMP '2020-01-01'`,
}
for _, sql := range cases {
t.Run(sql, func(t *testing.T) {
want := sql
if strings.HasPrefix(sql, "delete ") {
want = `DELETE HISTORY FROM t BEFORE SYSTEM_TIME TRANSACTION 7`
}
tree, err := gosqlx.ParseWithDialect(sql, keywords.DialectMariaDB)
if err != nil {
t.Fatal(err)
}
defer ast.ReleaseAST(tree)
if got := tree.SQL(); got != want {
t.Fatalf("SQL() = %q, want %q", got, want)
}
formatted, err := gosqlx.FormatWithDialect(sql, gosqlx.DefaultFormatOptions(), keywords.DialectMariaDB)
if err != nil {
t.Fatal(err)
}
for _, output := range []string{tree.SQL(), formatted} {
again, err := gosqlx.ParseWithDialect(output, keywords.DialectMariaDB)
if err != nil {
t.Fatalf("reparse %q: %v", output, err)
}
defer ast.ReleaseAST(again)
if got := again.SQL(); got != want {
t.Fatalf("round trip = %q, want %q", got, want)
}
}
})
}
}

func TestMariaDBDeleteHistoryRejectsInvalid(t *testing.T) {
for _, sql := range []string{
`DELETE HISTORY FROM t WHERE a = 1`,
`DELETE HISTORY FROM t BEFORE SYSTEM_TIME '2020-01-01' WHERE a = 1`,
`DELETE HISTORY FROM t BEFORE SYSTEM_TIME '2020-01-01' LIMIT 5`,
`DELETE HISTORY FROM t LIMIT 5`,
`DELETE HISTORY FROM t BEFORE SYSTEM_TIME`,
`DELETE HISTORY FROM t BEFORE '2020-01-01'`,
`DELETE HISTORY FROM t PARTITION ()`,
`DELETE HISTORY FROM t PARTITION (p0,)`,
`DELETE HISTORY FROM t BEFORE SYSTEM_TIME TRANSACTION`,
} {
t.Run(sql, func(t *testing.T) {
tree, err := gosqlx.ParseWithDialect(sql, keywords.DialectMariaDB)
if tree != nil {
defer ast.ReleaseAST(tree)
}
if err == nil {
t.Fatal("expected syntax error")
}
})
}
}

func TestMySQLDeleteHistoryRemainsTarget(t *testing.T) {
tree, err := gosqlx.ParseWithDialect(`DELETE HISTORY FROM t`, keywords.DialectMySQL)
if err != nil {
t.Fatal(err)
}
defer ast.ReleaseAST(tree)
stmt, ok := tree.Statements[0].(*ast.DeleteStatement)
if !ok {
t.Fatalf("got %T", tree.Statements[0])
}
if !strings.EqualFold(stmt.TableName, "history") || len(stmt.From) != 1 {
t.Fatalf("expected multi-table DELETE: %#v", stmt)
}
}
Loading
Loading