sq/testh/testh.go
Neil O'Toole 3f6157c4c4
Change logging library to slog ()
- Switch to slog logger.
2023-04-02 13:49:45 -06:00

690 lines
20 KiB
Go

// Package testh (test helper) contains functionality
// for testing.
package testh
import (
"context"
"io"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/neilotoole/sq/libsq/core/lg/lga"
"github.com/neilotoole/sq/libsq/core/lg/lgm"
"github.com/neilotoole/sq/libsq/core/lg"
"github.com/neilotoole/slogt"
"golang.org/x/exp/slog"
"github.com/neilotoole/sq/libsq/ast"
"github.com/neilotoole/sq/cli/config"
"github.com/neilotoole/sq/cli/output"
"github.com/neilotoole/sq/drivers/csv"
"github.com/neilotoole/sq/drivers/json"
"github.com/neilotoole/sq/drivers/mysql"
"github.com/neilotoole/sq/drivers/postgres"
"github.com/neilotoole/sq/drivers/sqlite3"
"github.com/neilotoole/sq/drivers/sqlserver"
"github.com/neilotoole/sq/drivers/userdriver"
"github.com/neilotoole/sq/drivers/userdriver/xmlud"
"github.com/neilotoole/sq/drivers/xlsx"
"github.com/neilotoole/sq/libsq"
"github.com/neilotoole/sq/libsq/core/cleanup"
"github.com/neilotoole/sq/libsq/core/errz"
"github.com/neilotoole/sq/libsq/core/sqlmodel"
"github.com/neilotoole/sq/libsq/core/stringz"
"github.com/neilotoole/sq/libsq/driver"
"github.com/neilotoole/sq/libsq/source"
"github.com/neilotoole/sq/testh/proj"
"github.com/neilotoole/sq/testh/sakila"
"github.com/neilotoole/sq/testh/testsrc"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)
func init() { //nolint:gochecknoinits
slogt.Default = slogt.Factory(func(w io.Writer) slog.Handler {
return slog.HandlerOptions{
Level: slog.LevelDebug,
AddSource: true,
}.NewTextHandler(w)
})
}
// Helper encapsulates a test helper session.
type Helper struct {
mu sync.Mutex
T testing.TB
Log *slog.Logger
registry *driver.Registry
files *source.Files
databases *driver.Databases
initOnce sync.Once
srcs *source.Set
srcCache map[string]*source.Source
Context context.Context
cancelFn context.CancelFunc
Cleanup *cleanup.Cleanup
}
// New returns a new Helper. The helper's Close func will be
// automatically invoked via t.Cleanup.
func New(t testing.TB) *Helper {
h := &Helper{
T: t,
Log: slogt.New(t),
Cleanup: cleanup.New(),
}
ctx, cancelFn := context.WithCancel(context.Background())
h.Context = ctx
h.cancelFn = cancelFn
t.Cleanup(h.Close)
return h
}
// NewWith is a convenience wrapper for New that also returns
// a Source for handle, an open Database and the SQLDriver.
// The function will fail if handle is not the handle for a
// source whose driver implements driver.SQLDriver.
func NewWith(t testing.TB, handle string) (*Helper, *source.Source, driver.Database, driver.SQLDriver) {
th := New(t)
src := th.Source(handle)
dbase := th.Open(src)
drvr := th.SQLDriverFor(src)
return th, src, dbase, drvr
}
func (h *Helper) init() {
h.initOnce.Do(func() {
log := h.Log
h.registry = driver.NewRegistry(log)
var err error
h.files, err = source.NewFiles(log)
require.NoError(h.T, err)
h.Cleanup.Add(func() {
h.T.Logf("Executing outer Files cleanup")
err := h.files.Close()
assert.NoError(h.T, err)
})
h.files.AddTypeDetectors(source.DetectMagicNumber)
h.databases = driver.NewDatabases(log, h.registry, sqlite3.NewScratchSource)
h.Cleanup.AddC(h.databases)
h.registry.AddProvider(sqlite3.Type, &sqlite3.Provider{Log: log})
h.registry.AddProvider(postgres.Type, &postgres.Provider{Log: log})
h.registry.AddProvider(sqlserver.Type, &sqlserver.Provider{Log: log})
h.registry.AddProvider(mysql.Type, &mysql.Provider{Log: log})
csvp := &csv.Provider{Log: log, Scratcher: h.databases, Files: h.files}
h.registry.AddProvider(csv.TypeCSV, csvp)
h.registry.AddProvider(csv.TypeTSV, csvp)
h.files.AddTypeDetectors(csv.DetectCSV, csv.DetectTSV)
jsonp := &json.Provider{Log: log, Scratcher: h.databases, Files: h.files}
h.registry.AddProvider(json.TypeJSON, jsonp)
h.registry.AddProvider(json.TypeJSONA, jsonp)
h.registry.AddProvider(json.TypeJSONL, jsonp)
h.files.AddTypeDetectors(json.DetectJSON, json.DetectJSONA, json.DetectJSONL)
h.registry.AddProvider(xlsx.Type, &xlsx.Provider{Log: log, Scratcher: h.databases, Files: h.files})
h.files.AddTypeDetectors(xlsx.DetectXLSX)
h.addUserDrivers()
})
}
// Close runs any cleanup tasks, failing h's testing.T if any cleanup
// error occurs. Close is automatically invoked via t.Cleanup, it does
// not need to be explicitly invoked unless desired.
func (h *Helper) Close() {
err := h.Cleanup.Run()
lg.WarnIfError(h.Log, "helper cleanup", err)
assert.NoError(h.T, err)
h.cancelFn()
}
// Source returns a test Source with the given handle. The source
// is loaded from the sq config file at TestSourcesConfigPath. Variables
// such as ${SQ_ROOT} in the config file are expanded. The same
// instance of *source.Source will be returned for multiple invocations
// of this method on the same Helper instance.
//
// For certain file-based source types, the returned src's Location
// may point to a copy of the file. This helps avoid tests dirtying
// a version-controlled data file.
//
// Any external database source (that is, any SQL source other than SQLite3)
// will have its location determined from an envar. Given a source @sakila_pg12,
// its location is derived from an envar SQ_TEST_SRC__SAKILA_PG12. If that envar
// is not set, the test calling this method will be skipped.
//
// If envar SQ_TEST_DIFFDB is true, DiffDB is run on every SQL source
// returned by Source.
func (h *Helper) Source(handle string) *source.Source {
h.mu.Lock()
defer h.mu.Unlock()
t := h.T
// invoke h.init to ensure that its cleanup side effects
// happen in the correct order (files get cleaned after
// databases, etc.).
h.init()
// If the handle refers to an external database, we will skip
// the test if the envar for the handle is not set.
if stringz.InSlice(sakila.SQLAllExternal(), handle) {
// Skip the test if the envar for the handle is not set
handleEnvar := "SQ_TEST_SRC__" + strings.ToUpper(strings.TrimPrefix(handle, "@"))
if envar, ok := os.LookupEnv(handleEnvar); !ok || strings.TrimSpace(envar) == "" {
h.T.Skipf("Skip test %s because envar {%s} for {%s} is not set",
h.T.Name(), handleEnvar, handle)
}
}
if h.srcs == nil {
// It might be expected that we would simply use the
// source set (h.srcs) to return the source, but this
// method also uses a cache. This is because this
// method makes a copy the data file of file-based sources
// as mentioned in the method godoc.
h.srcs = mustLoadSourceSet(t)
h.srcCache = map[string]*source.Source{}
}
// If it's already in the cache, return it.
src, ok := h.srcCache[handle]
if ok {
return src
}
src, err := h.srcs.Get(handle)
require.NoError(t, err,
"source %s was not found in %s", handle, testsrc.PathSrcsConfig)
if src.Type == sqlite3.Type {
// This could be easily generalized for CSV/XLSX etc.
fpath, err := sqlite3.PathFromLocation(src)
require.NoError(t, err)
srcFile, err := os.Open(fpath)
require.NoError(t, err)
defer func() {
assert.NoError(t, srcFile.Close())
}()
destFile, err := os.CreateTemp("", "*_"+filepath.Base(src.Location))
require.NoError(t, err)
defer func() {
assert.NoError(t, destFile.Close())
}()
destFileName := destFile.Name()
h.Files().CleanupE(func() error {
return errz.Err(os.Remove(destFileName))
})
_, err = io.Copy(destFile, srcFile)
require.NoError(t, err)
src.Location = sqlite3.Prefix + destFileName
}
h.srcCache[handle] = src
// envDiffDB is the name of the envar that controls whether the testing
// diffdb mechanism is executed automatically by Helper.Source.
const envDiffDB = "SQ_TEST_DIFFDB"
if proj.BoolEnvar(envDiffDB) {
h.DiffDB(src)
}
return src
}
// NewSourceSet is a convenience function for building a
// new *source.Set incorporating the supplied handles. See
// Helper.Source for more on the behavior.
func (h *Helper) NewSourceSet(handles ...string) *source.Set {
srcs := &source.Set{}
for _, handle := range handles {
require.NoError(h.T, srcs.Add(h.Source(handle)))
}
return srcs
}
// Open opens a Database for src via h's internal Databases
// instance: thus subsequent calls to Open may return the
// same Database instance. The opened Database will be closed
// during h.Close.
func (h *Helper) Open(src *source.Source) driver.Database {
dbase, err := h.Databases().Open(h.Context, src)
require.NoError(h.T, err)
return dbase
}
// openNew opens a new Database. It is the caller's responsibility
// to close the returned Database. Unlike method Open, this method
// will always invoke the driver's Open method.
//
// Some of Helper's methods (e.g. DropTable) need to use openNew rather
// than Open, as the Database returned by Open can be closed by test code,
// potentially causing problems during Cleanup.
func (h *Helper) openNew(src *source.Source) driver.Database {
reg := h.Registry()
drvr, err := reg.DriverFor(src.Type)
require.NoError(h.T, err)
dbase, err := drvr.Open(h.Context, src)
require.NoError(h.T, err)
return dbase
}
// SQLDriverFor is a convenience method to get src's driver.SQLDriver.
func (h *Helper) SQLDriverFor(src *source.Source) driver.SQLDriver {
reg := h.Registry()
drvr, err := reg.DriverFor(src.Type)
require.NoError(h.T, err)
sqlDrvr, ok := drvr.(driver.SQLDriver)
require.True(h.T, ok,
"driver %T is not a driver.SQLDriver: ensure that the src passed to SQLDriverFor implements driver.SQLDriver",
drvr)
return sqlDrvr
}
// DriverFor is a convenience method to get src's driver.Driver.
func (h *Helper) DriverFor(src *source.Source) driver.Driver {
reg := h.Registry()
drvr, err := reg.DriverFor(src.Type)
require.NoError(h.T, err)
return drvr
}
// RowCount returns the result of "SELECT COUNT(*) FROM tbl",
// failing h's test on any error.
func (h *Helper) RowCount(src *source.Source, tbl string) int64 {
dbase := h.openNew(src)
defer lg.WarnIfCloseError(h.Log, lgm.CloseDB, dbase)
query := "SELECT COUNT(*) FROM " + dbase.SQLDriver().Dialect().Enquote(tbl)
var count int64
require.NoError(h.T, dbase.DB().QueryRowContext(h.Context, query).Scan(&count))
return count
}
// CreateTable creates a new table in src, and inserts data, returning
// the number of data rows inserted. If dropAfter is true, the created
// table is dropped when t.Cleanup is run.
func (h *Helper) CreateTable(dropAfter bool, src *source.Source, tblDef *sqlmodel.TableDef,
data ...[]any,
) (affected int64) {
dbase := h.openNew(src)
defer lg.WarnIfCloseError(h.Log, lgm.CloseDB, dbase)
require.NoError(h.T, dbase.SQLDriver().CreateTable(h.Context, dbase.DB(), tblDef))
h.T.Logf("Created table %s.%s", src.Handle, tblDef.Name)
if dropAfter {
h.Cleanup.Add(func() { h.DropTable(src, tblDef.Name) })
}
if len(data) == 0 {
return 0
}
return h.Insert(src, tblDef.Name, tblDef.ColNames(), data...)
}
// Insert inserts records for cols into src.tbl, returning the number of
// records inserted. Note that the records arg may be mutated by src's
// driver InsertMungeFunc.
func (h *Helper) Insert(src *source.Source, tbl string, cols []string, records ...[]any) (affected int64) {
if len(records) == 0 {
return 0
}
dbase := h.openNew(src)
defer lg.WarnIfCloseError(h.Log, lgm.CloseDB, dbase)
drvr := dbase.SQLDriver()
conn, err := dbase.DB().Conn(h.Context)
require.NoError(h.T, err)
defer lg.WarnIfCloseError(h.Log, lgm.CloseDB, conn)
batchSize := driver.MaxBatchRows(drvr, len(cols))
bi, err := driver.NewBatchInsert(h.Context, drvr, conn, tbl, cols, batchSize)
require.NoError(h.T, err)
for _, rec := range records {
require.NoError(h.T, bi.Munge(rec))
select {
case <-h.Context.Done():
// Should not happen
close(bi.RecordCh)
require.NoError(h.T, h.Context.Err())
case err = <-bi.ErrCh:
// Should not happen
if err != nil {
close(bi.RecordCh)
require.NoError(h.T, err)
} else {
break
}
case bi.RecordCh <- rec:
}
}
close(bi.RecordCh) // Indicate that we're finished writing records
err = <-bi.ErrCh // Wait for bi to complete
require.NoError(h.T, err)
h.T.Logf("Inserted %d rows to %s.%s", bi.Written(), src.Handle, tbl)
return bi.Written()
}
// CopyTable copies fromTable into a new table toTable. If
// toTable is empty, a table name is generated based on
// fromTable. The table name used is returned.
// If dropAfter is true, the table is dropped when t.Cleanup is run.
// If copyData is true, fromTable's data is also copied.
// Constraints (keys, defaults etc.) may not be copied.
func (h *Helper) CopyTable(dropAfter bool, src *source.Source, fromTable, toTable string, copyData bool) string {
if toTable == "" {
toTable = stringz.UniqTableName(fromTable)
}
dbase := h.openNew(src)
defer lg.WarnIfCloseError(h.Log, lgm.CloseDB, dbase)
copied, err := dbase.SQLDriver().CopyTable(h.Context, dbase.DB(), fromTable, toTable, copyData)
require.NoError(h.T, err)
if dropAfter {
h.Cleanup.Add(func() { h.DropTable(src, toTable) })
}
h.Log.Debug("Copied table",
lga.From, source.Target(src, fromTable),
lga.To, source.Target(src, toTable),
"copy_data", copyData,
lga.Count, copied,
"drop_after", dropAfter,
)
return toTable
}
// DropTable drops tbl from src.
func (h *Helper) DropTable(src *source.Source, tbl string) {
dbase := h.openNew(src)
defer lg.WarnIfCloseError(h.Log, lgm.CloseDB, dbase)
require.NoError(h.T, dbase.SQLDriver().DropTable(h.Context, dbase.DB(), tbl, true))
h.Log.Debug("Dropped table", lga.Target, source.Target(src, tbl))
}
// QuerySQL uses libsq.QuerySQL to execute SQL query
// against src, returning a sink to which all records have
// been written. Note that QuerySQL uses the
// same Database instance as returned by h.Open.
func (h *Helper) QuerySQL(src *source.Source, query string, args ...any) (*RecordSink, error) {
dbase := h.Open(src)
sink := &RecordSink{}
recw := output.NewRecordWriterAdapter(sink)
err := libsq.QuerySQL(h.Context, dbase, recw, query, args...)
if err != nil {
return nil, err
}
_, err = recw.Wait()
if err != nil {
return nil, err
}
return sink, nil
}
// QuerySLQ executes the SLQ query.
func (h *Helper) QuerySLQ(query string) (*RecordSink, error) {
// We need to ensure that each of the handles in the query is loaded.
a, err := ast.Parse(h.Log, query)
require.NoError(h.T, err)
for _, handle := range ast.NewInspector(h.Log, a).FindHandles() {
// This triggers handle loading
_ = h.Source(handle)
}
qc := &libsq.QueryContext{
Sources: h.srcs,
DBOpener: h.databases,
JoinDBOpener: h.databases,
}
sink := &RecordSink{}
recw := output.NewRecordWriterAdapter(sink)
err = libsq.ExecuteSLQ(h.Context, qc, query, recw)
if err != nil {
return nil, err
}
if _, err = recw.Wait(); err != nil {
return nil, err
}
return sink, nil
}
// ExecSQL is a convenience wrapper for sql.DB.Exec that returns the
// rows affected, failing on any error. Note that ExecSQL uses the
// same Database instance as returned by h.Open.
func (h *Helper) ExecSQL(src *source.Source, query string, args ...any) (affected int64) {
dbase := h.Open(src)
res, err := dbase.DB().ExecContext(h.Context, query, args...)
require.NoError(h.T, err)
affected, err = res.RowsAffected()
require.NoError(h.T, err)
return affected
}
// InsertDefaultRow executes the equivalent
// of INSERT INTO tbl DEFAULT VALUES.
// It fails if a row was not inserted.
//
// Note that for some driver types, the driver does not
// support DEFAULT values for some col types. For example
// this method may fail for a MySQL column "col_text TEXT NOT NULL",
// as TEXT and BLOB cannot have a DEFAULT value.
func (h *Helper) InsertDefaultRow(src *source.Source, tbl string) {
drvr := h.SQLDriverFor(src)
var query string
if src.Type == mysql.Type {
// One driver had to be different...
// We could push this mysql-specific logic down to the driver impl
// but prob not worth the effort just for one driver.
query = "INSERT INTO " + drvr.Dialect().Enquote(tbl) + " () VALUES ()"
} else {
query = "INSERT INTO " + drvr.Dialect().Enquote(tbl) + " DEFAULT VALUES"
}
affected := h.ExecSQL(src, query)
require.Equal(h.T, int64(1), affected)
}
// TruncateTable truncates tbl in src.
func (h *Helper) TruncateTable(src *source.Source, tbl string) (affected int64) {
dbase := h.openNew(src)
defer lg.WarnIfCloseError(h.Log, lgm.CloseDB, dbase)
affected, err := h.DriverFor(src).Truncate(h.Context, src, tbl, true)
require.NoError(h.T, err)
return affected
}
// Registry returns the helper's registry instance,
// configured with standard providers.
func (h *Helper) Registry() *driver.Registry {
h.init()
return h.registry
}
// addUserDrivers adds some user drivers to the registry.
func (h *Helper) addUserDrivers() {
userDriverDefs := DriverDefsFrom(h.T, testsrc.PathDriverDefPpl, testsrc.PathDriverDefRSS)
// One day we may have more supported user driver genres.
userDriverImporters := map[string]userdriver.ImportFunc{
xmlud.Genre: xmlud.Import,
}
for _, userDriverDef := range userDriverDefs {
userDriverDef := userDriverDef
errs := userdriver.ValidateDriverDef(userDriverDef)
require.Empty(h.T, errs)
importFn, ok := userDriverImporters[userDriverDef.Genre]
require.True(h.T, ok, "unsupported genre {%s} for user driver {%s} specified via config",
userDriverDef.Genre, userDriverDef.Name)
// For each user driver definition, we register a
// distinct userdriver.Provider instance.
udp := &userdriver.Provider{
Log: h.Log,
DriverDef: userDriverDef,
ImportFn: importFn,
Scratcher: h.databases,
Files: h.files,
}
h.registry.AddProvider(source.Type(userDriverDef.Name), udp)
h.files.AddTypeDetectors(udp.TypeDetectors()...)
}
}
// IsMonotable returns true if src's driver is monotable.
func (h *Helper) IsMonotable(src *source.Source) bool {
return h.DriverFor(src).DriverMetadata().Monotable
}
// Databases returns the helper's Databases instance.
func (h *Helper) Databases() *driver.Databases {
h.init()
return h.databases
}
// Files returns the helper's Files instance.
func (h *Helper) Files() *source.Files {
h.init()
return h.files
}
// DiffDB fails the test if src's metadata is substantially different
// when t.Cleanup runs vs when DiffDB is invoked. Effectively DiffDB
// takes before and after snapshots of src's metadata, and compares
// various elements such as the number of tables, table row counts, etc.
// DiffDB is useful for verifying that tests are leaving the database
// as they found it.
//
// Note that DiffDB adds considerable overhead to test runtime.
//
// If envar SQ_TEST_DIFFDB is true, DiffDB is run on every SQL source
// returned by Helper.Source.
func (h *Helper) DiffDB(src *source.Source) {
if !h.DriverFor(src).DriverMetadata().IsSQL {
// SkipDiffDB for non-SQL source types
return
}
h.T.Logf("Executing DiffDB for %s", src.Handle) // FIXME: zap this
beforeDB := h.openNew(src)
defer lg.WarnIfCloseError(h.Log, lgm.CloseDB, beforeDB)
beforeMeta, err := beforeDB.SourceMetadata(h.Context)
require.NoError(h.T, err)
h.Cleanup.Add(func() {
// Currently DiffDB just checks if the tables and each
// table's row count match.
afterDB := h.openNew(src)
defer lg.WarnIfCloseError(h.Log, lgm.CloseDB, afterDB)
afterMeta, err := afterDB.SourceMetadata(h.Context)
require.NoError(h.T, err)
require.Equal(h.T, beforeMeta.TableNames(), afterMeta.TableNames(),
"diffdb: should have the same set of tables before and after")
for i, beforeTbl := range beforeMeta.Tables {
assert.Equal(h.T, beforeTbl.RowCount, afterMeta.Tables[i].RowCount,
"diffdb: %s: row count for {%s} is expected to be %d but got %d", src.Handle, beforeTbl.Name,
beforeTbl.RowCount, afterMeta.Tables[i].RowCount)
}
})
}
func mustLoadSourceSet(t testing.TB) *source.Set {
hookExpand := func(data []byte) ([]byte, error) {
// expand vars such as "${SQ_ROOT}"
return []byte(proj.Expand(string(data))), nil
}
fs := &config.YAMLFileStore{Path: proj.Rel(testsrc.PathSrcsConfig), HookLoad: hookExpand}
cfg, err := fs.Load()
require.NoError(t, err)
require.NotNil(t, cfg)
require.NotNil(t, cfg.Sources)
return cfg.Sources
}
// DriverDefsFrom builds DriverDef values from cfg files.
func DriverDefsFrom(t testing.TB, cfgFiles ...string) []*userdriver.DriverDef {
var userDriverDefs []*userdriver.DriverDef
for _, f := range cfgFiles {
ext := &config.Ext{}
require.NoError(t, yaml.Unmarshal(proj.ReadFile(f), ext))
userDriverDefs = append(userDriverDefs, ext.UserDrivers...)
}
return userDriverDefs
}
// TypeDetectors returns the common set of TypeDetectorFuncs.
func TypeDetectors() []source.TypeDetectFunc {
return []source.TypeDetectFunc{
source.DetectMagicNumber,
xlsx.DetectXLSX,
csv.DetectCSV, csv.DetectTSV,
/*json.DetectJSON,*/ json.DetectJSONA, json.DetectJSONL, // FIXME: enable DetectJSON when it's ready
}
}