forked from skeema/skeema
-
Notifications
You must be signed in to change notification settings - Fork 0
/
skeema_test.go
499 lines (456 loc) · 18.3 KB
/
skeema_test.go
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
497
498
499
package main
import (
"errors"
"fmt"
log "github.com/sirupsen/logrus"
"os"
"path/filepath"
"reflect"
"regexp"
"runtime"
"strings"
"testing"
"github.com/skeema/mybase"
"github.com/skeema/skeema/internal/fs"
"github.com/skeema/skeema/internal/tengo"
"github.com/skeema/skeema/internal/util"
)
func TestMain(m *testing.M) {
// Suppress packet error output when attempting to connect to a Dockerized
// mysql-server which is still starting up
tengo.UseFilteredDriverLogger()
// Add global options to the global command suite, just like in main()
util.AddGlobalOptions(CommandSuite)
os.Exit(m.Run())
}
func TestIntegration(t *testing.T) {
images := tengo.SplitEnv("SKEEMA_TEST_IMAGES")
if len(images) == 0 {
fmt.Println("SKEEMA_TEST_IMAGES env var is not set, so integration tests will be skipped!")
fmt.Println("To run integration tests, you may set SKEEMA_TEST_IMAGES to a comma-separated")
fmt.Println("list of Docker images. Example:\n# SKEEMA_TEST_IMAGES=\"mysql:5.6,mysql:5.7\" go test")
}
manager, err := tengo.NewDockerClient(tengo.DockerClientOptions{})
if err != nil {
t.Errorf("Unable to create sandbox manager: %s", err)
}
suite := &SkeemaIntegrationSuite{manager: manager}
tengo.RunSuite(suite, t, images)
}
type SkeemaIntegrationSuite struct {
manager *tengo.DockerClient
d *tengo.DockerizedInstance
repoPath string
}
func (s *SkeemaIntegrationSuite) Setup(backend string) (err error) {
// Remember working directory, which should be the base dir for the repo
s.repoPath, err = os.Getwd()
if err != nil {
return err
}
// Spin up a Dockerized database server
opts := tengo.DockerizedInstanceOptions{
Name: fmt.Sprintf("skeema-test-%s", tengo.ContainerNameForImage(backend)),
Image: backend,
RootPassword: "fakepw",
CommandArgs: []string{"--skip-log-bin"}, // override MySQL 8 default of enabling binlog
}
s.d, err = s.manager.GetOrCreateInstance(opts)
return err
}
func (s *SkeemaIntegrationSuite) Teardown(backend string) error {
if err := s.d.Stop(); err != nil {
return err
}
if err := os.Chdir(s.repoPath); err != nil {
return err
}
if err := os.RemoveAll(s.scratchPath()); err != nil {
return err
}
util.FlushInstanceCache()
return nil
}
func (s *SkeemaIntegrationSuite) BeforeTest(backend string) error {
// Clear data and re-source setup data
if err := s.d.NukeData(); err != nil {
return err
}
if _, err := s.d.SourceSQL(s.testdata("setup.sql")); err != nil {
return err
}
// Create or recreate scratch dir
if _, err := os.Stat(s.scratchPath()); err == nil { // dir exists
if err := os.Chdir(s.repoPath); err != nil {
return err
}
if err := os.RemoveAll(s.scratchPath()); err != nil {
return err
}
}
if err := os.MkdirAll(s.scratchPath(), 0777); err != nil {
return err
}
if err := os.Chdir(s.scratchPath()); err != nil {
return err
}
return nil
}
// testdata returns the absolute path of the testdata dir, or a file or dir
// based from it
func (s *SkeemaIntegrationSuite) testdata(joins ...string) string {
parts := append([]string{s.repoPath, "testdata"}, joins...)
return filepath.Join(parts...)
}
// scratchPath returns the scratch directory for tests to write temporary files
// to.
func (s *SkeemaIntegrationSuite) scratchPath() string {
return s.testdata(".scratch")
}
// handleCommand executes the supplied Skeema command-line, and confirms its exit
// code matches the expected value.
// pwd can specify a relative path (based off of testdata/.scratch) to
// execute the command from the designated subdirectory. Afterwards, the pwd
// will be restored to testdata/.scratch regardless.
func (s *SkeemaIntegrationSuite) handleCommand(t *testing.T, expectedExitCode int, pwd, commandLine string, a ...interface{}) *mybase.Config {
t.Helper()
path := filepath.Join(s.scratchPath(), pwd)
if err := os.Chdir(path); err != nil {
t.Fatalf("Unable to cd to %s: %s", path, err)
}
fullCommandLine := fmt.Sprintf(commandLine, a...)
if runtime.GOOS == "windows" {
// Omit ANSI color codes on Windows
fmt.Fprintf(os.Stderr, "%s$ %s\n", filepath.Join("testdata", ".scratch", pwd), fullCommandLine)
} else {
fmt.Fprintf(os.Stderr, "\x1b[37;1m%s$\x1b[0m %s\n", filepath.Join("testdata", ".scratch", pwd), fullCommandLine)
}
fakeFileSource := mybase.SimpleSource(map[string]string{"password": s.d.Instance.Password})
cfg := mybase.ParseFakeCLI(t, CommandSuite, fullCommandLine, fakeFileSource)
util.AddGlobalConfigFiles(cfg)
err := util.ProcessSpecialGlobalOptions(cfg)
if err != nil {
err = NewExitValue(CodeBadConfig, err.Error())
} else {
util.CloseCachedConnectionPools() // ensure no previous session state bleeds through
err = cfg.HandleCommand()
}
actualExitCode := ExitCode(err)
var msg string
if err != nil {
msg = err.Error()
}
if actualExitCode == 0 {
log.Info("Exit code 0 (SUCCESS)")
} else if actualExitCode >= CodeFatalError {
if msg == "" {
msg = "FATAL"
}
log.Errorf("Exit code %d (%s)", actualExitCode, msg)
} else {
if msg == "" {
msg = "WARNING"
}
log.Warnf("Exit code %d (%s)", actualExitCode, msg)
}
if actualExitCode != expectedExitCode {
t.Errorf("Unexpected exit code from `%s`: Expected code=%d, found code=%d, message=%s", fullCommandLine, expectedExitCode, actualExitCode, err)
}
if pwd != "" && pwd != "." {
if err := os.Chdir(s.scratchPath()); err != nil {
t.Fatalf("Unable to cd to %s: %s", s.scratchPath(), err)
}
}
fmt.Fprintf(os.Stderr, "\n")
return cfg
}
// verifyFiles compares the files in testdata/.scratch to the files in the
// specified dir, and fails the test if any differences are found.
func (s *SkeemaIntegrationSuite) verifyFiles(t *testing.T, cfg *mybase.Config, dirExpectedBase string) {
t.Helper()
// Hackily manipulate dirExpectedBase if testing against a database backend
// with different SHOW CREATE TABLE rules:
// In MariaDB 10.2+, default values are no longer quoted if non-strings; the
// blob and text types now permit default values; partitions are formatted
// differently; default values and on-update rules for CURRENT_TIMESTAMP always
// include parens and lowercase the function name.
// In MySQL 5.5, DATETIME columns cannot have default or on-update of
// CURRENT_TIMESTAMP; only one TIMESTAMP column can have on-update;
// CURRENT_TIMESTAMP does not take an arg for specifying sub-second precision
// In MySQL 8.0+, partitions are formatted differently; the default character
// set is now utf8mb4; the default collation for utf8mb4 has also changed.
if s.d.Flavor().Min(tengo.FlavorMariaDB102) {
dirExpectedBase = strings.Replace(dirExpectedBase, "golden", "golden-mariadb102", 1)
} else if s.d.Flavor().Matches(tengo.FlavorMySQL55) {
dirExpectedBase = strings.Replace(dirExpectedBase, "golden", "golden-mysql55", 1)
} else if s.d.Flavor().Min(tengo.FlavorMySQL80) {
dirExpectedBase = strings.Replace(dirExpectedBase, "golden", "golden-mysql80", 1)
}
expected, err := fs.ParseDir(dirExpectedBase, cfg)
if err != nil {
t.Fatalf("ParseDir(%s) returned %s", dirExpectedBase, err)
}
actual, err := fs.ParseDir(s.scratchPath(), cfg)
if err != nil {
t.Fatalf("ParseDir(%s) returned %s", s.scratchPath(), err)
}
s.compareDirs(t, expected, actual)
}
func (s *SkeemaIntegrationSuite) compareDirs(t *testing.T, a, b *fs.Dir) {
t.Helper()
if a.ParseError != nil {
t.Fatalf("Dir parse error: %v", a.ParseError)
} else if b.ParseError != nil {
t.Fatalf("Dir parse error: %v", b.ParseError)
}
s.compareDirOptionFiles(t, a, b)
s.compareDirSQLFiles(t, a, b)
s.compareDirLogicalSchemas(t, a, b)
// Compare subdirs and walk them
aSubdirs, err := a.Subdirs()
if err != nil {
t.Fatalf("Unable to list subdirs of %s: %v", a, err)
}
bSubdirs, err := b.Subdirs()
if err != nil {
t.Fatalf("Unable to list subdirs of %s: %v", b, err)
}
if len(aSubdirs) != len(bSubdirs) {
t.Errorf("Differing count of subdirs between %s and %s", a, b)
} else {
for n := range aSubdirs {
if aSubdirs[n].BaseName() != bSubdirs[n].BaseName() {
t.Errorf("Subdir name mismatch: %s vs %s", aSubdirs[n], bSubdirs[n])
}
s.compareDirs(t, aSubdirs[n], bSubdirs[n])
}
}
}
// compareDirOptionFiles confirms that option files are nearly identical between
// a and b. Of these, a should be the expected (golden) dir, and b the dir
// generated from the logic being tested. We manipulate a few fields in a's
// option file to look like b; this is to avoid false positives in fields that
// we expect to differ based solely on the test environment itself (such as the
// random port number of the Docker container).
func (s *SkeemaIntegrationSuite) compareDirOptionFiles(t *testing.T, a, b *fs.Dir) {
t.Helper()
if (a.OptionFile == nil && b.OptionFile != nil) || (a.OptionFile != nil && b.OptionFile == nil) {
t.Errorf("Presence of option files does not match between %s and %s", a, b)
}
if a.OptionFile != nil {
// Force port number of a to equal port number in b, since b will use whatever
// dynamic port was allocated to the Dockerized database instance
aSectionsWithPort := a.OptionFile.SectionsWithOption("port")
bSectionsWithPort := b.OptionFile.SectionsWithOption("port")
if !reflect.DeepEqual(aSectionsWithPort, bSectionsWithPort) {
t.Errorf("Sections with port option do not match between %s and %s", a.OptionFile.Path(), b.OptionFile.Path())
} else {
for _, section := range bSectionsWithPort {
b.OptionFile.UseSection(section)
forcedValue, _ := b.OptionFile.OptionValue("port")
a.OptionFile.SetOptionValue(section, "port", forcedValue)
}
}
// Force flavor of a to match the DockerizedInstance's flavor
for _, section := range a.OptionFile.SectionsWithOption("flavor") {
a.OptionFile.SetOptionValue(section, "flavor", s.d.Flavor().Family().String())
}
// If b sets a generator, force generator of a to be correct value for current
// version/edition
for _, section := range b.OptionFile.SectionsWithOption("generator") {
a.OptionFile.SetOptionValue(section, "generator", generatorString())
}
// Force charset/collation to match the DockerizedInstance's defaults, where requested
if sectionsWithSchema := a.OptionFile.SectionsWithOption("schema"); len(sectionsWithSchema) > 0 {
instDefCharSet, instDefCollation, err := s.d.DefaultCharSetAndCollation()
if err != nil {
t.Fatalf("Unexpected error querying Dockerized instance's default charset/collation: %v", err)
}
for _, section := range sectionsWithSchema {
a.OptionFile.UseSection(section)
if fileCharSet, ok := a.OptionFile.OptionValue("default-character-set"); ok && fileCharSet[0] == '{' {
a.OptionFile.SetOptionValue(section, "default-character-set", instDefCharSet)
a.OptionFile.SetOptionValue(section, "default-collation", instDefCollation)
} else if fileCharSet == "utf8" {
// MySQL 8.0.29 uses "utf8mb3" but keeps collations as-is; MySQL 8.0.30+
// and MariaDB 10.6+ use "utf8mb3" and also changes collation names to match
mysql8029 := tengo.FlavorMySQL80.Dot(29)
if flavor := s.d.Flavor(); flavor.Min(mysql8029) || flavor.Min(tengo.FlavorMariaDB106) {
a.OptionFile.SetOptionValue(section, "default-character-set", "utf8mb3")
if !flavor.Matches(mysql8029) {
collation, _ := a.OptionFile.OptionValue("default-collation")
a.OptionFile.SetOptionValue(section, "default-collation", strings.Replace(collation, "utf8_", "utf8mb3_", 1))
}
}
}
}
}
if !a.OptionFile.SameContents(b.OptionFile) {
t.Errorf("File contents do not match between %s and %s", a.OptionFile.Path(), b.OptionFile.Path())
fmt.Printf("Expected:\n%s\n", fs.ReadTestFile(t, a.OptionFile.Path()))
fmt.Printf("Actual:\n%s\n", fs.ReadTestFile(t, b.OptionFile.Path()))
}
}
}
// compareDirSQLFiles compares the existence of *.sql files between dirs a and
// b. Does not compare the actual file contents, which is instead handled by
// compareDirLogicalSchemas.
func (s *SkeemaIntegrationSuite) compareDirSQLFiles(t *testing.T, a, b *fs.Dir) {
t.Helper()
if len(a.SQLFiles) != len(b.SQLFiles) {
t.Errorf("Differing count of *.sql files between %s and %s", a, b)
} else {
for _, sqlFile := range a.SQLFiles {
base := sqlFile.FileName()
bFilePath := filepath.Join(b.Path, base)
if _, ok := b.SQLFiles[bFilePath]; !ok {
t.Errorf("File %s was found in %s, but not in %s", base, a, b)
}
}
}
}
var reDisplayWidth = regexp.MustCompile(`(tinyint|smallint|mediumint|int|bigint)\((\d+)\)( unsigned)?( zerofill)?`)
var repAlwaysCollation = strings.NewReplacer("DEFAULT CHARSET=latin1;", "DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;",
"DEFAULT CHARSET=latin1\n", "DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci\n",
"DEFAULT CHARSET=utf8mb4;", "DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;")
// compareDirLogicalSchemas compares LogicalSchemas between a and b. Of these, a
// should be the expected (golden) dir, and b the dir generated from the logic
// being tested. Some flavor-specific adjustments are automatically made to the
// statements in a.
func (s *SkeemaIntegrationSuite) compareDirLogicalSchemas(t *testing.T, a, b *fs.Dir) {
t.Helper()
if len(a.LogicalSchemas) != len(b.LogicalSchemas) {
t.Errorf("Mismatch between count of parsed logical schemas: %s=%d vs %s=%d", a, len(a.LogicalSchemas), b, len(b.LogicalSchemas))
} else if len(a.LogicalSchemas) > 0 {
aCreates, bCreates := a.LogicalSchemas[0].Creates, b.LogicalSchemas[0].Creates
if len(aCreates) != len(bCreates) {
t.Errorf("Mismatch in CREATE count: %s=%d, %s=%d", a, len(aCreates), b, len(bCreates))
} else {
flavor := s.d.Flavor()
for key, aStmt := range aCreates {
bStmt := bCreates[key]
aText := strings.ReplaceAll(aStmt.Text, "\r\n", "\n")
bText := strings.ReplaceAll(bStmt.Text, "\r\n", "\n")
if flavor.OmitIntDisplayWidth() {
aText = reDisplayWidth.ReplaceAllString(aText, "$1$3$4")
}
if flavor.AlwaysShowCollate() {
aText = repAlwaysCollation.Replace(aText)
}
if aText != bText {
t.Errorf("Mismatch for %s:\n%s:\n%s\n\n%s:\n%s\n", key, aStmt.Location(), aText, bStmt.Location(), bText)
}
}
}
}
}
func (s *SkeemaIntegrationSuite) reinitAndVerifyFiles(t *testing.T, extraInitOpts, comparePath string) {
t.Helper()
if comparePath == "" {
comparePath = "../golden/init"
}
if err := os.RemoveAll("mydb"); err != nil {
t.Fatalf("Unable to clean directory: %s", err)
}
cfg := s.handleCommand(t, CodeSuccess, ".", "skeema init --dir mydb -h %s -P %d %s", s.d.Instance.Host, s.d.Instance.Port, extraInitOpts)
s.verifyFiles(t, cfg, comparePath)
}
func (s *SkeemaIntegrationSuite) assertTableExists(t *testing.T, schema, table, column string) {
t.Helper()
exists, phrase, err := s.objectExists(schema, tengo.ObjectTypeTable, table, column)
if err != nil {
t.Fatalf("Unexpected error checking existence of %s: %s", phrase, err)
}
if !exists {
t.Errorf("Expected %s to exist, but it does not", phrase)
}
}
func (s *SkeemaIntegrationSuite) assertTableMissing(t *testing.T, schema, table, column string) {
t.Helper()
exists, phrase, err := s.objectExists(schema, tengo.ObjectTypeTable, table, column)
if err != nil {
t.Fatalf("Unexpected error checking existence of %s: %s", phrase, err)
}
if exists {
t.Errorf("Expected %s to not exist, but it does", phrase)
}
}
func (s *SkeemaIntegrationSuite) objectExists(schemaName string, objectType tengo.ObjectType, objectName, columnName string) (exists bool, phrase string, err error) {
if schemaName == "" || (objectName == "" && columnName != "") || (objectType != tengo.ObjectTypeTable && columnName != "") {
panic(errors.New("Invalid parameter combination"))
}
if objectName == "" && columnName == "" {
phrase = fmt.Sprintf("schema %s", schemaName)
has, err := s.d.HasSchema(schemaName)
return has, phrase, err
} else if columnName == "" {
phrase = fmt.Sprintf("%s %s.%s", objectType, schemaName, objectName)
} else {
phrase = fmt.Sprintf("column %s.%s.%s", schemaName, objectName, columnName)
}
schema, err := s.d.Schema(schemaName)
if err != nil {
return false, phrase, fmt.Errorf("Unable to obtain %s: %s", phrase, err)
}
if columnName == "" {
dict := schema.Objects()
key := tengo.ObjectKey{Type: objectType, Name: objectName}
_, ok := dict[key]
return ok, phrase, nil
}
table := schema.Table(objectName)
columns := table.ColumnsByName()
_, exists = columns[columnName]
return exists, phrase, nil
}
// sourceSQL wraps tengo.DockerizedInstance.SourceSQL. If an error occurs, it is
// fatal to the test. filePath should be a relative path based from testdata/.
func (s *SkeemaIntegrationSuite) sourceSQL(t *testing.T, filePath string) {
t.Helper()
filePath = filepath.Join("..", filePath)
if _, err := s.d.SourceSQL(filePath); err != nil {
t.Fatalf("Unable to source %s: %s", filePath, err)
}
}
// cleanData wraps tengo.DockerizedInstance.NukeData. If an error occurs, it is
// fatal to the test. To automatically source one or more *.sql files after
// nuking the data, supply relative file paths as args.
func (s *SkeemaIntegrationSuite) cleanData(t *testing.T, sourceAfter ...string) {
t.Helper()
if err := s.d.NukeData(); err != nil {
t.Fatalf("Unable to clear database state: %s", err)
}
for _, filePath := range sourceAfter {
s.sourceSQL(t, filePath)
}
}
// dbExec runs the specified SQL DML or DDL in the specified schema. If
// something goes wrong, it is fatal to the current test.
func (s *SkeemaIntegrationSuite) dbExec(t *testing.T, schemaName, query string, args ...interface{}) {
t.Helper()
s.dbExecWithParams(t, schemaName, "", query, args...)
}
// dbExecWithOptions run the specified SQL DML or DDL in the specified schema,
// using the supplied URI-encoded session variables. If something goes wrong,
// it is fatal to the current test.
func (s *SkeemaIntegrationSuite) dbExecWithParams(t *testing.T, schemaName, params, query string, args ...interface{}) {
t.Helper()
db, err := s.d.CachedConnectionPool(schemaName, params)
if err != nil {
t.Fatalf("Unable to connect to DockerizedInstance: %s", err)
}
_, err = db.Exec(query, args...)
if err != nil {
t.Fatalf("Error running query on DockerizedInstance.\nSchema: %s\nQuery: %s\nError: %s", schemaName, query, err)
}
}
// getOptionFile returns a mybase.File representing the .skeema file in the
// specified directory
func getOptionFile(t *testing.T, basePath string, baseConfig *mybase.Config) *mybase.File {
t.Helper()
dir, err := fs.ParseDir(basePath, baseConfig)
if err != nil {
t.Fatalf("Unable to obtain directory %s: %s", basePath, err)
}
return dir.OptionFile
}