-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathfeature_test.go
More file actions
483 lines (393 loc) · 16.3 KB
/
feature_test.go
File metadata and controls
483 lines (393 loc) · 16.3 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
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
package sqlpro
// feature_test.go is a from-scratch, feature-by-feature test suite that aims to
// cover the full public surface of sqlpro. It is independent of the older tests
// in query_test.go etc. and uses its own tables (prefix "feat_"). It runs
// against the SQLite database set up in TestMain.
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func mustExec(t *testing.T, sql string) {
t.Helper()
require.NoError(t, dbConn.Exec(sql))
}
// --- struct tags: pk, omitempty, readonly, "-", embedding -------------------
func TestFeatureTags(t *testing.T) {
mustExec(t, `DROP TABLE IF EXISTS feat_tags`)
mustExec(t, `CREATE TABLE feat_tags(
id INTEGER PRIMARY KEY AUTOINCREMENT,
source TEXT NOT NULL DEFAULT 'embedded-default',
name TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'db-default',
slug TEXT NOT NULL DEFAULT 'readonly-default')`)
type meta struct {
Source string `db:"source,omitempty"`
}
type row struct {
meta // embedded: its "source" column is promoted
ID int64 `db:"id,pk,omitempty"`
Name string `db:"name"`
Kind string `db:"kind,omitempty"` // zero -> DB default applies
Slug string `db:"slug,readonly"` // never written
Notes string `db:"-"` // never persisted
}
in := &row{Name: "widget", Slug: "should-be-ignored", Notes: "go-only"}
require.NoError(t, dbConn.Insert("feat_tags", in))
assert.Greater(t, in.ID, int64(0), "pk written back")
var got row
require.NoError(t, dbConn.Query(&got, "SELECT * FROM feat_tags WHERE id = ?", in.ID))
assert.Equal(t, "widget", got.Name)
assert.Equal(t, "embedded-default", got.Source, "omitempty embedded field -> DB default")
assert.Equal(t, "db-default", got.Kind, "omitempty field -> DB default")
assert.Equal(t, "readonly-default", got.Slug, "readonly field not written -> DB default")
assert.Equal(t, "", got.Notes, `db:"-" never persisted`)
}
// --- json column + nullable pointers ----------------------------------------
func TestFeatureJSONAndNull(t *testing.T) {
mustExec(t, `DROP TABLE IF EXISTS feat_json`)
mustExec(t, `CREATE TABLE feat_json(
id INTEGER PRIMARY KEY AUTOINCREMENT,
opt TEXT,
cfg TEXT)`)
type cfg struct {
Theme string `json:"theme"`
}
type row struct {
ID int64 `db:"id,pk,omitempty"`
Opt *string `db:"opt"` // nullable
Cfg *cfg `db:"cfg,json"` // JSON, nil -> NULL
}
opt := "set"
require.NoError(t, dbConn.Insert("feat_json", &row{Opt: &opt, Cfg: &cfg{Theme: "dark"}}))
require.NoError(t, dbConn.Insert("feat_json", &row{})) // Opt nil, Cfg nil
var rows []*row
require.NoError(t, dbConn.Query(&rows, "SELECT * FROM feat_json ORDER BY id"))
require.Len(t, rows, 2)
require.NotNil(t, rows[0].Opt)
assert.Equal(t, "set", *rows[0].Opt)
require.NotNil(t, rows[0].Cfg)
assert.Equal(t, "dark", rows[0].Cfg.Theme)
assert.Nil(t, rows[1].Opt, "nil pointer round-trips as NULL")
assert.Nil(t, rows[1].Cfg, "zero JSON pointer stored as NULL")
// Confirm the second row really is SQL NULL, not the string "null".
var n int64
require.NoError(t, dbConn.Query(&n, "SELECT count(*) FROM feat_json WHERE cfg IS NULL"))
assert.Equal(t, int64(1), n)
}
// --- Save = upsert (insert when pk zero, update when set) --------------------
func TestFeatureSaveUpsert(t *testing.T) {
mustExec(t, `DROP TABLE IF EXISTS feat_save`)
mustExec(t, `CREATE TABLE feat_save(id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT)`)
type row struct {
ID int64 `db:"id,pk,omitempty"`
V string `db:"v"`
}
r := &row{V: "first"}
require.NoError(t, dbConn.Save("feat_save", r)) // insert path
require.Greater(t, r.ID, int64(0))
r.V = "second"
require.NoError(t, dbConn.Save("feat_save", r)) // update path (pk set)
var got row
require.NoError(t, dbConn.Query(&got, "SELECT * FROM feat_save WHERE id = ?", r.ID))
assert.Equal(t, "second", got.V)
var count int64
require.NoError(t, dbConn.Query(&count, "SELECT count(*) FROM feat_save"))
assert.Equal(t, int64(1), count, "upsert did not create a duplicate")
}
// --- bulk: InsertBulk, OnConflictDoNothing, UpdateBulk ----------------------
func TestFeatureBulk(t *testing.T) {
ctx := context.Background()
mustExec(t, `DROP TABLE IF EXISTS feat_bulk`)
mustExec(t, `CREATE TABLE feat_bulk(
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
n INTEGER)`)
type row struct {
ID int64 `db:"id,pk,omitempty"`
Name string `db:"name"`
N int64 `db:"n"`
}
require.NoError(t, dbConn.InsertBulk("feat_bulk", []*row{
{Name: "a", N: 1}, {Name: "b", N: 2}, {Name: "c", N: 3},
}))
// OnConflictDoNothing on the unique "name": "a" is skipped, "d" inserted.
require.NoError(t, dbConn.InsertBulkOnConflictDoNothingContext(ctx, "feat_bulk",
[]*row{{Name: "a", N: 99}, {Name: "d", N: 4}}, "name"))
var total int64
require.NoError(t, dbConn.Query(&total, "SELECT count(*) FROM feat_bulk"))
assert.Equal(t, int64(4), total)
var aN int64
require.NoError(t, dbConn.Query(&aN, "SELECT n FROM feat_bulk WHERE name = 'a'"))
assert.Equal(t, int64(1), aN, "conflict row was not overwritten")
// UpdateBulk by pk.
var all []*row
require.NoError(t, dbConn.Query(&all, "SELECT * FROM feat_bulk ORDER BY id"))
for _, r := range all {
r.N += 100
}
require.NoError(t, dbConn.UpdateBulkContext(ctx, "feat_bulk", all))
var sum int64
require.NoError(t, dbConn.Query(&sum, "SELECT sum(n) FROM feat_bulk"))
assert.Equal(t, int64(1+2+3+4+400), sum)
}
// --- transactions: ExecTX commit/rollback -----------------------------------
func TestFeatureExecTX(t *testing.T) {
ctx := context.Background()
mustExec(t, `DROP TABLE IF EXISTS feat_tx`)
mustExec(t, `CREATE TABLE feat_tx(id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT)`)
type row struct {
ID int64 `db:"id,pk,omitempty"`
V string `db:"v"`
}
// Commit: writes are visible afterwards, and operations inside use CtxTX.
require.NoError(t, dbConn.ExecTX(ctx, func(ctx context.Context) error {
tx := CtxTX(ctx)
assert.True(t, tx.ActiveTX())
assert.True(t, tx.IsWriteMode())
return tx.Insert("feat_tx", &row{V: "kept"})
}, nil))
// Rollback: returning an error discards all writes in the job.
sentinel := errors.New("boom")
err := dbConn.ExecTX(ctx, func(ctx context.Context) error {
tx := CtxTX(ctx)
if err := tx.Insert("feat_tx", &row{V: "discarded"}); err != nil {
return err
}
return sentinel
}, nil)
assert.ErrorIs(t, err, sentinel)
var vals []string
require.NoError(t, dbConn.Query(&vals, "SELECT v FROM feat_tx ORDER BY id"))
assert.Equal(t, []string{"kept"}, vals)
}
// --- transactions: nesting is rejected --------------------------------------
func TestFeatureExecTXNoNesting(t *testing.T) {
ctx := context.Background()
err := dbConn.ExecTX(ctx, func(ctx context.Context) error {
return dbConn.ExecTX(ctx, func(ctx context.Context) error { return nil }, nil)
}, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "unable to nest")
}
// --- transactions: hooks ----------------------------------------------------
func TestFeatureTXHooks(t *testing.T) {
ctx := context.Background()
mustExec(t, `DROP TABLE IF EXISTS feat_hooks`)
mustExec(t, `CREATE TABLE feat_hooks(id INTEGER PRIMARY KEY AUTOINCREMENT, v TEXT)`)
type row struct {
ID int64 `db:"id,pk,omitempty"`
V string `db:"v"`
}
// Commit case: BeforeCommit, AfterCommit and AfterTransaction fire;
// AfterRollback does not.
var order []string
require.NoError(t, dbConn.ExecTX(ctx, func(ctx context.Context) error {
tx := CtxTX(ctx)
tx.BeforeCommit(func() error { order = append(order, "before"); return nil })
tx.AfterCommit(func() { order = append(order, "afterCommit") })
tx.AfterRollback(func() { order = append(order, "afterRollback") })
tx.AfterTransaction(func() { order = append(order, "afterTx") })
return tx.Insert("feat_hooks", &row{V: "x"})
}, nil))
assert.Equal(t, []string{"before", "afterCommit", "afterTx"}, order)
// A failing BeforeCommit hook rolls the transaction back.
hookErr := errors.New("veto")
err := dbConn.ExecTX(ctx, func(ctx context.Context) error {
tx := CtxTX(ctx)
tx.BeforeCommit(func() error { return hookErr })
return tx.Insert("feat_hooks", &row{V: "vetoed"})
}, nil)
assert.ErrorIs(t, err, hookErr)
var count int64
require.NoError(t, dbConn.Query(&count, "SELECT count(*) FROM feat_hooks"))
assert.Equal(t, int64(1), count, "vetoed insert was rolled back")
// Rollback case: AfterRollback and AfterTransaction fire; the commit hooks do not.
order = nil
sentinel := errors.New("rollback me")
_ = dbConn.ExecTX(ctx, func(ctx context.Context) error {
tx := CtxTX(ctx)
tx.BeforeCommit(func() error { order = append(order, "before"); return nil })
tx.AfterCommit(func() { order = append(order, "afterCommit") })
tx.AfterRollback(func() { order = append(order, "afterRollback") })
tx.AfterTransaction(func() { order = append(order, "afterTx") })
return sentinel
}, nil)
assert.Equal(t, []string{"afterRollback", "afterTx"}, order)
}
// --- Exec helpers -----------------------------------------------------------
func TestFeatureExecRowsAffected(t *testing.T) {
ctx := context.Background()
mustExec(t, `DROP TABLE IF EXISTS feat_exec`)
mustExec(t, `CREATE TABLE feat_exec(id INTEGER PRIMARY KEY AUTOINCREMENT, v INTEGER)`)
type row struct {
ID int64 `db:"id,pk,omitempty"`
V int64 `db:"v"`
}
require.NoError(t, dbConn.InsertBulk("feat_exec", []*row{{V: 1}, {V: 1}, {V: 2}}))
affected, _, err := dbConn.ExecContextRowsAffected(ctx, "UPDATE feat_exec SET v = 9 WHERE v = 1")
require.NoError(t, err)
assert.Equal(t, int64(2), affected)
// Empty SQL is rejected.
assert.Error(t, dbConn.Exec(""))
}
// --- query: zero rows -------------------------------------------------------
func TestFeatureZeroRows(t *testing.T) {
mustExec(t, `DROP TABLE IF EXISTS feat_zero`)
mustExec(t, `CREATE TABLE feat_zero(id INTEGER PRIMARY KEY)`)
var x int64
err := dbConn.Query(&x, "SELECT id FROM feat_zero WHERE id = 1")
assert.ErrorIs(t, err, ErrQueryReturnedZeroRows)
// A slice target just comes back empty (no error).
var xs []int64
assert.NoError(t, dbConn.Query(&xs, "SELECT id FROM feat_zero"))
assert.Empty(t, xs)
}
// --- placeholders: IN ? expansion and @ identifier --------------------------
func TestFeaturePlaceholders(t *testing.T) {
mustExec(t, `DROP TABLE IF EXISTS feat_ph`)
mustExec(t, `CREATE TABLE feat_ph(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)`)
type row struct {
ID int64 `db:"id,pk,omitempty"`
Name string `db:"name"`
}
require.NoError(t, dbConn.InsertBulk("feat_ph", []*row{
{Name: "x"}, {Name: "y"}, {Name: "z"},
}))
// IN ? expands the slice into the right number of placeholders.
var names []string
require.NoError(t, dbConn.Query(&names,
"SELECT name FROM feat_ph WHERE name IN ? ORDER BY name", []string{"x", "z", "q"}))
assert.Equal(t, []string{"x", "z"}, names)
// @ quotes its argument as a SQL identifier (table/column name).
var count int64
require.NoError(t, dbConn.Query(&count, "SELECT count(*) FROM @", "feat_ph"))
assert.Equal(t, int64(3), count)
}
// --- escaping + ILIKE helpers -----------------------------------------------
func TestFeatureEscaping(t *testing.T) {
assert.Equal(t, `'O''Hara'`, escValue("O'Hara"))
assert.Equal(t, `'O''Hara'`, dbConn.EscValue("O'Hara"))
// IlikeSql builds a driver-correct snippet (SQLite here).
snippet := IlikeSql(dbConn.Driver(), "berg")
assert.Contains(t, snippet, "LIKE")
assert.Contains(t, snippet, "%berg%")
}
// --- introspection ----------------------------------------------------------
func TestFeatureVersionName(t *testing.T) {
v, err := dbConn.Version()
require.NoError(t, err)
assert.Contains(t, v, "Sqlite")
n, err := dbConn.Name()
require.NoError(t, err)
assert.NotEmpty(t, n)
}
// --- NullTime / NullJson / NullRawMessage scanners (read path) ---------------
func TestFeatureNullScanners(t *testing.T) {
mustExec(t, `DROP TABLE IF EXISTS feat_null`)
mustExec(t, `CREATE TABLE feat_null(id INTEGER PRIMARY KEY AUTOINCREMENT, t DATETIME, j TEXT, r TEXT)`)
now := time.Now()
type row struct {
ID int64 `db:"id,pk,omitempty"`
T *time.Time `db:"t"`
J json.RawMessage `db:"j"`
R *json.RawMessage `db:"r"`
}
raw := json.RawMessage(`{"a":1}`)
require.NoError(t, dbConn.Insert("feat_null", &row{T: &now, J: json.RawMessage(`[1,2]`), R: &raw}))
require.NoError(t, dbConn.Insert("feat_null", &row{})) // all NULL
var rows []*row
require.NoError(t, dbConn.Query(&rows, "SELECT * FROM feat_null ORDER BY id"))
require.Len(t, rows, 2)
require.NotNil(t, rows[0].T)
assert.Equal(t, now.Format(time.RFC3339Nano), rows[0].T.Format(time.RFC3339Nano))
assert.JSONEq(t, `[1,2]`, string(rows[0].J))
require.NotNil(t, rows[0].R)
assert.JSONEq(t, `{"a":1}`, string(*rows[0].R))
assert.Nil(t, rows[1].T)
assert.Empty(t, rows[1].J)
assert.Nil(t, rows[1].R)
}
// --- null / notnull tag options (non-json fields) ---------------------------
func TestFeatureNullNotNull(t *testing.T) {
mustExec(t, `DROP TABLE IF EXISTS feat_nn`)
mustExec(t, `CREATE TABLE feat_nn(id INTEGER PRIMARY KEY AUTOINCREMENT, plain INTEGER, nullable INTEGER)`)
// "null" on a value field stores a zero value as SQL NULL; without it, the
// zero value (0) is stored.
type row struct {
ID int64 `db:"id,pk,omitempty"`
Plain int64 `db:"plain"` // zero -> 0
Nullable int64 `db:"nullable,null"` // zero -> NULL
}
require.NoError(t, dbConn.Insert("feat_nn", &row{}))
var plainNull, nullableNull bool
require.NoError(t, dbConn.Query(&plainNull, "SELECT plain IS NULL FROM feat_nn"))
require.NoError(t, dbConn.Query(&nullableNull, "SELECT nullable IS NULL FROM feat_nn"))
assert.False(t, plainNull, "plain zero stored as 0, not NULL")
assert.True(t, nullableNull, `"null" tag stores zero as SQL NULL`)
// "notnull" on a pointer field rejects a nil value (it would otherwise become
// NULL): storing nil panics.
type strict struct {
ID int64 `db:"id,pk,omitempty"`
Name *string `db:"plain,notnull"`
}
assert.Panics(t, func() {
_ = dbConn.Insert("feat_nn", &strict{}) // Name is nil + notnull -> panic
})
}
// --- json null handling: null / notnull / default ---------------------------
func TestFeatureJSONNullModes(t *testing.T) {
mustExec(t, `DROP TABLE IF EXISTS feat_jnull`)
mustExec(t, `CREATE TABLE feat_jnull(id INTEGER PRIMARY KEY AUTOINCREMENT, jdef TEXT, jnull TEXT, jnotnull TEXT)`)
type cfg struct {
A int `json:"a"`
}
// All three pointers are nil. A nil pointer marshals to "null".
type row struct {
ID int64 `db:"id,pk,omitempty"`
JDef *cfg `db:"jdef,json"` // default: "null" -> SQL NULL
JNull *cfg `db:"jnull,json,null"` // null: -> SQL NULL
JNotNull *cfg `db:"jnotnull,json,notnull"` // notnull: -> literal "null"
}
require.NoError(t, dbConn.Insert("feat_jnull", &row{}))
var defNull, nullNull, notnullNull bool
require.NoError(t, dbConn.Query(&defNull, "SELECT jdef IS NULL FROM feat_jnull"))
require.NoError(t, dbConn.Query(&nullNull, "SELECT jnull IS NULL FROM feat_jnull"))
require.NoError(t, dbConn.Query(¬nullNull, "SELECT jnotnull IS NULL FROM feat_jnull"))
assert.True(t, defNull, `default json: zero marshaling to "null" -> SQL NULL`)
assert.True(t, nullNull, `",null": zero -> SQL NULL`)
assert.False(t, notnullNull, `",notnull": zero -> literal "null", not SQL NULL`)
var raw string
require.NoError(t, dbConn.Query(&raw, "SELECT jnotnull FROM feat_jnull"))
assert.Equal(t, "null", raw, `",notnull" stores the JSON text "null"`)
}
// --- json_ignore_error on read ----------------------------------------------
func TestFeatureJSONIgnoreError(t *testing.T) {
mustExec(t, `DROP TABLE IF EXISTS feat_jerr`)
mustExec(t, `CREATE TABLE feat_jerr(id INTEGER PRIMARY KEY AUTOINCREMENT, j TEXT)`)
// Put invalid JSON into the column directly.
mustExec(t, `INSERT INTO feat_jerr(j) VALUES ('not json at all')`)
type cfg struct {
A int `json:"a"`
}
// Without the flag, reading the invalid JSON is an error.
type strict struct {
ID int64 `db:"id,pk,omitempty"`
J cfg `db:"j,json"`
}
var s strict
assert.Error(t, dbConn.Query(&s, "SELECT * FROM feat_jerr"))
// With json_ignore_error, the unmarshal error is swallowed and the field is
// left zero.
type lenient struct {
ID int64 `db:"id,pk,omitempty"`
J cfg `db:"j,json,json_ignore_error"`
}
var l lenient
assert.NoError(t, dbConn.Query(&l, "SELECT * FROM feat_jerr"))
assert.Equal(t, cfg{}, l.J)
}