Repository navigation
Expand file tree
/
Copy pathstatement_test.go
More file actions
456 lines (436 loc) · 16.2 KB
/
Copy pathstatement_test.go
File metadata and controls
456 lines (436 loc) · 16.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
package statement
import (
"go/ast"
"go/parser"
"go/token"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseOneKinds(t *testing.T) {
tests := []struct {
name string
sql string
want Statement
}{
{
name: "alter table unqualified",
sql: "ALTER TABLE users ADD COLUMN age int",
want: Statement{kind: KindAlterTable, table: "users"},
},
{
name: "alter table schema-qualified",
sql: "ALTER TABLE billing.invoices DROP COLUMN note",
want: Statement{kind: KindAlterTable, schema: "billing", table: "invoices"},
},
{
name: "alter table quoted mixed-case identifier",
sql: `ALTER TABLE "Order Items" ADD COLUMN qty int`,
want: Statement{kind: KindAlterTable, table: "Order Items"},
},
{
name: "alter table if exists",
sql: "ALTER TABLE IF EXISTS users ADD COLUMN age int",
want: Statement{kind: KindAlterTable, table: "users"},
},
{
name: "alter table rename to parses as RenameStmt but is a table target",
sql: "ALTER TABLE users RENAME TO users_old",
want: Statement{kind: KindAlterTable, table: "users"},
},
{
name: "alter table rename column",
sql: "ALTER TABLE users RENAME COLUMN a TO b",
want: Statement{kind: KindAlterTable, table: "users"},
},
{
name: "alter table rename constraint",
sql: "ALTER TABLE users RENAME CONSTRAINT users_pk TO users_pkey",
want: Statement{kind: KindAlterTable, table: "users"},
},
{
name: "alter table rename schema-qualified",
sql: "ALTER TABLE billing.users RENAME COLUMN a TO b",
want: Statement{kind: KindAlterTable, schema: "billing", table: "users"},
},
{
name: "alter table set schema parses as AlterObjectSchemaStmt but is a table target",
sql: "ALTER TABLE users SET SCHEMA archive",
want: Statement{kind: KindAlterTable, table: "users"},
},
{
name: "alter table owner to",
sql: "ALTER TABLE users OWNER TO app_owner",
want: Statement{kind: KindAlterTable, table: "users"},
},
{
name: "alter view rename is not a table target",
sql: "ALTER VIEW v RENAME TO w",
want: Statement{kind: KindOther},
},
{
name: "alter index rename is not a table target",
sql: "ALTER INDEX i RENAME TO j",
want: Statement{kind: KindOther},
},
{
name: "alter sequence set schema is not a table target",
sql: "ALTER SEQUENCE s SET SCHEMA archive",
want: Statement{kind: KindOther},
},
{
name: "create index",
sql: "CREATE INDEX idx_users_email ON users (email)",
want: Statement{kind: KindCreateIndex, table: "users", buildsIndex: true},
},
{
name: "create index schema-qualified",
sql: "CREATE INDEX idx_users_email ON billing.users (email)",
want: Statement{kind: KindCreateIndex, schema: "billing", table: "users", buildsIndex: true},
},
{
name: "create unique index concurrently is a concurrent index statement",
sql: "CREATE UNIQUE INDEX CONCURRENTLY idx ON users (email)",
want: Statement{kind: KindCreateIndex, table: "users", concurrent: true, buildsIndex: true},
},
{
name: "drop index",
sql: "DROP INDEX idx_users_email",
want: Statement{kind: KindDropIndex, indexTarget: IndexTargetSingleRelation,
indexRelation: IndexRelation{Name: "idx_users_email", Kind: IndexRelationIndex}},
},
{
name: "drop schema-qualified index names the index's schema",
sql: "DROP INDEX app.idx_users_email",
want: Statement{kind: KindDropIndex, indexTarget: IndexTargetSingleRelation,
indexRelation: IndexRelation{Schema: "app", Name: "idx_users_email", Kind: IndexRelationIndex}},
},
{
name: "drop of several indexes names no single relation",
sql: "DROP INDEX idx_a, idx_b",
want: Statement{kind: KindDropIndex, indexTarget: IndexTargetOther},
},
{
name: "drop index concurrently",
sql: "DROP INDEX CONCURRENTLY idx_users_email",
want: Statement{kind: KindDropIndex, concurrent: true, indexTarget: IndexTargetSingleRelation,
indexRelation: IndexRelation{Name: "idx_users_email", Kind: IndexRelationIndex}},
},
{
name: "reindex table",
sql: "REINDEX TABLE users",
want: Statement{kind: KindReindex, indexTarget: IndexTargetSingleRelation,
indexRelation: IndexRelation{Name: "users", Kind: IndexRelationTable}},
},
{
name: "reindex index",
sql: "REINDEX INDEX idx_users_email",
want: Statement{kind: KindReindex, indexTarget: IndexTargetSingleRelation,
indexRelation: IndexRelation{Name: "idx_users_email", Kind: IndexRelationIndex}},
},
{
name: "reindex schema-qualified index names the index's schema",
sql: "REINDEX INDEX app.idx_users_email",
want: Statement{kind: KindReindex, indexTarget: IndexTargetSingleRelation,
indexRelation: IndexRelation{Schema: "app", Name: "idx_users_email", Kind: IndexRelationIndex}},
},
{
name: "reindex schema names no single relation",
sql: "REINDEX SCHEMA app",
want: Statement{kind: KindReindex, indexTarget: IndexTargetOther},
},
{
name: "reindex table concurrently",
sql: "REINDEX TABLE CONCURRENTLY users",
want: Statement{kind: KindReindex, concurrent: true, indexTarget: IndexTargetSingleRelation,
indexRelation: IndexRelation{Name: "users", Kind: IndexRelationTable}},
},
{
name: "alter index parses as AlterTableStmt but is not a table target",
sql: "ALTER INDEX idx_users_email SET (fillfactor = 90)",
want: Statement{kind: KindOther},
},
{
name: "drop table is not a drop-index",
sql: "DROP TABLE users",
want: Statement{kind: KindCatalogWork},
},
{
name: "create table",
sql: "CREATE TABLE t (id int)",
want: Statement{kind: KindCreateTable, table: "t"},
},
{
name: "dml",
sql: "UPDATE users SET age = 1",
want: Statement{kind: KindDataChange},
},
{
name: "grant is provisioning, unlike create role catalog syntax",
sql: "GRANT SELECT ON users TO app",
want: Statement{kind: KindProvisioning},
},
{
name: "insert is data change, unlike create table as",
sql: "INSERT INTO users VALUES (1)",
want: Statement{kind: KindDataChange},
},
{
name: "create table as is neither a plain create table nor a data change",
sql: "CREATE TABLE users_copy AS SELECT * FROM users",
want: Statement{kind: KindOther},
},
{
name: "delete is data change",
sql: "DELETE FROM users WHERE id = 1",
want: Statement{kind: KindDataChange},
},
{
name: "merge is data change",
sql: "MERGE INTO users u USING staged s ON u.id = s.id WHEN MATCHED THEN UPDATE SET age = s.age",
want: Statement{kind: KindDataChange},
},
{
name: "create role is provisioning",
sql: "CREATE ROLE app LOGIN",
want: Statement{kind: KindProvisioning},
},
{
name: "row-level-security policy is provisioning",
sql: "CREATE POLICY p ON users USING (owner = current_user)",
want: Statement{kind: KindProvisioning},
},
{
name: "publication is provisioning",
sql: "CREATE PUBLICATION pub FOR TABLE users",
want: Statement{kind: KindProvisioning},
},
{
name: "create view is catalog work",
sql: "CREATE VIEW v AS SELECT id FROM users",
want: Statement{kind: KindCatalogWork},
},
{
name: "create function is catalog work",
sql: "CREATE FUNCTION f() RETURNS int LANGUAGE sql AS 'SELECT 1'",
want: Statement{kind: KindCatalogWork},
},
{
name: "create trigger is catalog work",
sql: "CREATE TRIGGER trg BEFORE INSERT ON users FOR EACH ROW EXECUTE FUNCTION f()",
want: Statement{kind: KindCatalogWork},
},
{
name: "create extension is catalog work",
sql: "CREATE EXTENSION pg_stat_statements",
want: Statement{kind: KindCatalogWork},
},
{
name: "standalone sequence is catalog work",
sql: "CREATE SEQUENCE users_id_seq",
want: Statement{kind: KindCatalogWork},
},
{
name: "comment is catalog work",
sql: "COMMENT ON TABLE users IS 'people'",
want: Statement{kind: KindCatalogWork},
},
{
name: "drop view is catalog work, unlike drop index",
sql: "DROP VIEW v",
want: Statement{kind: KindCatalogWork},
},
{
name: "vacuum is unnamed grammar and stays other",
sql: "VACUUM users",
want: Statement{kind: KindOther},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ParseOne(tt.sql)
require.NoError(t, err)
tt.want.sql = tt.sql
assert.Equal(t, tt.want, got)
})
}
}
func TestParseOneRejectsInvalidSQL(t *testing.T) {
_, err := ParseOne("ALTER TABEL users ADD COLUMN age int")
require.Error(t, err)
assert.Contains(t, err.Error(), "parse statement")
}
func TestParseOneRejectsMultipleStatements(t *testing.T) {
_, err := ParseOne("ALTER TABLE a ADD COLUMN x int; ALTER TABLE b ADD COLUMN y int")
require.ErrorIs(t, err, ErrNotOneStatement)
}
// A second statement smuggled behind a legitimate ALTER never yields a
// Statement at all — the executor only accepts what ParseOne constructs, so
// multi-statement SQL is unrepresentable downstream (invariant ST-7).
func TestParseOneRejectsSmuggledStatement(t *testing.T) {
_, err := ParseOne("ALTER TABLE t ADD COLUMN a int; DROP TABLE victim")
require.ErrorIs(t, err, ErrNotOneStatement)
}
func TestParseOneRejectsEmptyInput(t *testing.T) {
_, err := ParseOne("")
require.ErrorIs(t, err, ErrNotOneStatement)
}
func TestKindString(t *testing.T) {
assert.Equal(t, "ALTER TABLE", KindAlterTable.String())
assert.Equal(t, "CREATE INDEX", KindCreateIndex.String())
assert.Equal(t, "DROP INDEX", KindDropIndex.String())
assert.Equal(t, "REINDEX", KindReindex.String())
assert.Equal(t, "other", KindOther.String())
}
// Canonical is the report's one rendering per change: the diff door's
// quoted generation and the alter door's hand-written text converge on the
// same string, so a consumer hashing or displaying report SQL sees one
// spelling regardless of front door.
func TestCanonicalConvergesQuotingAcrossFrontDoors(t *testing.T) {
generated, err := Canonical(`ALTER TABLE "t_1"."t" DROP COLUMN "doomed"`)
require.NoError(t, err)
submitted, err := Canonical("ALTER TABLE t_1.t DROP COLUMN doomed")
require.NoError(t, err)
assert.Equal(t, generated, submitted)
// Identifiers that need quoting keep it.
kept, err := Canonical(`ALTER TABLE "Mixed Case" DROP COLUMN c`)
require.NoError(t, err)
assert.Contains(t, kept, `"Mixed Case"`)
}
func TestCanonicalRefusesNotExactlyOneStatement(t *testing.T) {
_, err := Canonical("ALTER TABLE t DROP COLUMN a; ALTER TABLE t DROP COLUMN b")
require.ErrorIs(t, err, ErrNotOneStatement)
_, err = Canonical("not sql")
require.Error(t, err)
}
// Reprinting through the deparser drops comments, so Canonical refuses
// commented input rather than silently discarding content.
func TestCanonicalRefusesCommentedInput(t *testing.T) {
_, err := Canonical("ALTER TABLE t DROP COLUMN a -- doomed")
require.ErrorIs(t, err, ErrCommentLoss)
_, err = Canonical("ALTER TABLE t /* keep */ DROP COLUMN a")
require.ErrorIs(t, err, ErrCommentLoss)
}
func TestBuildsIndex(t *testing.T) {
tests := []struct {
name string
sql string
want bool
}{
{"create index", "CREATE INDEX i ON s.t (c)", true},
{"create index concurrently", "CREATE INDEX CONCURRENTLY i ON s.t (c)", true},
{"add unique constraint", "ALTER TABLE s.t ADD CONSTRAINT u UNIQUE (c)", true},
{"add primary key", "ALTER TABLE s.t ADD CONSTRAINT p PRIMARY KEY (c)", true},
{"add exclusion constraint", "ALTER TABLE s.t ADD CONSTRAINT x EXCLUDE USING gist (c WITH =)", true},
{"add column with inline unique", "ALTER TABLE s.t ADD COLUMN e int UNIQUE", true},
{"add column with inline primary key", "ALTER TABLE s.t ADD COLUMN e int PRIMARY KEY", true},
{"unique using index adopts an existing index", "ALTER TABLE s.t ADD CONSTRAINT u UNIQUE USING INDEX u", false},
{"primary key using index adopts an existing index", "ALTER TABLE s.t ADD CONSTRAINT p PRIMARY KEY USING INDEX p", false},
{"plain add column", "ALTER TABLE s.t ADD COLUMN e int", false},
{"check constraint builds nothing", "ALTER TABLE s.t ADD CONSTRAINT c CHECK (e > 0)", false},
{"foreign key builds nothing on the referencing table", "ALTER TABLE s.t ADD CONSTRAINT fk FOREIGN KEY (e) REFERENCES s.p (id)", false},
{"rewriting type change rebuilds existing indexes only", "ALTER TABLE s.t ALTER COLUMN c TYPE bigint", false},
{"set not null", "ALTER TABLE s.t ALTER COLUMN c SET NOT NULL", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
st, err := ParseOne(tt.sql)
require.NoError(t, err)
assert.Equal(t, tt.want, st.BuildsIndex())
})
}
}
// The closed set is complete: Kinds() enumerates exactly the Kind constants
// the iota block in statement.go declares, and every named kind has its own
// String(). A length check pinned to the last constant would keep passing
// after a kind is appended past it and left out of the walk.
func TestKindsIsClosedAndNamed(t *testing.T) {
kinds := Kinds()
require.Equal(t, KindOther, kinds[0], "the catch-all leads the walk")
declared := declaredKinds(t)
enumerated := make(map[Kind]struct{}, len(kinds))
names := map[string]bool{}
for _, k := range kinds {
_, dup := enumerated[k]
assert.False(t, dup, "duplicate kind %d", k)
enumerated[k] = struct{}{}
assert.NotEmpty(t, k.String())
assert.False(t, names[k.String()], "duplicate kind name %q", k.String())
names[k.String()] = true
if k != KindOther {
assert.NotEqual(t, KindOther.String(), k.String(),
"kind %d falls through to the catch-all name", k)
}
}
assert.Equal(t, declared, enumerated,
"Kinds() must enumerate exactly the Kind constants statement.go declares")
}
// IndexTargets() is the closed set the eligibility registry walks, so it
// must name every declared target shape except IndexTargetNone, which
// marks statements outside index maintenance rather than a shape of one.
// Each named shape has its own String() rather than the numeric fallback.
func TestIndexTargetsIsClosedAndNamed(t *testing.T) {
targets := IndexTargets()
assert.NotContains(t, targets, IndexTargetNone, "the non-shape is not a target the registry decides")
declared := declaredIotaValues(t, "IndexTargetNone")
delete(declared, int(IndexTargetNone))
enumerated := make(map[int]struct{}, len(targets))
names := map[string]bool{}
for _, target := range targets {
_, dup := enumerated[int(target)]
assert.False(t, dup, "duplicate index target %d", target)
enumerated[int(target)] = struct{}{}
assert.NotContains(t, target.String(), "IndexTarget(", "target %d falls through to the numeric name", target)
assert.False(t, names[target.String()], "duplicate index target name %q", target.String())
names[target.String()] = true
}
assert.Equal(t, declared, enumerated,
"IndexTargets() must enumerate exactly the IndexTarget constants statement.go declares after IndexTargetNone")
assert.Equal(t, "none", IndexTargetNone.String())
}
// declaredKinds parses statement.go and returns the value of every constant
// in the iota block that KindOther opens.
func declaredKinds(t *testing.T) map[Kind]struct{} {
t.Helper()
values := declaredIotaValues(t, "KindOther")
declared := make(map[Kind]struct{}, len(values))
for v := range values {
declared[Kind(v)] = struct{}{}
}
return declared
}
// declaredIotaValues parses statement.go and returns the value of every
// constant in the iota block that the named anchor constant opens.
func declaredIotaValues(t *testing.T, anchor string) map[int]struct{} {
t.Helper()
fset := token.NewFileSet()
file, err := parser.ParseFile(fset, "statement.go", nil, parser.SkipObjectResolution)
require.NoError(t, err)
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.CONST || len(gen.Specs) == 0 {
continue
}
first, ok := gen.Specs[0].(*ast.ValueSpec)
if !ok || len(first.Names) != 1 || first.Names[0].Name != anchor {
continue
}
declared := make(map[int]struct{}, len(gen.Specs))
for i, spec := range gen.Specs {
vs, ok := spec.(*ast.ValueSpec)
require.True(t, ok)
require.Len(t, vs.Names, 1, "one constant per line in the %s iota block", anchor)
if i == 0 {
require.Len(t, vs.Values, 1, "%s opens the iota block", anchor)
ident, ok := vs.Values[0].(*ast.Ident)
require.True(t, ok && ident.Name == "iota", "%s is the iota anchor", anchor)
} else {
require.Empty(t, vs.Values, "%s takes its value from iota", vs.Names[0].Name)
}
declared[i] = struct{}{}
}
return declared
}
t.Fatalf("statement.go declares no const block opened by %s", anchor)
return nil
}