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
99 changes: 99 additions & 0 deletions cmd/gosqlx/cmd/insert_update_forms_format_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// 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 cmd

import (
"bytes"
"strings"
"testing"

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

// TestFormatCommand_InsertUpdateForms is a regression test for the #610
// grammar additions: the `gosqlx format` renderer dropped UPDATE ... FROM
// sources and rendered INSERT ... DEFAULT VALUES as a bare INSERT INTO t, so
// `format -i` rewrote files with SQL of different meaning or invalid SQL.
func TestFormatCommand_InsertUpdateForms(t *testing.T) {
cases := []struct {
name, dialect, in, want string
}{
{"update from", "postgresql",
"UPDATE t SET a = s.b FROM s WHERE t.id = s.id",
"UPDATE t SET a = s.b FROM s WHERE t.id = s.id"},
{"update from list", "postgresql",
"UPDATE t SET a = s.b FROM s, u AS v WHERE t.id = s.id",
"UPDATE t SET a = s.b FROM s, u AS v WHERE t.id = s.id"},
{"update from", "sqlserver",
"UPDATE t SET a = s.b FROM s WHERE t.id = s.id",
"UPDATE t SET a = s.b FROM s WHERE t.id = s.id"},
{"update from no where", "postgresql",
"UPDATE t SET a = s.b FROM s",
"UPDATE t SET a = s.b FROM s"},
{"default values", "postgresql",
"INSERT INTO t DEFAULT VALUES",
"INSERT INTO t DEFAULT VALUES"},
{"default values", "sqlserver",
"INSERT INTO t DEFAULT VALUES",
"INSERT INTO t DEFAULT VALUES"},
{"default values", "sqlite",
"INSERT INTO t DEFAULT VALUES",
"INSERT INTO t DEFAULT VALUES"},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name+"/"+tc.dialect, func(t *testing.T) {
var out, errBuf bytes.Buffer
f := NewFormatter(&out, &errBuf, CLIFormatterOptions{Compact: true, Uppercase: true, Dialect: tc.dialect})
got, err := f.formatSQL(tc.in)
if err != nil {
t.Fatalf("formatSQL(%q) failed: %v", tc.in, err)
}
got = strings.TrimSpace(got)
if got != tc.want {
t.Fatalf("formatSQL mismatch\n got: %q\nwant: %q", got, tc.want)
}

// The output must mean the same thing: re-parse it and compare
// the canonical SQL() of both trees.
d := keywords.SQLDialect(tc.dialect)
inTree, err := gosqlx.ParseWithDialect(tc.in, d)
if err != nil {
t.Fatalf("parse(%q) failed: %v", tc.in, err)
}
outTree, err := gosqlx.ParseWithDialect(got, d)
if err != nil {
t.Fatalf("re-parse of %q failed: %v", got, err)
}
if inTree.SQL() != outTree.SQL() {
t.Fatalf("formatted SQL changed meaning\n in: %q\n out: %q", inTree.SQL(), outTree.SQL())
}
})
}

t.Run("readable update from", func(t *testing.T) {
var out, errBuf bytes.Buffer
f := NewFormatter(&out, &errBuf, CLIFormatterOptions{IndentSize: 2, Uppercase: true, Dialect: "postgresql"})
got, err := f.formatSQL("UPDATE t SET a = s.b FROM s WHERE t.id = s.id")
if err != nil {
t.Fatalf("formatSQL failed: %v", err)
}
want := "UPDATE t\nSET a = s.b\nFROM s\nWHERE t.id = s.id"
if strings.TrimSpace(got) != want {
t.Fatalf("readable output mismatch\n got: %q\nwant: %q", strings.TrimSpace(got), want)
}
})
}
13 changes: 13 additions & 0 deletions cmd/gosqlx/cmd/sql_formatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,11 @@ func (f *SQLFormatter) formatInsert(stmt *ast.InsertStatement) error {
f.builder.WriteString(")")
}

if stmt.DefaultValues {
f.writeNewline()
f.writeKeyword("DEFAULT VALUES")
}

if len(stmt.Values) > 0 {
f.writeNewline()
f.writeKeyword("VALUES")
Expand Down Expand Up @@ -359,6 +364,14 @@ func (f *SQLFormatter) formatUpdate(stmt *ast.UpdateStatement) error {
}
}

// PostgreSQL/SQL Server UPDATE ... FROM source list (#610)
if len(stmt.From) > 0 {
f.writeNewline()
f.writeKeyword("FROM")
f.builder.WriteString(" ")
f.formatTableReferences(stmt.From)
}

if stmt.Where != nil {
f.writeNewline()
f.writeKeyword("WHERE")
Expand Down
20 changes: 18 additions & 2 deletions pkg/formatter/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -396,13 +396,26 @@ func renderInsert(i *ast.InsertStatement, opts ast.FormatOptions) string {
sb.WriteString(" ")
sb.WriteString(i.TableName)

if len(i.Columns) > 0 {
// MySQL INSERT ... SET form: Columns[k] = Values[0][k].
setForm := i.SetSyntax && i.Query == nil && len(i.Columns) > 0 &&
len(i.Values) == 1 && len(i.Values[0]) == len(i.Columns)

if len(i.Columns) > 0 && !setForm {
sb.WriteString(" (")
sb.WriteString(exprListSQL(i.Columns))
sb.WriteString(")")
}

if i.Query != nil {
if setForm {
sb.WriteString(f.clauseSep())
sb.WriteString(f.kw("SET"))
sb.WriteString(" ")
sets := make([]string, len(i.Columns))
for k, col := range i.Columns {
sets[k] = exprSQL(col) + " = " + exprSQL(i.Values[0][k])
}
sb.WriteString(strings.Join(sets, ", "))
} else if i.Query != nil {
sb.WriteString(f.clauseSep())
sb.WriteString(FormatStatement(i.Query, opts))
} else if len(i.Values) > 0 {
Expand All @@ -418,6 +431,9 @@ func renderInsert(i *ast.InsertStatement, opts ast.FormatOptions) string {
rows[idx] = "(" + strings.Join(vals, ", ") + ")"
}
sb.WriteString(strings.Join(rows, ", "))
} else if i.DefaultValues {
sb.WriteString(f.clauseSep())
sb.WriteString(f.kw("DEFAULT VALUES"))
}

if i.OnConflict != nil {
Expand Down
133 changes: 133 additions & 0 deletions pkg/formatter/render_insert_forms_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
// 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 formatter_test

import (
"strings"
"testing"

"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"
)

// TestRenderInsertForms checks that FormatAST renders the #610 INSERT and
// UPDATE forms and that its output re-parses to the same statement.
func TestRenderInsertForms(t *testing.T) {
tests := []struct {
name string
dialect keywords.SQLDialect
sql string
contains []string
excludes []string
// compactOnly skips the readable style: it renders a nested query
// source with its own ";" (#602), so RETURNING after it would not
// re-parse until that is fixed.
compactOnly bool
}{
{
name: "default values", dialect: keywords.DialectPostgreSQL,
sql: "INSERT INTO t DEFAULT VALUES RETURNING id",
contains: []string{"DEFAULT VALUES", "RETURNING id"},
},
{
name: "default values sqlserver", dialect: keywords.DialectSQLServer,
sql: "INSERT INTO t DEFAULT VALUES",
contains: []string{"DEFAULT VALUES"},
},
{
name: "default values sqlite", dialect: keywords.DialectSQLite,
sql: "INSERT INTO t DEFAULT VALUES",
contains: []string{"DEFAULT VALUES"},
},
{
name: "mysql set", dialect: keywords.DialectMySQL,
sql: "INSERT INTO t SET a = 1, b = 2",
contains: []string{"SET a = 1, b = 2"},
excludes: []string{"(a, b)", "VALUES"},
},
{
name: "mariadb set", dialect: keywords.DialectMariaDB,
sql: "INSERT INTO t SET a = 1, b = 'x'",
contains: []string{"SET a = 1, b = 'x'"},
excludes: []string{"(a, b)", "VALUES"},
},
{
name: "returning after select source", dialect: keywords.DialectPostgreSQL,
sql: "INSERT INTO t SELECT * FROM s RETURNING id",
contains: []string{"RETURNING id"},
compactOnly: true,
},
{
name: "parenthesized source", dialect: keywords.DialectGeneric,
sql: "INSERT INTO t (SELECT * FROM s)",
contains: []string{"INSERT INTO t SELECT * FROM s"},
excludes: []string{"(SELECT"},
compactOnly: true,
},
{
name: "update from", dialect: keywords.DialectPostgreSQL,
sql: "UPDATE t SET a = s.b FROM s WHERE t.id = s.id",
contains: []string{"FROM s"},
},
}

styles := []struct {
name string
opts ast.FormatOptions
}{
{"compact", ast.CompactStyle()},
{"readable", ast.ReadableStyle()},
}

for _, tt := range tests {
for _, st := range styles {
if tt.compactOnly && st.name != "compact" {
continue
}
t.Run(tt.name+"/"+st.name, func(t *testing.T) {
tree, err := parser.ParseWithDialect(tt.sql, tt.dialect)
if err != nil {
t.Fatalf("ParseWithDialect(%q, %q): %v", tt.sql, tt.dialect, err)
}
defer ast.ReleaseAST(tree)
want := tree.SQL()

out := formatter.FormatAST(tree, st.opts)
flat := strings.Join(strings.Fields(out), " ")
for _, c := range tt.contains {
if !strings.Contains(flat, c) {
t.Errorf("FormatAST = %q, want it to contain %q", out, c)
}
}
for _, e := range tt.excludes {
if strings.Contains(flat, e) {
t.Errorf("FormatAST = %q, must not contain %q", out, e)
}
}

tree2, err := parser.ParseWithDialect(out, tt.dialect)
if err != nil {
t.Fatalf("re-parse of FormatAST output %q: %v", out, err)
}
defer ast.ReleaseAST(tree2)
if got := tree2.SQL(); got != want {
t.Fatalf("round-trip SQL() = %q, want %q", got, want)
}
})
}
}
}
20 changes: 20 additions & 0 deletions pkg/schema/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,26 @@ func (v *Validator) validateUpdate(s *ast.UpdateStatement) []ValidationError {
tableMap[tableName] = tableName
}

// UPDATE ... FROM: check the extra tables exist and make their names and
// aliases resolvable, as validateSelect does for its FROM list.
for _, ref := range s.From {
if ref.Name == "" {
continue // subquery, skip
}
if _, ok := v.Schema.GetTable(ref.Name); !ok {
errors = append(errors, ValidationError{
Message: fmt.Sprintf("table %q does not exist in schema", ref.Name),
Severity: "error",
Suggestion: v.suggestTable(ref.Name),
})
}
if ref.Alias != "" {
tableMap[ref.Alias] = ref.Name
} else {
tableMap[ref.Name] = ref.Name
}
}

// Validate SET column names
for _, upd := range s.Assignments {
colName := extractColumnName(upd.Column)
Expand Down
40 changes: 40 additions & 0 deletions pkg/schema/validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -613,3 +613,43 @@ func TestSchema_CaseInsensitiveColumnLookup(t *testing.T) {
t.Fatal("expected case-insensitive column lookup to succeed")
}
}

// TestValidator_UpdateFrom pins that UPDATE ... FROM tables and aliases are
// resolvable and checked for existence (#610 review).
func TestValidator_UpdateFrom(t *testing.T) {
v := NewValidator(helperSchema())

t.Run("valid", func(t *testing.T) {
for _, sql := range []string{
"UPDATE users SET name = o.status FROM orders o WHERE users.id = o.user_id",
"UPDATE users SET name = 'x' FROM orders WHERE users.id = orders.user_id",
} {
errs, err := v.Validate(sql)
if err != nil {
t.Fatalf("Validate(%q): %v", sql, err)
}
if len(errs) != 0 {
t.Errorf("Validate(%q): unexpected errors: %v", sql, errs)
}
}
})

t.Run("missing table", func(t *testing.T) {
errs, err := v.Validate("UPDATE users SET name = 'x' FROM nosuch WHERE users.id = nosuch.id")
if err != nil {
t.Fatalf("Validate: %v", err)
}
found := false
for _, e := range errs {
if strings.Contains(e.Message, `table "nosuch" does not exist`) {
found = true
}
if strings.Contains(e.Message, "not referenced in FROM clause") {
t.Errorf("unexpected misleading error: %q", e.Message)
}
}
if !found {
t.Errorf("expected missing-table error, got %v", errs)
}
})
}
6 changes: 6 additions & 0 deletions pkg/sql/ast/ast_statements.go
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,12 @@ type InsertStatement struct {
OnConflict *OnConflict
OnDuplicateKey *UpsertClause // MySQL: ON DUPLICATE KEY UPDATE
Pos models.Location // Source position of the INSERT keyword (1-based line and column)
// DefaultValues marks INSERT INTO t DEFAULT VALUES (no Columns, Values or Query).
DefaultValues bool
// SetSyntax marks the MySQL/MariaDB INSERT INTO t SET col = expr, ... form.
// The assignments are stored as Columns plus a single Values row
// (Columns[i] = Values[0][i]); renderers emit SET when the shapes match.
SetSyntax bool
}

func (i *InsertStatement) statementNode() {}
Expand Down
2 changes: 2 additions & 0 deletions pkg/sql/ast/pool_statement_release.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ func releaseInsertStatement(stmt *InsertStatement, mode releaseMode) {

stmt.TableName = ""
stmt.Pos = models.Location{}
stmt.DefaultValues = false
stmt.SetSyntax = false

// Return to pool
if mode == releaseAll {
Expand Down
Loading
Loading