File: schema_test.go

package info (click to toggle)
incus 6.0.5-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 24,392 kB
  • sloc: sh: 16,313; ansic: 3,121; python: 457; makefile: 337; ruby: 51; sql: 50; lisp: 6
file content (496 lines) | stat: -rw-r--r-- 14,378 bytes parent folder | download | duplicates (2)
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
484
485
486
487
488
489
490
491
492
493
494
495
496
package schema_test

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"os"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"github.com/lxc/incus/v6/internal/server/db/query"
	"github.com/lxc/incus/v6/internal/server/db/schema"
	"github.com/lxc/incus/v6/shared/util"
)

// WriteTempFile creates a temp file with the specified content.
func WriteTempFile(dir string, prefix string, content string) (string, error) {
	f, err := os.CreateTemp(dir, prefix)
	if err != nil {
		return "", err
	}

	defer func() { _ = f.Close() }()

	_, err = f.WriteString(content)
	if err != nil {
		return "", err
	}

	return f.Name(), f.Close()
}

// Create a new Schema by specifying an explicit map from versions to Update
// functions.
func TestNewFromMap(t *testing.T) {
	db := newDB(t)
	schema := schema.NewFromMap(map[int]schema.Update{
		1: updateCreateTable,
		2: updateInsertValue,
	})
	initial, err := schema.Ensure(db)
	assert.NoError(t, err)
	assert.Equal(t, 0, initial)
}

// Panic if there are missing versions in the map.
func TestNewFromMap_MissingVersions(t *testing.T) {
	assert.Panics(t, func() {
		schema.NewFromMap(map[int]schema.Update{
			1: updateCreateTable,
			3: updateInsertValue,
		})
	}, "updates map misses version 2")
}

// If the database schema version is more recent than our update series, an
// error is returned.
func TestSchemaEnsure_VersionMoreRecentThanExpected(t *testing.T) {
	schema, db := newSchemaAndDB(t)
	schema.Add(updateNoop)
	_, err := schema.Ensure(db)
	assert.NoError(t, err)

	schema, _ = newSchemaAndDB(t)
	_, err = schema.Ensure(db)
	assert.NotNil(t, err)
	assert.EqualError(t, err, "schema version '1' is more recent than expected '0'")
}

// If a "fresh" SQL statement for creating the schema from scratch is provided,
// but it fails to run, an error is returned.
func TestSchemaEnsure_FreshStatementError(t *testing.T) {
	schema, db := newSchemaAndDB(t)
	schema.Add(updateNoop)
	schema.Fresh("garbage")

	_, err := schema.Ensure(db)
	assert.NotNil(t, err)
	assert.Contains(t, err.Error(), "cannot apply fresh schema")
}

// If the database schema contains "holes" in the applied versions, an error is
// returned.
func TestSchemaEnsure_MissingVersion(t *testing.T) {
	schema, db := newSchemaAndDB(t)
	schema.Add(updateNoop)
	_, err := schema.Ensure(db)
	assert.NoError(t, err)

	_, err = db.Exec(`INSERT INTO schema (version, updated_at) VALUES (3, strftime("%s"))`)
	assert.NoError(t, err)

	schema.Add(updateNoop)
	schema.Add(updateNoop)

	_, err = schema.Ensure(db)
	assert.NotNil(t, err)
	assert.EqualError(t, err, "Missing updates: 1 to 3")
}

// If the schema has no update, the schema table gets created and has no version.
func TestSchemaEnsure_ZeroUpdates(t *testing.T) {
	schema, db := newSchemaAndDB(t)

	_, err := schema.Ensure(db)
	assert.NoError(t, err)

	tx, err := db.Begin()
	assert.NoError(t, err)

	versions, err := query.SelectIntegers(context.Background(), tx, "SELECT version FROM SCHEMA")
	assert.NoError(t, err)
	assert.Equal(t, []int{}, versions)
}

// If the schema has updates and no one was applied yet, all of them get
// applied.
func TestSchemaEnsure_ApplyAllUpdates(t *testing.T) {
	schema, db := newSchemaAndDB(t)
	schema.Add(updateCreateTable)
	schema.Add(updateInsertValue)

	initial, err := schema.Ensure(db)
	assert.NoError(t, err)
	assert.Equal(t, 0, initial)

	tx, err := db.Begin()
	assert.NoError(t, err)

	// THe update version is recorded.
	versions, err := query.SelectIntegers(context.Background(), tx, "SELECT version FROM SCHEMA")
	assert.NoError(t, err)
	assert.Equal(t, []int{1, 2}, versions)

	// The two updates have been applied in order.
	ids, err := query.SelectIntegers(context.Background(), tx, "SELECT id FROM test")
	assert.NoError(t, err)
	assert.Equal(t, []int{1}, ids)
}

// If the schema schema has been created using a dump, the schema table will
// contain just one row with the update level associated with the dump. It's
// possible to apply further updates from there, and only these new ones will
// be inserted in the schema table.
func TestSchemaEnsure_ApplyAfterInitialDumpCreation(t *testing.T) {
	schema, db := newSchemaAndDB(t)
	schema.Add(updateCreateTable)
	schema.Add(updateAddColumn)
	_, err := schema.Ensure(db)
	assert.NoError(t, err)

	dump, err := schema.Dump(db)
	assert.NoError(t, err)

	_, db = newSchemaAndDB(t)
	schema.Fresh(dump)
	_, err = schema.Ensure(db)
	assert.NoError(t, err)

	schema.Add(updateNoop)
	_, err = schema.Ensure(db)
	assert.NoError(t, err)

	tx, err := db.Begin()
	assert.NoError(t, err)

	// Only updates starting from the initial dump are recorded.
	versions, err := query.SelectIntegers(context.Background(), tx, "SELECT version FROM SCHEMA")
	assert.NoError(t, err)
	assert.Equal(t, []int{2, 3}, versions)
}

// If the schema has updates and part of them were already applied, only the
// missing ones are applied.
func TestSchemaEnsure_OnlyApplyMissing(t *testing.T) {
	schema, db := newSchemaAndDB(t)
	schema.Add(updateCreateTable)
	_, err := schema.Ensure(db)
	assert.NoError(t, err)

	schema.Add(updateInsertValue)
	initial, err := schema.Ensure(db)
	assert.NoError(t, err)
	assert.Equal(t, 1, initial)

	tx, err := db.Begin()
	assert.NoError(t, err)

	// All update versions are recorded.
	versions, err := query.SelectIntegers(context.Background(), tx, "SELECT version FROM SCHEMA")
	assert.NoError(t, err)
	assert.Equal(t, []int{1, 2}, versions)

	// The two updates have been applied in order.
	ids, err := query.SelectIntegers(context.Background(), tx, "SELECT id FROM test")
	assert.NoError(t, err)
	assert.Equal(t, []int{1}, ids)
}

// If a update fails, an error is returned, and all previous changes are rolled
// back.
func TestSchemaEnsure_FailingUpdate(t *testing.T) {
	schema, db := newSchemaAndDB(t)
	schema.Add(updateCreateTable)
	schema.Add(updateBoom)
	_, err := schema.Ensure(db)
	assert.EqualError(t, err, "failed to apply update 1: boom")

	tx, err := db.Begin()
	assert.NoError(t, err)

	// Not update was applied.
	tables, err := query.SelectStrings(context.Background(), tx, "SELECT name FROM sqlite_master WHERE type = 'table'")
	assert.NoError(t, err)
	assert.NotContains(t, tables, "schema")
	assert.NotContains(t, tables, "test")
}

// If a hook fails, an error is returned, and all previous changes are rolled
// back.
func TestSchemaEnsure_FailingHook(t *testing.T) {
	schema, db := newSchemaAndDB(t)
	schema.Add(updateCreateTable)
	schema.Hook(func(context.Context, int, *sql.Tx) error { return errors.New("boom") })
	_, err := schema.Ensure(db)
	assert.EqualError(t, err, "failed to execute hook (version 0): boom")

	tx, err := db.Begin()
	assert.NoError(t, err)

	// Not update was applied.
	tables, err := query.SelectStrings(context.Background(), tx, "SELECT name FROM sqlite_master WHERE type = 'table'")
	assert.NoError(t, err)
	assert.NotContains(t, tables, "schema")
	assert.NotContains(t, tables, "test")
}

// If the schema check callback returns ErrGracefulAbort, the process is
// aborted, although every change performed so far gets still committed.
func TestSchemaEnsure_CheckGracefulAbort(t *testing.T) {
	check := func(ctx context.Context, current int, tx *sql.Tx) error {
		_, err := tx.Exec("CREATE TABLE test (n INTEGER)")
		require.NoError(t, err)
		return schema.ErrGracefulAbort
	}

	schema, db := newSchemaAndDB(t)
	schema.Add(updateCreateTable)
	schema.Check(check)

	_, err := schema.Ensure(db)
	require.EqualError(t, err, "schema check gracefully aborted")

	tx, err := db.Begin()
	assert.NoError(t, err)

	// The table created by the check function still got committed.
	// to insert the row was not.
	ids, err := query.SelectIntegers(context.Background(), tx, "SELECT n FROM test")
	assert.NoError(t, err)
	assert.Equal(t, []int{}, ids)
}

// The SQL text returns by Dump() can be used to create the schema from
// scratch, without applying each individual update.
func TestSchemaDump(t *testing.T) {
	schema, db := newSchemaAndDB(t)
	schema.Add(updateCreateTable)
	schema.Add(updateAddColumn)
	_, err := schema.Ensure(db)
	assert.NoError(t, err)

	dump, err := schema.Dump(db)
	assert.NoError(t, err)

	_, db = newSchemaAndDB(t)
	schema.Fresh(dump)
	_, err = schema.Ensure(db)
	assert.NoError(t, err)

	tx, err := db.Begin()
	assert.NoError(t, err)

	// All update versions are in place.
	versions, err := query.SelectIntegers(context.Background(), tx, "SELECT version FROM schema")
	assert.NoError(t, err)
	assert.Equal(t, []int{2}, versions)

	// Both the table added by the first update and the extra column added
	// by the second update are there.
	_, err = tx.Exec("SELECT id, name FROM test")
	assert.NoError(t, err)
}

// If not all updates are applied, Dump() returns an error.
func TestSchemaDump_MissingUpdatees(t *testing.T) {
	schema, db := newSchemaAndDB(t)
	schema.Add(updateCreateTable)
	_, err := schema.Ensure(db)
	assert.NoError(t, err)
	schema.Add(updateAddColumn)

	_, err = schema.Dump(db)
	assert.EqualError(t, err, "update level is 1, expected 2")
}

// After trimming a schema, only the updates up to the trim point are applied.
func TestSchema_Trim(t *testing.T) {
	updates := map[int]schema.Update{
		1: updateCreateTable,
		2: updateInsertValue,
		3: updateAddColumn,
	}

	schema := schema.NewFromMap(updates)
	trimmed := schema.Trim(2)
	assert.Len(t, trimmed, 1)

	db := newDB(t)
	_, err := schema.Ensure(db)
	require.NoError(t, err)

	tx, err := db.Begin()
	require.NoError(t, err)

	versions, err := query.SelectIntegers(context.Background(), tx, "SELECT version FROM schema")
	require.NoError(t, err)
	assert.Equal(t, []int{1, 2}, versions)
}

// Exercise a given update in a schema.
func TestSchema_ExeciseUpdate(t *testing.T) {
	updates := map[int]schema.Update{
		1: updateCreateTable,
		2: updateInsertValue,
		3: updateAddColumn,
	}

	schema := schema.NewFromMap(updates)
	db, err := schema.ExerciseUpdate(2, nil)
	require.NoError(t, err)

	tx, err := db.Begin()
	require.NoError(t, err)

	// Update 2 has been applied.
	ids, err := query.SelectIntegers(context.Background(), tx, "SELECT id FROM test")
	require.NoError(t, err)
	assert.Equal(t, []int{1}, ids)

	// Update 3 has not been applied.
	_, err = query.SelectStrings(context.Background(), tx, "SELECT name FROM test")
	require.EqualError(t, err, "no such column: name")
}

// A custom schema file path is given, but it does not exists. This is a no-op.
func TestSchema_File_NotExists(t *testing.T) {
	schema, db := newSchemaAndDB(t)
	schema.Add(updateCreateTable)
	schema.File("/non/existing/file/path")

	_, err := schema.Ensure(db)
	require.NoError(t, err)
}

// A custom schema file path is given, but it contains non valid SQL. An error
// is returned an no change to the database is performed at all.
func TestSchema_File_Garbage(t *testing.T) {
	schema, db := newSchemaAndDB(t)
	schema.Add(updateCreateTable)

	path, err := WriteTempFile("", "incus-db-schema-", "SELECT FROM baz")
	require.NoError(t, err)
	defer func() { _ = os.Remove(path) }()

	schema.File(path)

	_, err = schema.Ensure(db)

	message := fmt.Sprintf("failed to execute queries from %s: near \"FROM\": syntax error", path)
	require.EqualError(t, err, message)
}

// A custom schema file path is given, it runs some queries that repair an
// otherwise broken update, before the update is run.
func TestSchema_File(t *testing.T) {
	schema, db := newSchemaAndDB(t)

	// Add an update that would insert a value into a non-existing table.
	schema.Add(updateInsertValue)

	path, err := WriteTempFile("", "incus-db-schema-",
		`CREATE TABLE test (id INTEGER);
INSERT INTO test VALUES (2);
`)
	require.NoError(t, err)
	defer func() { _ = os.Remove(path) }()

	schema.File(path)

	_, err = schema.Ensure(db)
	require.NoError(t, err)

	// The file does not exist anymore.
	assert.False(t, util.PathExists(path))

	// The table was created, and the extra row inserted as well.
	tx, err := db.Begin()
	require.NoError(t, err)

	ids, err := query.SelectIntegers(context.Background(), tx, "SELECT id FROM test ORDER BY id")
	require.NoError(t, err)
	assert.Equal(t, []int{1, 2}, ids)
}

// A both a custom schema file path and a hook are set, the hook runs before
// the queries in the file are executed.
func TestSchema_File_Hook(t *testing.T) {
	schema, db := newSchemaAndDB(t)

	// Add an update that would insert a value into a non-existing table.
	schema.Add(updateInsertValue)

	// Add a custom schema update query file that inserts a value into a
	// non-existing table.
	path, err := WriteTempFile("", "incus-db-schema-", "INSERT INTO test VALUES (2)")
	require.NoError(t, err)
	defer func() { _ = os.Remove(path) }()

	schema.File(path)

	// Add a hook that takes care of creating the test table, this shows
	// that it's run before anything else.
	schema.Hook(func(ctx context.Context, version int, tx *sql.Tx) error {
		if version == -1 {
			_, err := tx.Exec("CREATE TABLE test (id INTEGER)")
			return err
		}

		return nil
	})

	_, err = schema.Ensure(db)
	require.NoError(t, err)

	// The table was created, and the both rows inserted as well.
	tx, err := db.Begin()
	require.NoError(t, err)

	ids, err := query.SelectIntegers(context.Background(), tx, "SELECT id FROM test ORDER BY id")
	require.NoError(t, err)
	assert.Equal(t, []int{1, 2}, ids)
}

// Return a new in-memory SQLite database.
func newDB(t *testing.T) *sql.DB {
	db, err := sql.Open("sqlite3", ":memory:")
	assert.NoError(t, err)
	return db
}

// Return both an empty schema and a test database.
func newSchemaAndDB(t *testing.T) (*schema.Schema, *sql.DB) {
	return schema.Empty(), newDB(t)
}

// An update that does nothing.
func updateNoop(context.Context, *sql.Tx) error {
	return nil
}

// An update that creates a test table.
func updateCreateTable(ctx context.Context, tx *sql.Tx) error {
	_, err := tx.Exec("CREATE TABLE test (id INTEGER)")
	return err
}

// An update that inserts a value into the test table.
func updateInsertValue(ctx context.Context, tx *sql.Tx) error {
	_, err := tx.Exec("INSERT INTO test VALUES (1)")
	return err
}

// An update that adds a column to the test tabble.
func updateAddColumn(ctx context.Context, tx *sql.Tx) error {
	_, err := tx.Exec("ALTER TABLE test ADD COLUMN name TEXT")
	return err
}

// An update that unconditionally fails with an error.
func updateBoom(ctx context.Context, tx *sql.Tx) error {
	return errors.New("boom")
}