Skip to content

Commit 2f7f12c

Browse files
committed
fmt: preserve ') VALUES (' seams and !=/<> operator spellings
Two formatting fixes reported from running sqlc fmt on a real project: The boundary before a bare VALUES list was decided by comparing the source lines of the neighbouring nodes (the last column and the first value), which always differ when both paren lists are multi-line — so an authored ') VALUES (' seam broke apart. The seam now gets its own group, and AttachComments reads the author's actual choice out of the source by scanning back from the first value across '(' and the VALUES keyword. The parsers normalized inequality spellings: PostgreSQL's grammar turns != into <>, and the MySQL converter mapped opcode.NE to != regardless of what was written. The format parsers now read the author's spelling back out of the source — PostgreSQL via a new NewFormatParser that rewrites A_Expr names at the operator's recorded location, MySQL via the same source-scan approach SQLite already used. The compiler's parsers keep the canonical names. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4UpYmwPdxzePtsRXoL1H8
1 parent 250f19f commit 2f7f12c

10 files changed

Lines changed: 183 additions & 24 deletions

File tree

internal/cmd/fmt.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ type queryFormatter interface {
4545
func newQueryFormatter(engine config.Engine) queryFormatter {
4646
switch engine {
4747
case config.EnginePostgreSQL:
48-
return postgresql.NewParser()
48+
// The format parser preserves the author's operator spellings
49+
// (`!=` vs `<>`) that the grammar would otherwise normalize.
50+
return postgresql.NewFormatParser()
4951
case config.EngineSQLite:
5052
return sqlite.NewParser()
5153
case config.EngineMySQL:

internal/endtoend/testdata/fmt/mysql/query.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,6 @@ WHERE p.bio IS NOT NULL;
7070
SHOW WARNINGS;
7171

7272
CREATE TABLE scores (points decimal(10, 5), views bigint unsigned NOT NULL);
73+
74+
-- name: SpelledOperators :many
75+
SELECT id FROM authors WHERE name != ? AND bio <> ?;

internal/endtoend/testdata/fmt/mysql/stdout.txt

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -31,37 +31,30 @@
3131
FROM authors
3232
-- soft-deleted rows are filtered
3333
WHERE bio IS NOT NULL
34-
@@ -21,8 +25,9 @@
34+
@@ -21,7 +25,8 @@
3535
SELECT /* inline note */ id, name FROM authors ORDER BY name;
3636

3737
-- name: CountSigils :one
3838
+SELECT count(*)
3939
+FROM authors
40-
+WHERE id != ? AND name != @user_name; # session variable stays
4140
-SELECT count(*) FROM authors
42-
-WHERE id <> ? AND name <> @user_name; # session variable stays
41+
WHERE id <> ? AND name <> @user_name; # session variable stays
4342

4443
-- name: CastUnsigned :one
45-
SELECT CAST(id AS UNSIGNED) FROM authors LIMIT 1;
46-
@@ -34,14 +39,11 @@
44+
@@ -34,11 +39,7 @@
4745
SELECT id FROM authors LIMIT 1;
4846

4947
-- name: CreateAuthor :execresult
50-
+INSERT INTO authors (name, bio)
51-
+VALUES (?, ?);
48+
+INSERT INTO authors (name, bio) VALUES (?, ?);
5249
-insert into authors (
5350
- name, bio
5451
-) values (
5552
- ?, ?
5653
-);
5754

5855
-- name: CasePreserved :many
59-
+SELECT ID, Name FROM Authors WHERE Name != '' ORDER BY Name;
60-
-SELECT ID, Name FROM Authors WHERE Name <> '' ORDER BY Name;
61-
62-
-- name: LiteralsSurvive :one
63-
SELECT true AS t, false AS f, 1.50 AS score, CASE WHEN name = '' THEN NULL ELSE name END AS n FROM authors LIMIT 1;
64-
@@ -62,7 +64,8 @@
56+
SELECT ID, Name FROM Authors WHERE Name <> '' ORDER BY Name;
57+
@@ -62,7 +63,8 @@
6558
SELECT /*+ MAX_EXECUTION_TIME(1000) */ id FROM authors LIMIT 1;
6659

6760
-- name: UpdateWithJoin :exec

internal/endtoend/testdata/fmt/postgresql/query.sql

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,5 +29,23 @@ SELECT id, name, bio, created_at FROM authors WHERE name LIKE $1 AND bio IS NOT
2929
-- name: AtParamsGlued :many
3030
SELECT name FROM authors WHERE name = @slug AND @filter::bool;
3131

32+
-- name: CreateAuthorLong :one
33+
INSERT INTO authors (
34+
name,
35+
bio
36+
) VALUES (
37+
$1,
38+
$2
39+
)
40+
RETURNING *;
41+
42+
-- name: CreateAuthorBrokenValues :one
43+
INSERT INTO authors (name, bio)
44+
VALUES ($1, $2)
45+
RETURNING *;
46+
47+
-- name: SpelledOperators :many
48+
SELECT id FROM authors WHERE name != $1 AND bio <> $2;
49+
3250
-- name: DeleteAuthor :exec
3351
DELETE FROM authors WHERE id = @id

internal/endtoend/testdata/fmt/postgresql/stdout.txt

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,17 @@
2020
ORDER BY name;
2121

2222
-- name: CreateAuthor :one
23-
@@ -13,8 +15,5 @@
23+
@@ -13,8 +15,4 @@
2424
-INSERT INTO authors (
2525
- name, bio
2626
-) VALUES (
2727
- $1, $2
2828
-)
29-
+INSERT INTO authors (name, bio)
30-
+VALUES ($1, $2)
29+
+INSERT INTO authors (name, bio) VALUES ($1, $2)
3130
RETURNING *;
3231

3332
-- name: PickyQuery :many
34-
@@ -21,5 +20,6 @@
33+
@@ -21,5 +19,6 @@
3534
-SELECT id, -- the primary key
3635
- name
3736
+SELECT
@@ -40,8 +39,8 @@
4039
FROM authors
4140
WHERE id > $1;
4241

43-
@@ -30,4 +30,4 @@
44-
SELECT name FROM authors WHERE name = @slug AND @filter::bool;
42+
@@ -48,4 +47,4 @@
43+
SELECT id FROM authors WHERE name != $1 AND bio <> $2;
4544

4645
-- name: DeleteAuthor :exec
4746
-DELETE FROM authors WHERE id = @id

internal/engine/dolphin/convert.go

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,31 @@ type cc struct {
2121
// lowercasing them for case-insensitive catalog matching; the format
2222
// parser sets it (see NewFormatParser).
2323
preserveCase bool
24+
// src is the statement's source text, set alongside preserveCase: the
25+
// format parser reads spellings the tree does not keep (`!=` vs `<>`)
26+
// back out of it.
27+
src string
28+
}
29+
30+
// operatorSpelling reads the operator token that sits immediately before
31+
// the operand at byte offset rpos, reporting which of the candidate
32+
// spellings the author wrote. Source that does not end in a candidate right
33+
// there (a comment against the operand, no recorded position) returns
34+
// ok=false and the caller keeps the canonical name.
35+
func (c *cc) operatorSpelling(rpos int, candidates ...string) (string, bool) {
36+
if c.src == "" || rpos <= 0 || rpos > len(c.src) {
37+
return "", false
38+
}
39+
end := rpos
40+
for end > 0 && (c.src[end-1] == ' ' || c.src[end-1] == '\t' || c.src[end-1] == '\r' || c.src[end-1] == '\n') {
41+
end--
42+
}
43+
for _, cand := range candidates {
44+
if end >= len(cand) && c.src[end-len(cand):end] == cand {
45+
return cand, true
46+
}
47+
}
48+
return "", false
2449
}
2550

2651
func todo(n pcast.Node) *ast.TODO {
@@ -224,11 +249,20 @@ func (c *cc) convertBinaryOperationExpr(n *pcast.BinaryOperationExpr) ast.Node {
224249
Location: n.OriginTextPosition(),
225250
}
226251
} else {
252+
name := opToName(n.Op)
253+
if n.Op == opcode.NE && c.src != "" {
254+
// MySQL spells inequality two ways (!= and <>) and the tree
255+
// keeps only the opcode, so the format parser reads the
256+
// author's choice back out of the source.
257+
if op, ok := c.operatorSpelling(n.R.OriginTextPosition(), "!=", "<>"); ok {
258+
name = op
259+
}
260+
}
227261
return &ast.A_Expr{
228262
// TODO: Set kind
229263
Name: &ast.List{
230264
Items: []ast.Node{
231-
&ast.String{Str: opToName(n.Op)},
265+
&ast.String{Str: name},
232266
},
233267
},
234268
Lexpr: c.convert(n.L),

internal/engine/dolphin/parse.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,11 @@ func (p *Parser) ParseFile(r io.Reader) (*ast.File, error) {
9797
searchFrom := 0
9898
for i := range stmtNodes {
9999
converter := &cc{preserveCase: p.preserveCase}
100+
if p.preserveCase {
101+
// The format parser also preserves operator spellings the tree
102+
// does not keep; the compiler's parser stays canonical.
103+
converter.src = src
104+
}
100105
// A statement sqlc has no node for converts to a TODO and stays in
101106
// the list: the formatter needs its extent to keep it as written,
102107
// and Parse filters it out for the compiler.

internal/engine/postgresql/parse.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111

1212
"github.com/sqlc-dev/sqlc/internal/source"
1313
"github.com/sqlc-dev/sqlc/internal/sql/ast"
14+
"github.com/sqlc-dev/sqlc/internal/sql/astutils"
1415
"github.com/sqlc-dev/sqlc/internal/sql/sqlerr"
1516
)
1617

@@ -144,7 +145,38 @@ func NewParser() *Parser {
144145
return &Parser{}
145146
}
146147

148+
// NewFormatParser returns the parser sqlc fmt uses. It differs from the
149+
// compiler's parser in one way: where the grammar normalizes an operator the
150+
// author may spell two ways (`!=` parses as `<>`), the spelling is read back
151+
// out of the source so formatting preserves it. The compiler keeps the
152+
// normalized name, which is the one the catalog knows.
153+
func NewFormatParser() *Parser {
154+
return &Parser{preserveSpelling: true}
155+
}
156+
147157
type Parser struct {
158+
preserveSpelling bool
159+
}
160+
161+
// restoreOperatorSpelling rewrites operators the grammar normalized back to
162+
// the spelling the author wrote. The scanner turns `!=` into `<>` before the
163+
// AST exists; an A_Expr's location points at the operator token, so the
164+
// source says which spelling to print.
165+
func restoreOperatorSpelling(n ast.Node, src string) {
166+
astutils.Walk(astutils.VisitorFunc(func(node ast.Node) {
167+
expr, ok := node.(*ast.A_Expr)
168+
if !ok || expr.Name == nil || len(expr.Name.Items) != 1 {
169+
return
170+
}
171+
s, ok := expr.Name.Items[0].(*ast.String)
172+
if !ok || s.Str != "<>" {
173+
return
174+
}
175+
loc := expr.Location
176+
if loc >= 0 && loc+2 <= len(src) && src[loc:loc+2] == "!=" {
177+
expr.Name = &ast.List{Items: []ast.Node{&ast.String{Str: "!="}}}
178+
}
179+
}), n)
148180
}
149181

150182
var errSkip = errors.New("skip stmt")
@@ -208,6 +240,9 @@ func (p *Parser) ParseFile(r io.Reader) (*ast.File, error) {
208240
if n == nil {
209241
return nil, fmt.Errorf("unexpected nil node")
210242
}
243+
if p.preserveSpelling {
244+
restoreOperatorSpelling(n, contents)
245+
}
211246
stmts = append(stmts, ast.Statement{
212247
Raw: &ast.RawStmt{
213248
Stmt: n,

internal/sql/ast/comment.go

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package ast
22

33
import (
44
"sort"
5+
"strings"
56

67
"github.com/sqlc-dev/sqlc/internal/sql/format"
78
)
@@ -201,13 +202,70 @@ func AttachComments(raw *RawStmt, d format.Dialect, comments []Comment, src stri
201202
break
202203
}
203204
}
204-
if prevPos >= 0 && nextPos >= 0 && lineOf(prevPos) != lineOf(nextPos) {
205+
if prevPos < 0 || nextPos < 0 {
206+
continue
207+
}
208+
// The boundary before a bare VALUES list is a seam between two paren
209+
// lists, so the neighbouring nodes' lines cannot decide it: with both
210+
// lists broken they always differ, even when the author glued
211+
// `) VALUES (`. Read the author's choice at the keyword itself.
212+
if sel, ok := a.node.(*SelectStmt); ok && items(sel.ValuesLists) {
213+
if broken, ok := valuesSeamBroken(src, nextPos); ok {
214+
if broken {
215+
table.breaks[a.node] = true
216+
}
217+
continue
218+
}
219+
}
220+
if lineOf(prevPos) != lineOf(nextPos) {
205221
table.breaks[a.node] = true
206222
}
207223
}
208224
return table
209225
}
210226

227+
// valuesSeamBroken reports whether the author broke the line before a bare
228+
// VALUES keyword. nextPos is the position of the first printed node inside
229+
// the list; scanning backward from it crosses `(` and then the keyword, and
230+
// the whitespace in front of the keyword holds the answer. Source that does
231+
// not scan that way (an extra paren, a comment against the keyword) returns
232+
// ok=false and the caller falls back to the line heuristic.
233+
func valuesSeamBroken(src string, nextPos int) (broken bool, ok bool) {
234+
if nextPos <= 0 || nextPos > len(src) {
235+
return false, false
236+
}
237+
isSpace := func(c byte) bool {
238+
return c == ' ' || c == '\t' || c == '\r' || c == '\n'
239+
}
240+
i := nextPos - 1
241+
for i >= 0 && isSpace(src[i]) {
242+
i--
243+
}
244+
if i < 0 || src[i] != '(' {
245+
return false, false
246+
}
247+
i--
248+
for i >= 0 && isSpace(src[i]) {
249+
i--
250+
}
251+
const keyword = "VALUES"
252+
if i+1 < len(keyword) || !strings.EqualFold(src[i+1-len(keyword):i+1], keyword) {
253+
return false, false
254+
}
255+
i -= len(keyword)
256+
// A keyword, not the tail of an identifier.
257+
if i >= 0 && !isSpace(src[i]) && src[i] != ')' {
258+
return false, false
259+
}
260+
for i >= 0 && isSpace(src[i]) {
261+
if src[i] == '\n' {
262+
broken = true
263+
}
264+
i--
265+
}
266+
return broken, true
267+
}
268+
211269
type anchor struct {
212270
node Node
213271
pos int

internal/sql/ast/insert_stmt.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,20 @@ func (n *InsertStmt) Format(buf *TrackedBuffer, d format.Dialect) {
6161
if n.DefaultValues {
6262
buf.WriteString(" DEFAULT VALUES")
6363
} else if set(n.SelectStmt) {
64-
buf.beforeClause(n.SelectStmt, d)
65-
buf.Line()
64+
if sel, ok := n.SelectStmt.(*SelectStmt); ok && items(sel.ValuesLists) {
65+
// The seam before a bare VALUES list keeps the author's own
66+
// choice — `) VALUES (` glued or VALUES on its own line — so it
67+
// gets a group of its own: a break inside either paren list must
68+
// not decide it. AttachComments reads the choice out of the
69+
// source (see valuesSeamBroken) and marks the boundary.
70+
buf.Group()
71+
buf.beforeClause(n.SelectStmt, d)
72+
buf.Line()
73+
buf.EndGroup()
74+
} else {
75+
buf.beforeClause(n.SelectStmt, d)
76+
buf.Line()
77+
}
6678
buf.astFormat(n.SelectStmt, d)
6779
}
6880

0 commit comments

Comments
 (0)