From 460c8a4c3450cf2e493339190a0f2e4fc7528783 Mon Sep 17 00:00:00 2001 From: Joshua Smith Date: Wed, 5 Aug 2020 15:28:14 -0600 Subject: [PATCH] Sql read (#124) * first draft of sql_read worker * add unit tests * add postgres datatypes * simplify read logic * fix unreliable unit test --- apps/workers/sql_load/worker.go | 2 +- apps/workers/sql_read/main.go | 99 ++++++++++++++ apps/workers/sql_read/worker.go | 186 +++++++++++++++++++++++++++ apps/workers/sql_read/worker_test.go | 134 +++++++++++++++++++ go.mod | 7 +- go.sum | 14 ++ internal/test/nop.sql | 1 + 7 files changed, 440 insertions(+), 3 deletions(-) create mode 100644 apps/workers/sql_read/main.go create mode 100644 apps/workers/sql_read/worker.go create mode 100644 apps/workers/sql_read/worker_test.go create mode 100644 internal/test/nop.sql diff --git a/apps/workers/sql_load/worker.go b/apps/workers/sql_load/worker.go index 85b55884..3170d0fb 100644 --- a/apps/workers/sql_load/worker.go +++ b/apps/workers/sql_load/worker.go @@ -146,7 +146,7 @@ func (w *worker) DoTask(ctx context.Context) (task.Result, string) { w.dbDriver, w.Params.Table, w.records) } -// Queries the database for the table schema for each column +// QuerySchema queries the database for the table schema for each column // sets the worker's db value func (w *worker) QuerySchema() (err error) { var t, s string // table and schema diff --git a/apps/workers/sql_read/main.go b/apps/workers/sql_read/main.go new file mode 100644 index 00000000..1ec51e18 --- /dev/null +++ b/apps/workers/sql_read/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "fmt" + "log" + "strings" + + "github.com/jbsmith7741/go-tools/appenderr" + "github.com/jmoiron/sqlx" + + tools "github.com/pcelvng/task-tools" + "github.com/pcelvng/task-tools/bootstrap" + "github.com/pcelvng/task-tools/file" +) + +const ( + taskType = "sql_read" + desc = `` +) + +type options struct { + DBOptions `toml:"mysql"` + + FOpts *file.Options `toml:"file"` + db *sqlx.DB +} + +type DBOptions struct { + Type string `toml:"type" commented:"true"` + Username string `toml:"username" commented:"true"` + Password string `toml:"password" commented:"true"` + Host string `toml:"host" comment:"host can be 'host:port', 'host', 'host:' or ':port'"` + DBName string `toml:"dbname"` +} + +func (o *options) Validate() error { + errs := appenderr.New() + if o.Host == "" { + errs.Addf("missing db host") + } + if o.DBName == "" { + errs.Addf("missing db name") + } + return errs.ErrOrNil() +} + +// connectDB creates a connection to the database +func (o *options) connectDB() (err error) { + var dsn string + switch o.Type { + case "mysql": + dsn = fmt.Sprintf("%s:%s@tcp(%s)/%s?parseTime=true", o.Username, o.Password, o.Host, o.DBName) + case "postgres": + host, port := o.Host, "" + if v := strings.Split(o.Host, ":"); len(v) > 1 { + host, port = v[0], v[1] + } + + dsn = fmt.Sprintf("host=%s dbname=%s sslmode=disable", host, o.DBName) + if o.Username != "" { + dsn += " user=" + o.Username + } + if o.Password != "" { + dsn += " password=" + o.Password + } + if port != "" { + dsn += " port=" + port + } + default: + return fmt.Errorf("unknown db type %s", o.Type) + } + o.db, err = sqlx.Open("mysql", dsn) + return err +} + +func main() { + opts := &options{ + FOpts: file.NewOptions(), + DBOptions: DBOptions{ + Type: "mysql", + Username: "user", + Password: "pass", + Host: "127.0.0.1:3306", + DBName: "db", + }, + } + app := bootstrap.NewWorkerApp(taskType, opts.NewWorker, opts). + Description(desc). + Version(tools.Version) + + app.Initialize() + + // setup database connection + if err := opts.connectDB(); err != nil { + log.Fatal("db connect", err) + } + + app.Run() +} diff --git a/apps/workers/sql_read/worker.go b/apps/workers/sql_read/worker.go new file mode 100644 index 00000000..3d87ced1 --- /dev/null +++ b/apps/workers/sql_read/worker.go @@ -0,0 +1,186 @@ +package main + +import ( + "context" + "fmt" + "io/ioutil" + "strings" + + "github.com/dustin/go-humanize" + _ "github.com/go-sql-driver/mysql" + "github.com/jbsmith7741/uri" + "github.com/jmoiron/sqlx" + jsoniter "github.com/json-iterator/go" + "github.com/pcelvng/task" + "github.com/pcelvng/task-tools/file" +) + +type worker struct { + task.Meta + + db *sqlx.DB + writer file.Writer + + Fields FieldMap + Query string +} + +type FieldMap map[string]string + +func (o *options) NewWorker(info string) task.Worker { + // unmarshal info string + iOpts := struct { + Table string `uri:"table" required:"true"` + QueryFile string `uri:"origin"` // path to query file + Fields map[string]string `uri:"field"` + Destination string `uri:"dest" required:"true"` + }{} + if err := uri.Unmarshal(info, &iOpts); err != nil { + return task.InvalidWorker(err.Error()) + } + + var query string + // get query + if len(iOpts.Fields) > 0 { + if s := strings.Split(iOpts.Table, "."); len(s) != 2 { + return task.InvalidWorker("invalid table %s (schema.table)", iOpts.Table) + } + var cols string + for k := range iOpts.Fields { + cols += k + ", " + } + cols = strings.TrimRight(cols, ", ") + query = fmt.Sprintf("select %s from %s", cols, iOpts.Table) + } + + if iOpts.QueryFile != "" { + r, err := file.NewReader(iOpts.QueryFile, o.FOpts) + if err != nil { + return task.InvalidWorker(err.Error()) + } + b, err := ioutil.ReadAll(r) + if err != nil { + return task.InvalidWorker(err.Error()) + } + query = string(b) + } + + if query == "" { + return task.InvalidWorker("query path or field params required") + } + + w, err := file.NewWriter(iOpts.Destination, o.FOpts) + if err != nil { + return task.InvalidWorker("writer: %s", err) + } + + return &worker{ + Meta: task.NewMeta(), + db: o.db, + Fields: iOpts.Fields, + Query: query, + writer: w, + } +} + +/* + +type Field struct { + DataType string + Name string +} + +func getTableInfo(db *sqlx.DB, table string) (map[string]*Field, error) { + // pull info about table + s := strings.Split(table, ".") + if len(s) != 2 { + return nil, errors.New("table requires schema and table (schema.table)") + } + + rows, err := db.Query("SELECT column_name, data_type\n FROM information_schema.columns WHERE table_schema = ? AND table_name = ?", s[0], s[1]) + if err != nil { + return nil, err + } + + fields := make(map[string]*Field) + defer rows.Close() + for rows.Next() { + var name, dType string + + if err = rows.Scan(&name, &dType); err != nil { + return nil, err + } + + if strings.Contains(dType, "char") || strings.Contains(dType, "text") { + dType = "string" + } + + if strings.Contains(dType, "int") || strings.Contains(dType, "serial") { + dType = "int" + } + + if strings.Contains(dType, "numeric") || strings.Contains(dType, "dec") || + strings.Contains(dType, "double") || strings.Contains(dType, "real") || + strings.Contains(dType, "fixed") || strings.Contains(dType, "float") { + dType = "float" + } + fields[name] = &Field{Name: name, DataType: dType} + } + return fields, rows.Close() +} */ + +func (w *worker) DoTask(ctx context.Context) (task.Result, string) { + // pull Data from mysql database + rows, err := w.db.QueryxContext(ctx, w.Query) + if err != nil { + return task.Failed(err) + } + for rows.Next() { + if task.IsDone(ctx) { + w.writer.Abort() + return task.Interrupted() + } + row := make(map[string]interface{}) + if err := rows.MapScan(row); err != nil { + return task.Failf("mapscan %s", err) + } + + r := w.Fields.convertRow(row) + b, err := jsoniter.Marshal(r) + if err != nil { + return task.Failed(err) + } + if err := w.writer.WriteLine(b); err != nil { + return task.Failed(err) + } + } + if err := rows.Close(); err != nil { + return task.Failed(err) + } + + // write to file + if err := w.writer.Close(); err != nil { + return task.Failed(err) + } + + sts := w.writer.Stats() + w.SetMeta("file", sts.Path) + + return task.Completed("%d rows written to %s (%s)", sts.LineCnt, sts.Path, humanize.Bytes(uint64(sts.ByteCnt))) +} + +func (m FieldMap) convertRow(data map[string]interface{}) map[string]interface{} { + result := make(map[string]interface{}) + for key, value := range data { + name := m[key] + switch v := value.(type) { + case []byte: + s := string(v) + result[name] = s + default: + result[name] = value + } + } + + return result +} diff --git a/apps/workers/sql_read/worker_test.go b/apps/workers/sql_read/worker_test.go new file mode 100644 index 00000000..7ac2b6a8 --- /dev/null +++ b/apps/workers/sql_read/worker_test.go @@ -0,0 +1,134 @@ +package main + +import ( + "context" + "database/sql/driver" + "errors" + "testing" + "time" + + "github.com/DATA-DOG/go-sqlmock" + "github.com/hydronica/trial" + "github.com/jbsmith7741/go-tools/sqlh" + "github.com/jmoiron/sqlx" + "github.com/pcelvng/task" + + "github.com/pcelvng/task-tools/file/mock" +) + +func TestNewWorker(t *testing.T) { + mockDb := sqlx.MustOpen(sqlh.Mock, "mockDNS") + tFile := "../../../internal/test/nop.sql" + fn := func(in trial.Input) (interface{}, error) { + opts := &options{ + db: mockDb, + } + w := opts.NewWorker(in.String()) + if invalid, s := task.IsInvalidWorker(w); invalid { + return nil, errors.New(s) + } + + return w.(*worker).Query, nil + } + cases := trial.Cases{ + "default": { + Input: tFile + "?table=schema.table&dest=nop://", + Expected: "select * from fake_table;", + }, + "no query": { + Input: "?table=schema.table&dest=nop://", + ShouldErr: true, + }, + "invalid table": { + Input: "?table=t&field=i:i&dest=nop://", + ExpectedErr: errors.New("(schema.table)"), + }, + "missing info params": { + Input: "", + ShouldErr: true, + }, + "writer err": { + Input: tFile + "?table=schema.table&dest=nop://init_err", + ExpectedErr: errors.New("writer: "), + }, + } + trial.New(fn, cases).Timeout(3 * time.Second).SubTest(t) +} + +func TestWorker_DoTask(t *testing.T) { + type input struct { + wPath string + fields FieldMap // table definition + Rows [][]driver.Value // data returned from database + } + + fn := func(i trial.Input) (interface{}, error) { + in := i.Interface().(input) + // setup mock db response + db, mDB, _ := sqlmock.New() + eq := mDB.ExpectQuery("select *") + + cols := make([]string, 0) + for k := range in.fields { + cols = append(cols, k) + } + // Add mock data + rows := sqlmock.NewRows(cols) + for _, d := range in.Rows { + rows.AddRow(d...) + } + eq.WillReturnRows(rows) + + writer := mock.NewWriter(in.wPath) + w := &worker{ + Meta: task.NewMeta(), + writer: writer, + Fields: in.fields, + db: sqlx.NewDb(db, "sql"), + Query: "select *", + } + + // return data written to file or an err on task failure + r, s := w.DoTask(context.Background()) + if r == task.CompleteResult { + return writer.GetLines(), nil + } else { + return nil, errors.New(s) + } + } + cases := trial.Cases{ + "basic": { + Input: input{}, + Expected: []string{}, + }, + "good data": { + Input: input{ + fields: FieldMap{"v": "fruit"}, + Rows: [][]driver.Value{ + {"apple"}, + {"banana"}, + }, + }, + Expected: []string{ + `{"fruit":"apple"}`, + `{"fruit":"banana"}`, + }, + }, + "write fail": { + Input: input{ + wPath: "nop://writeline_err", + fields: FieldMap{"id": "id", "v": "fruit"}, + Rows: [][]driver.Value{{1, "apple"}}, + }, + ShouldErr: true, + }, + "close err": { + Input: input{ + wPath: "nop://err", + }, + ShouldErr: true, + }, + } + trial.New(fn, cases).Test(t) + +} diff --git a/go.mod b/go.mod index 10b7b7f9..738a1f28 100644 --- a/go.mod +++ b/go.mod @@ -4,18 +4,21 @@ go 1.14 require ( github.com/BurntSushi/toml v0.3.1 + github.com/DATA-DOG/go-sqlmock v1.4.1 github.com/buger/jsonparser v1.0.0 github.com/davecgh/go-spew v1.1.1 + github.com/dustin/go-humanize v1.0.0 github.com/dustinevan/chron v1.0.0 // indirect github.com/go-ini/ini v1.57.0 // indirect github.com/go-sql-driver/mysql v1.5.0 - github.com/google/go-cmp v0.4.1 + github.com/google/go-cmp v0.5.1 github.com/hydronica/toml v0.4.1 - github.com/hydronica/trial v0.4.0 + github.com/hydronica/trial v0.5.0 github.com/jarcoal/httpmock v1.0.4 github.com/jbsmith7741/go-tools v0.2.0 github.com/jbsmith7741/trial v0.3.1 github.com/jbsmith7741/uri v0.4.1 + github.com/jmoiron/sqlx v1.2.0 github.com/json-iterator/go v1.1.10 github.com/lib/pq v1.7.0 github.com/minio/minio-go v6.0.14+incompatible diff --git a/go.sum b/go.sum index 088ead15..cacf7b00 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,8 @@ dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7 github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/DATA-DOG/go-sqlmock v1.4.1 h1:ThlnYciV1iM/V0OSF/dtkqWb6xo5qITT1TJBG1MRDJM= +github.com/DATA-DOG/go-sqlmock v1.4.1/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= @@ -38,6 +40,8 @@ github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= +github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustinevan/chron v1.0.0 h1:p7xO5zg9RhgsRLDSDfjUtf+LVYqSUNWqSoKorUwey4k= github.com/dustinevan/chron v1.0.0/go.mod h1:Ugu3EDaJooCMtWtNtcdY9Crw0HN0e41NSnPzVeXxmZo= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= @@ -47,6 +51,7 @@ github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2 github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk= +github.com/go-sql-driver/mysql v1.4.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w= github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs= github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -77,6 +82,8 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.1 h1:/exdXoGamhu5ONeUJH0deniYLWYvQwW66yvlfiiKTu0= github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1 h1:JFrFEBb2xKufg6XkJsJr+WbKb4FQlURi5RUcBveYu9k= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/martian v2.1.0+incompatible h1:/CP5g8u/VJHijgedC/Legn3BAbAaWPgecwXBIDzw5no= github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= @@ -97,6 +104,8 @@ github.com/hydronica/toml v0.4.1 h1:rsQiPyfiOctbn2oEE33bZsOO6jmGnUvThFU5Eg7uXaQ= github.com/hydronica/toml v0.4.1/go.mod h1:c7QhbYq3Wp9SlOWuG7MAieKUyXP2P/hXhy/YqWfbS/4= github.com/hydronica/trial v0.4.0 h1:RT7Aa5+RJeqJDIDlIJE+zHL7wNpktx1i66MOgiLaQqE= github.com/hydronica/trial v0.4.0/go.mod h1:b9IOkaVa93ny6m1202FP9puhZWxyNsiLYsGSMhkFk+Y= +github.com/hydronica/trial v0.5.0 h1:344R3XuyHDrGQJnU1dFn8nmARINpDTvtnh61gsbOMDA= +github.com/hydronica/trial v0.5.0/go.mod h1:sfQjkbZWzxECJphMWtdc508UcJhYUvnw6LlGYsniGCg= github.com/jarcoal/httpmock v1.0.4 h1:jp+dy/+nonJE4g4xbVtl9QdrUNbn6/3hDT5R4nDIZnA= github.com/jarcoal/httpmock v1.0.4/go.mod h1:ATjnClrvW/3tijVmpL/va5Z3aAyGvqU3gCT8nX0Txik= github.com/jbsmith7741/go-tools v0.2.0 h1:l4jdjoE4y6Mom5P8vhN3eCUs48CR7dAJINNbuhiE/Oo= @@ -107,6 +116,8 @@ github.com/jbsmith7741/trial v0.3.1/go.mod h1:M4FQWUgVpPY2+i53L2nSB0AyPc86kSTIig github.com/jbsmith7741/uri v0.3.1/go.mod h1:shq2zFtcySdrxOFxwSVtD3UGkekZr/eUGH2+KRRfF6I= github.com/jbsmith7741/uri v0.4.1 h1:gblcpwbDNmy4scW+tTtP9i2G/n8xkwk66FtYcG0wmQ4= github.com/jbsmith7741/uri v0.4.1/go.mod h1:Ctt8YJ5gCFx5BX/FMFg5VkwuI9buBcvsITIiSMH+TeA= +github.com/jmoiron/sqlx v1.2.0 h1:41Ip0zITnmWNR/vHV+S4m+VoUivnWY5E4OJfLZjCJMA= +github.com/jmoiron/sqlx v1.2.0/go.mod h1:1FEQNm3xlJgrMD+FBdI9+xvCksHtbpVBBw5dYhBSsks= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.10 h1:Kz6Cvnvv2wGdaG/V8yMvfkmNiXq9Ya2KUv4rouJJr68= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= @@ -123,8 +134,11 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= github.com/lib/pq v1.7.0 h1:h93mCPfUSkaul3Ka/VG8uZdmW1uMHDGxzu0NWHuJmHY= github.com/lib/pq v1.7.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.9.0 h1:pDRiWfl+++eC2FEFRy6jXmQlvp4Yh3z1MJKg4UeYM/4= +github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU= github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/minio/minio-go v6.0.14+incompatible h1:fnV+GD28LeqdN6vT2XdGKW8Qe/IfjJDswNVuni6km9o= diff --git a/internal/test/nop.sql b/internal/test/nop.sql new file mode 100644 index 00000000..7fe74b58 --- /dev/null +++ b/internal/test/nop.sql @@ -0,0 +1 @@ +select * from fake_table; \ No newline at end of file