diff --git a/README.md b/README.md index c6d46f7..50d9435 100644 --- a/README.md +++ b/README.md @@ -4,11 +4,11 @@ The `regresql` tool implement a regression testing facility for SQL queries, and supports the PostgreSQL database system. A regression test allows to ensure known results when the code is edited. To enable that we need: - - some code to test, here SQL queries, each in its own file, + - some code to test, here SQL queries - a known result set for each SQL query, - a regression driver that runs queries again and check their result against the known expected result set. - + The RegreSQL tool is that regression driver. It helps with creating the expected result set for each query and then running query files again to check that the results are still the same. @@ -21,7 +21,7 @@ against a known PostgreSQL database content. The `regresql` tool is written in Go, so: go get github.com/dimitri/regresql - + This command will compile and install the command in your `$GOPATH/bin`, which defaults to `~/go/bin`. See if you're new to the Go language. @@ -75,6 +75,17 @@ about [psql variables](https://www.postgresql.org/docs/current/static/app-psql.html#APP-PSQL-VARIABLES) and their usage syntax and quoting rules: `:foo`, `:'foo'` and `:"foo"`. +RegreSQL supports either single query per SQL file, or multiple queries in file. In latter case you have +to tag/name the queries to enable the support. + +Example + +``` +-- name: my-sample-query +SELECT count(*) FROM users +``` + + ## Test Suites By default a Test Suite is a source directory. @@ -92,25 +103,28 @@ RegreSQL needs the following files and directories to run: running the regression tests and the top level directory where to find the SQL files to test against. - - `./regresql/expected/path/to/query.yaml` + - `./regresql/expected/path/to/file_query-name.yaml` - For each file *query.sql* found in your source tree, RegreSQL creates a - subpath in `./regresql/plans` with a *query.yaml* file. This YAML file + For each file *file.sql* found in your source tree, RegreSQL creates a + subpath in `./regresql/plans` with a *file_query-name.yaml* file. This YAML file contains query plans: that's a list of SQL parameters values to use when testing. - - `./regresql/expected/path/to/query.out` + - `./regresql/expected/path/to/file_query-name.out` For each file *query.sql* found in your source tree, RegreSQL creates a - subpath in `./regresql/expected` directory and stores in *query.out* the + subpath in `./regresql/expected` directory and stores in *file_query-name.out* the expected result set of the query, - - `./regresql/out/path/to/query.sql` + - `./regresql/out/path/to/file_query-name.sql` - The result of running the query in *query.sql* is stored in *query.out* + The result of running the query in *file_query-name.sql* is stored in *query.out* in the `regresql/out` directory subpath for it, so that it is possible to compare this result to the expected one in `regresql/expected`. +In all cases `query_name` is replaced by the tagged query name. If not present, name +`default` is used. + ## Example In a small local application the command `regresql list` returns the @@ -141,7 +155,7 @@ Now we have to edit the YAML plan files to add bindings, here's an example for a query using a single parameter, `:name`: ``` -$ cat src/sql/album-by-artist.sql +$ cat src/sql/album_by_artist.sql -- name: list-albums-by-artist -- List the album titles and duration of a given artist select album.title as album, @@ -153,7 +167,7 @@ $ cat src/sql/album-by-artist.sql group by album order by album; -$ cat regresql/plans/src/sql/album-by-artist.yaml +$ cat regresql/plans/src/sql/album_by_artist_album-by-artist.yaml "1": name: "Red Hot Chili Peppers" ``` @@ -164,12 +178,12 @@ And we can now run the tests: $ regresql test Connecting to 'postgres:///chinook?sslmode=disable'… ✓ TAP version 13 -ok 1 - src/sql/album-by-artist.1.out -ok 2 - src/sql/album-tracks.1.out -ok 3 - src/sql/artist.1.out -ok 4 - src/sql/genre-topn.top-3.out -ok 5 - src/sql/genre-topn.top-1.out -ok 6 - src/sql/genre-tracks.out +ok 1 - src/sql/album-by-artist_album-by-artist.1.out +ok 2 - src/sql/album-tracks_album-tracks.1.out +ok 3 - src/sql/artist_top-artists-by-album.1.out +ok 4 - src/sql/genre-topn_genre-top-n.top-3.out +ok 5 - src/sql/genre-topn.genre-top-n.top-1.out +ok 6 - src/sql/genre-tracks_tracks-by-genre.1.out ``` We can see the following files have been created by the RegreSQL tool: diff --git a/regresql/plans.go b/regresql/plans.go index 0f80847..c97a33d 100644 --- a/regresql/plans.go +++ b/regresql/plans.go @@ -208,6 +208,7 @@ func (p *Plan) Write() { func getPlanPath(q *Query, targetdir string) string { planPath := filepath.Join(targetdir, filepath.Base(q.Path)) planPath = strings.TrimSuffix(planPath, path.Ext(planPath)) + planPath = planPath + "_" + q.Name planPath = planPath + ".yaml" return planPath diff --git a/regresql/regresql.go b/regresql/regresql.go index 4f3363c..9251760 100644 --- a/regresql/regresql.go +++ b/regresql/regresql.go @@ -34,15 +34,15 @@ func Init(root string, pguri string) { suite.Println() fmt.Println("") - fmt.Printf(`Empty test plans have been created in '%s'.\n -Edit the plans to add query binding values, then run\n -\n - regresql update\n -\n -to create the expected regression files for your test plans. Plans are\n -simple YAML files containing multiple set of query parameter bindings. The\n -default plan files contain a single entry named "1", you can rename the test\n -case and add a value for each parameter.\n `, + fmt.Printf(`Empty test plans have been created in '%s'. +Edit the plans to add query binding values, then run + + regresql update + +to create the expected regression files for your test plans. Plans are +simple YAML files containing multiple set of query parameter bindings. The +default plan files contain a single entry named "1", you can rename the test +case and add a value for each parameter.`, suite.PlanDir) } @@ -71,7 +71,7 @@ func PlanQueries(root string) { suite.Println() fmt.Println("") - fmt.Println(`Empty test plans have been created. + fmt.Printf(`Empty test plans have been created. Edit the plans to add query binding values, then run regresql update diff --git a/regresql/scanner.go b/regresql/scanner.go new file mode 100644 index 0000000..f8694ed --- /dev/null +++ b/regresql/scanner.go @@ -0,0 +1,71 @@ +// Credits to https://github.com/gchaincl/dotsql +package regresql + +import ( + "bufio" + "regexp" + "strings" +) + +type Scanner struct { + line string + queries map[string]string + current string +} + +type stateFn func(*Scanner) stateFn + +func getTag(line string) string { + re := regexp.MustCompile("^\\s*--\\s*name:\\s*(\\S+)") + matches := re.FindStringSubmatch(line) + if matches == nil { + return "" + } + return matches[1] +} + +func initialState(s *Scanner) stateFn { + if tag := getTag(s.line); len(tag) > 0 { + s.current = tag + return queryState + } else { + s.appendQueryLine() + } + return initialState +} + +func queryState(s *Scanner) stateFn { + if tag := getTag(s.line); len(tag) > 0 { + s.current = tag + } else { + s.appendQueryLine() + } + return queryState +} + +func (s *Scanner) appendQueryLine() { + current := s.queries[s.current] + line := strings.Trim(s.line, " \t") + if len(line) == 0 { + return + } + + if len(current) > 0 { + current = current + "\n" + } + + current = current + line + s.queries[s.current] = current +} + +func (s *Scanner) Run(io *bufio.Scanner) map[string]string { + s.queries = make(map[string]string) + s.current = "default" + + for state := initialState; io.Scan(); { + s.line = io.Text() + state = state(s) + } + + return s.queries +} diff --git a/regresql/sql.go b/regresql/sql.go index c00f202..e505f09 100644 --- a/regresql/sql.go +++ b/regresql/sql.go @@ -1,8 +1,9 @@ package regresql import ( + "bufio" "fmt" - "io/ioutil" + "os" "regexp" ) @@ -25,27 +26,33 @@ In the previous query, we would have Vars = [a b] and Params = [a a b]. */ type Query struct { Path string + Name string Text string // original query text Query string // "normalized" SQL query for lib/pq Vars []string // variable names used in the query text Params []string // ordered list of params used in the query } -// Parse a SQL file and returns a Query instance, with variables used in the -// query separated in the Query.Vars map. -func parseQueryFile(queryPath string) (*Query, error) { - sqlbytes, err := ioutil.ReadFile(queryPath) +// Parse a SQL file and returns map of Queries instances, with variables +// used in the query separated in the Query.Vars map. +func parseQueryFile(queryPath string) (map[string]*Query, error) { + f, err := os.Open(queryPath) if err != nil { - var q *Query - e := fmt.Errorf( - "Failed to parse query file '%s': %s\n", - queryPath, - err) - return q, e + return nil, fmt.Errorf("Failed to open query file '%s: %s\n", queryPath, err.Error()) } - queryString := string(sqlbytes) - return parseQueryString(queryPath, queryString), nil + scanner := &Scanner{} + newQueries := scanner.Run(bufio.NewScanner(f)) + + queries := make(map[string]*Query) + + for name, query := range newQueries { + query := parseQueryString(queryPath, name, query) + + queries[name] = query + } + + return queries, nil } // let's consider as an example the following SQL query: @@ -61,7 +68,7 @@ func parseQueryFile(queryPath string) (*Query, error) { // // the idea is that then we can replace the param names by their values // thanks to the plan test bindings given by the user (see p.Execute) -func parseQueryString(queryPath string, queryString string) *Query { +func parseQueryString(queryPath, queryName, queryString string) *Query { // find a uses of variables in the SQL query text, and put then in a // map so that we get each of them only once, even when used several // times in the same query @@ -95,7 +102,7 @@ func parseQueryString(queryPath string, queryString string) *Query { } // now build and return our Query - return &Query{queryPath, queryString, sql, vars, params} + return &Query{queryPath, queryName, queryString, sql, vars, params} } // Prepare an args... interface{} for Query from given bindings diff --git a/regresql/sql_test.go b/regresql/sql_test.go index b69d176..ca08313 100644 --- a/regresql/sql_test.go +++ b/regresql/sql_test.go @@ -6,7 +6,7 @@ import ( func TestParseQueryString(t *testing.T) { queryString := `select * from foo where id = :user_id` - q := parseQueryString("no/path", queryString) + q := parseQueryString("no/path", "default", queryString) if len(q.Vars) != 1 || q.Vars[0] != "user_id" { t.Error("Expected [\"user_id\"], got ", q.Vars) @@ -15,7 +15,7 @@ func TestParseQueryString(t *testing.T) { func TestParseQueryStringWithTypeCast(t *testing.T) { queryString := `select name::text from foo where id = :user_id` - q := parseQueryString("no/path", queryString) + q := parseQueryString("no/path", "default", queryString) if len(q.Vars) != 1 || q.Vars[0] != "user_id" { t.Error("Expected only [\"user_id\"], got ", q.Vars) @@ -24,7 +24,7 @@ func TestParseQueryStringWithTypeCast(t *testing.T) { func TestPrepareOneParam(t *testing.T) { queryString := `select * from foo where id = :id` - q := parseQueryString("no/path", queryString) + q := parseQueryString("no/path", "default", queryString) b := make(map[string]string) b["id"] = "1" @@ -42,7 +42,7 @@ func TestPrepareOneParam(t *testing.T) { func TestPrepareTwoParams(t *testing.T) { queryString := `select * from foo where a = :a and b between :a and :b` - q := parseQueryString("no/path", queryString) + q := parseQueryString("no/path", "default", queryString) b := make(map[string]string) b["a"] = "a" b["b"] = "b" diff --git a/regresql/suite.go b/regresql/suite.go index b77efc5..fe896e8 100644 --- a/regresql/suite.go +++ b/regresql/suite.go @@ -131,14 +131,15 @@ func (s *Suite) initRegressHierarchy() error { for _, name := range folder.Files { qfile := filepath.Join(s.Root, folder.Dir, name) - q, err := parseQueryFile(qfile) - + queries, err := parseQueryFile(qfile) if err != nil { return err } - if _, err := q.CreateEmptyPlan(rdir); err != nil { - fmt.Println("Skipping:", err) + for _, q := range queries { + if _, err := q.CreateEmptyPlan(rdir); err != nil { + fmt.Println("Skipping:", err) + } } } } @@ -167,21 +168,23 @@ func (s *Suite) createExpectedResults(pguri string) error { for _, name := range folder.Files { qfile := filepath.Join(s.Root, folder.Dir, name) - q, err := parseQueryFile(qfile) - + queries, err := parseQueryFile(qfile) if err != nil { return err } - p, err := q.GetPlan(rdir) - if err != nil { - return err - } - p.Execute(db) - p.WriteResultSets(edir) + for _, q := range queries { + + p, err := q.GetPlan(rdir) + if err != nil { + return err + } + p.Execute(db) + p.WriteResultSets(edir) - for _, rs := range p.ResultSets { - fmt.Printf(" %s\n", filepath.Base(rs.Filename)) + for _, rs := range p.ResultSets { + fmt.Printf(" %s\n", filepath.Base(rs.Filename)) + } } } } @@ -212,23 +215,24 @@ func (s *Suite) testQueries(pguri string) error { for _, name := range folder.Files { qfile := filepath.Join(s.Root, folder.Dir, name) - q, err := parseQueryFile(qfile) - + queries, err := parseQueryFile(qfile) if err != nil { return err } - p, err := q.GetPlan(rdir) - if err != nil { - return err - } - if err := p.Execute(db); err != nil { - return err - } - if err := p.WriteResultSets(odir); err != nil { - return err + for _, q := range queries { + p, err := q.GetPlan(rdir) + if err != nil { + return err + } + if err := p.Execute(db); err != nil { + return err + } + if err := p.WriteResultSets(odir); err != nil { + return err + } + p.CompareResultSets(s.RegressDir, edir, t) } - p.CompareResultSets(s.RegressDir, edir, t) } } return nil