From 52d475b1495a59d00195df8c4c6ce1351c2e7019 Mon Sep 17 00:00:00 2001 From: Jean-Hadrien Chabran Date: Tue, 6 Apr 2021 20:45:30 +0200 Subject: [PATCH 01/10] POC the parser --- cmd/examplar/examplar_test.go | 26 +++++++++ cmd/examplar/extest1.sql | 10 ++++ cmd/examplar/main.go | 99 +++++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+) create mode 100644 cmd/examplar/examplar_test.go create mode 100644 cmd/examplar/extest1.sql create mode 100644 cmd/examplar/main.go diff --git a/cmd/examplar/examplar_test.go b/cmd/examplar/examplar_test.go new file mode 100644 index 000000000..bc83af8c6 --- /dev/null +++ b/cmd/examplar/examplar_test.go @@ -0,0 +1,26 @@ +package main + +import ( + "os" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestParse(t *testing.T) { + f, err := os.Open("extest1.sql") + require.NoError(t, err) + + ex, err := parse(f) + require.NoError(t, err) + + require.Equal(t, ex.setup, []string{"CREATE TABLE foo (a int);"}) + require.Equal(t, ex.teardown, []string{"DROP TABLE foo;"}) + + example := ex.examples[0] + require.NotNil(t, example) + require.Equal(t, example.name, "insert something") + + require.Equal(t, example.statements, []string{"INSERT INTO foo (1);"}) + require.Equal(t, example.assertions, []string{"1"}) +} diff --git a/cmd/examplar/extest1.sql b/cmd/examplar/extest1.sql new file mode 100644 index 000000000..ae34e99b9 --- /dev/null +++ b/cmd/examplar/extest1.sql @@ -0,0 +1,10 @@ +--- setup: +CREATE TABLE foo (a int); + +--- teardown: +DROP TABLE foo; + +--- test: insert something +INSERT INTO foo (1); +--- `1` + diff --git a/cmd/examplar/main.go b/cmd/examplar/main.go new file mode 100644 index 000000000..0db9e7a19 --- /dev/null +++ b/cmd/examplar/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "bufio" + "io" + "strings" +) + +const comment = "---" + +type State int + +const ( + ILLEGAL State = iota + SETUP + TEARDOWN + TEST + ASSERT_EQ +) + +type example struct { + name string + statements []string + assertions []string +} + +type examplar struct { + setup []string + teardown []string + examples []*example +} + +func parse(r io.Reader) (*examplar, error) { + var ex examplar + var state State + + s := bufio.NewScanner(r) + s.Split(bufio.ScanLines) + + for s.Scan() { + line := s.Text() + // fmt.Println("state ", state, "| ", line) + + if line == "" { + continue + } + + if strings.HasPrefix(line, comment) { + c := strings.TrimPrefix(line, comment) + c = strings.TrimLeft(c, " ") + + switch { + case strings.HasPrefix(c, "setup:"): + state = SETUP + case strings.HasPrefix(c, "teardown:"): + state = TEARDOWN + case strings.HasPrefix(c, "test:"): + if state != TEST { + name := strings.TrimPrefix(c, "test:") + name = strings.TrimSpace(name) + ex.examples = append(ex.examples, &example{name: name}) + } + state = TEST + case strings.HasPrefix(c, "`"): + state = ASSERT_EQ + expected := strings.TrimPrefix(c, "`") + expected = strings.TrimSuffix(expected, "`") + + t := ex.examples[len(ex.examples)-1] + t.assertions = append(t.assertions, expected) + + default: + state = ILLEGAL + } + } else { + switch state { + case SETUP: + ex.setup = append(ex.setup, line) + case TEARDOWN: + ex.teardown = append(ex.teardown, line) + case TEST: + t := ex.examples[len(ex.examples)-1] + t.statements = append(t.statements, line) + case ASSERT_EQ: + case ILLEGAL: + panic(line) + } + } + } + + if err := s.Err(); err != nil { + return nil, err + } + + return &ex, nil +} + +func main() { +} From e0b5bae8afcae52b19b987fc538f2fb69fa92058 Mon Sep 17 00:00:00 2001 From: Jean-Hadrien Chabran Date: Wed, 7 Apr 2021 11:22:45 +0200 Subject: [PATCH 02/10] POC the generator --- cmd/examplar/examplar_test.go | 40 +++++++++++++++++++++++-- cmd/examplar/extest1.sql | 6 ++-- cmd/examplar/extest1_test.go.gold | 37 +++++++++++++++++++++++ cmd/examplar/main.go | 48 +++++++++++++++++++++++++----- cmd/examplar/test_template.go.tmpl | 42 ++++++++++++++++++++++++++ 5 files changed, 160 insertions(+), 13 deletions(-) create mode 100644 cmd/examplar/extest1_test.go.gold create mode 100644 cmd/examplar/test_template.go.tmpl diff --git a/cmd/examplar/examplar_test.go b/cmd/examplar/examplar_test.go index bc83af8c6..47348c9c1 100644 --- a/cmd/examplar/examplar_test.go +++ b/cmd/examplar/examplar_test.go @@ -1,7 +1,10 @@ package main import ( + "fmt" + "io/ioutil" "os" + "strings" "testing" "github.com/stretchr/testify/require" @@ -10,8 +13,9 @@ import ( func TestParse(t *testing.T) { f, err := os.Open("extest1.sql") require.NoError(t, err) + defer f.Close() - ex, err := parse(f) + ex, err := parse(f, "extest1") require.NoError(t, err) require.Equal(t, ex.setup, []string{"CREATE TABLE foo (a int);"}) @@ -21,6 +25,36 @@ func TestParse(t *testing.T) { require.NotNil(t, example) require.Equal(t, example.name, "insert something") - require.Equal(t, example.statements, []string{"INSERT INTO foo (1);"}) - require.Equal(t, example.assertions, []string{"1"}) + stmt := example.statements[0] + require.Equal(t, stmt.Code, "INSERT INTO foo (a) VALUES (1);") + + stmt = example.statements[1] + require.Equal(t, stmt.Code, "SELECT * FROM foo;") + require.Equal(t, stmt.EqAssertion, `{"a": 1}`) +} + +func TestTemplate(t *testing.T) { + g, err := os.Open("extest1_test.go.gold") + require.NoError(t, err) + defer g.Close() + + gb, err := ioutil.ReadAll(g) + require.NoError(t, err) + + gold := string(gb) + + f, err := os.Open("extest1.sql") + require.NoError(t, err) + defer f.Close() + + ex, err := parse(f, "extest1") + require.NoError(t, err) + + var b strings.Builder + + err = generate(ex, &b) + require.NoError(t, err) + + fmt.Printf(b.String() + "\n") + require.Equal(t, strings.Split(gold, "\n"), strings.Split(b.String(), "\n")) } diff --git a/cmd/examplar/extest1.sql b/cmd/examplar/extest1.sql index ae34e99b9..f7ac9bd3a 100644 --- a/cmd/examplar/extest1.sql +++ b/cmd/examplar/extest1.sql @@ -5,6 +5,6 @@ CREATE TABLE foo (a int); DROP TABLE foo; --- test: insert something -INSERT INTO foo (1); ---- `1` - +INSERT INTO foo (a) VALUES (1); +SELECT * FROM foo; +--- `{"a": 1}` diff --git a/cmd/examplar/extest1_test.go.gold b/cmd/examplar/extest1_test.go.gold new file mode 100644 index 000000000..ecdf9c661 --- /dev/null +++ b/cmd/examplar/extest1_test.go.gold @@ -0,0 +1,37 @@ +package integration_test + +import ( + "encoding/json" + "testing" + + "github.com/genjidb/genji" + "github.com/stretchr/testify/require" +) + +func TestInsertSomething(t *testing.T) { + db, err := genji.Open(":memory:") + require.NoError(t, err) + + // teardown extest1.sql:4 + t.Cleanup(func() { + err := db.Exec("DROP TABLE foo;") + require.NoError(t, err) + defer db.Close() + }) + + // setup extest1.sql:1 + err = db.Exec("CREATE TABLE foo (a int);") + require.NoError(t, err) + // extest1.sql:8 + err = db.Exec("INSERT INTO foo (a) VALUES (1);") + require.NoError(t, err) + // extest1.sql:9 + doc, err := db.QueryDocument("SELECT * FROM foo;") + require.NoError(t, err) + + data, err := json.Marshal(doc) + require.NoError(t, err) + + expected := `{"a": 1}` + require.JSONEq(t, expected, string(data)) +} diff --git a/cmd/examplar/main.go b/cmd/examplar/main.go index 0db9e7a19..2d905de2f 100644 --- a/cmd/examplar/main.go +++ b/cmd/examplar/main.go @@ -4,6 +4,7 @@ import ( "bufio" "io" "strings" + "text/template" ) const comment = "---" @@ -20,18 +21,23 @@ const ( type example struct { name string - statements []string - assertions []string + statements []*Statement } type examplar struct { + name string setup []string teardown []string examples []*example } -func parse(r io.Reader) (*examplar, error) { - var ex examplar +type Statement struct { + Code string + EqAssertion string +} + +func parse(r io.Reader, name string) (*examplar, error) { + ex := examplar{name: name} var state State s := bufio.NewScanner(r) @@ -67,8 +73,8 @@ func parse(r io.Reader) (*examplar, error) { expected = strings.TrimSuffix(expected, "`") t := ex.examples[len(ex.examples)-1] - t.assertions = append(t.assertions, expected) - + stmt := t.statements[len(t.statements)-1] + stmt.EqAssertion = expected default: state = ILLEGAL } @@ -80,7 +86,9 @@ func parse(r io.Reader) (*examplar, error) { ex.teardown = append(ex.teardown, line) case TEST: t := ex.examples[len(ex.examples)-1] - t.statements = append(t.statements, line) + t.statements = append(t.statements, &Statement{ + Code: line, + }) case ASSERT_EQ: case ILLEGAL: panic(line) @@ -95,5 +103,31 @@ func parse(r io.Reader) (*examplar, error) { return &ex, nil } +func normalizeTestName(name string) string { + name = strings.TrimSpace(name) + name = strings.Title(name) + return strings.ReplaceAll(name, " ", "") +} + +func generate(ex *examplar, w io.Writer) error { + tmpl := template.Must(template.ParseFiles("test_template.go.tmpl")) + + bindings := struct { + Package string + TestName string + Setup string + Teardown string + Statements []*Statement + }{ + "integration_test", + normalizeTestName(ex.examples[0].name), + strings.Join(ex.setup, "\n"), + strings.Join(ex.teardown, "\n"), + ex.examples[0].statements, + } + + return tmpl.Execute(w, bindings) +} + func main() { } diff --git a/cmd/examplar/test_template.go.tmpl b/cmd/examplar/test_template.go.tmpl new file mode 100644 index 000000000..f788917e1 --- /dev/null +++ b/cmd/examplar/test_template.go.tmpl @@ -0,0 +1,42 @@ +package {{ .Package }} + +import ( + "encoding/json" + "testing" + + "github.com/genjidb/genji" + "github.com/stretchr/testify/require" +) + +func Test{{ .TestName }}(t *testing.T) { + db, err := genji.Open(":memory:") + require.NoError(t, err) + + // teardown extest1.sql:4 + t.Cleanup(func() { + err := db.Exec("{{ .Teardown }}") + require.NoError(t, err) + defer db.Close() + }) + + // setup extest1.sql:1 + err = db.Exec("{{ .Setup }}") + require.NoError(t, err) + {{- range .Statements }} + {{- if .EqAssertion }} + // extest1.sql:9 + doc, err := db.QueryDocument("{{ .Code }}") + require.NoError(t, err) + + data, err := json.Marshal(doc) + require.NoError(t, err) + + expected := `{{ .EqAssertion }}` + require.JSONEq(t, expected, string(data)) + {{- else}} + // extest1.sql:8 + err = db.Exec("{{ .Code }}") + require.NoError(t, err) + {{- end }} + {{- end }} +} From 464b14a4ef97dbca9039915c074b77ebf5ba67f2 Mon Sep 17 00:00:00 2001 From: Jean-Hadrien Chabran Date: Thu, 8 Apr 2021 12:40:31 +0200 Subject: [PATCH 03/10] Add multiline assertion support --- cmd/examplar/examplar_test.go | 20 ++- cmd/examplar/extest1.sql | 8 + cmd/examplar/main.go | 265 ++++++++++++++++++++++------- cmd/examplar/test_template.go.tmpl | 4 +- 4 files changed, 222 insertions(+), 75 deletions(-) diff --git a/cmd/examplar/examplar_test.go b/cmd/examplar/examplar_test.go index 47348c9c1..a1afea727 100644 --- a/cmd/examplar/examplar_test.go +++ b/cmd/examplar/examplar_test.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "io/ioutil" "os" "strings" @@ -15,7 +14,7 @@ func TestParse(t *testing.T) { require.NoError(t, err) defer f.Close() - ex, err := parse(f, "extest1") + ex, err := Parse(f, "extest1") require.NoError(t, err) require.Equal(t, ex.setup, []string{"CREATE TABLE foo (a int);"}) @@ -23,14 +22,18 @@ func TestParse(t *testing.T) { example := ex.examples[0] require.NotNil(t, example) - require.Equal(t, example.name, "insert something") + require.Equal(t, "insert something", example.name) stmt := example.statements[0] - require.Equal(t, stmt.Code, "INSERT INTO foo (a) VALUES (1);") + require.Equal(t, "INSERT INTO foo (a) VALUES (1);", stmt.Code) stmt = example.statements[1] - require.Equal(t, stmt.Code, "SELECT * FROM foo;") - require.Equal(t, stmt.EqAssertion, `{"a": 1}`) + require.Equal(t, "SELECT * FROM foo;", stmt.Code) + require.Equal(t, `{"a": 1}`, stmt.Expectation) + + stmt = example.statements[2] + require.Equal(t, "SELECT a, b FROM foo;", stmt.Code) + require.JSONEq(t, `{"a": 1, "b": null}`, stmt.Expectation) } func TestTemplate(t *testing.T) { @@ -47,14 +50,13 @@ func TestTemplate(t *testing.T) { require.NoError(t, err) defer f.Close() - ex, err := parse(f, "extest1") + ex, err := Parse(f, "extest1") require.NoError(t, err) var b strings.Builder - err = generate(ex, &b) + err = Generate(ex, &b) require.NoError(t, err) - fmt.Printf(b.String() + "\n") require.Equal(t, strings.Split(gold, "\n"), strings.Split(b.String(), "\n")) } diff --git a/cmd/examplar/extest1.sql b/cmd/examplar/extest1.sql index f7ac9bd3a..d6eeaa659 100644 --- a/cmd/examplar/extest1.sql +++ b/cmd/examplar/extest1.sql @@ -8,3 +8,11 @@ DROP TABLE foo; INSERT INTO foo (a) VALUES (1); SELECT * FROM foo; --- `{"a": 1}` + +SELECT a, b FROM foo; +--- ```json +--- { +--- "a": 1, +--- "b": null +--- } +--- ``` diff --git a/cmd/examplar/main.go b/cmd/examplar/main.go index 2d905de2f..dd6eed525 100644 --- a/cmd/examplar/main.go +++ b/cmd/examplar/main.go @@ -3,104 +3,241 @@ package main import ( "bufio" "io" + "regexp" "strings" "text/template" ) -const comment = "---" +const commentPrefix = "---" -type State int +type Tag int const ( - ILLEGAL State = iota + UNKNOWN Tag = iota SETUP TEARDOWN TEST - ASSERT_EQ ) -type example struct { +// Test is a list of statements. +type Test struct { name string statements []*Statement } -type examplar struct { +// Statement is a pair composed of a line of code and an expectation on its result when evaluated. +type Statement struct { + Code string + Expectation string +} + +// Examplar represents a group of tests and can optionally include setup code and teardown code. +type Examplar struct { name string setup []string teardown []string - examples []*example + examples []*Test } -type Statement struct { - Code string - EqAssertion string +// HasSetup returns true if setup code is provided. +func (ex *Examplar) HasSetup() bool { + return len(ex.setup) > 0 +} + +// HasSetup returns true if teardown code is provided. +func (ex *Examplar) HasTeardown() bool { + return len(ex.teardown) > 0 } -func parse(r io.Reader, name string) (*examplar, error) { - ex := examplar{name: name} - var state State +func (ex *Examplar) appendTest(name string) { + ex.examples = append(ex.examples, &Test{ + name: name, + }) +} - s := bufio.NewScanner(r) - s.Split(bufio.ScanLines) +func (ex *Examplar) currentTest() *Test { + return ex.examples[len(ex.examples)-1] +} - for s.Scan() { - line := s.Text() - // fmt.Println("state ", state, "| ", line) +type stateFn func(*Scanner) stateFn - if line == "" { - continue +type Scanner struct { + line string + ex *Examplar +} + +func initialState(s *Scanner) stateFn { + if tag, data := parseTag(s.line); tag != UNKNOWN { + switch tag { + case SETUP: + return setupState + case TEARDOWN: + return teardownState + case TEST: + s.ex.appendTest(data) + return testState } + } + + return initialState +} - if strings.HasPrefix(line, comment) { - c := strings.TrimPrefix(line, comment) - c = strings.TrimLeft(c, " ") - - switch { - case strings.HasPrefix(c, "setup:"): - state = SETUP - case strings.HasPrefix(c, "teardown:"): - state = TEARDOWN - case strings.HasPrefix(c, "test:"): - if state != TEST { - name := strings.TrimPrefix(c, "test:") - name = strings.TrimSpace(name) - ex.examples = append(ex.examples, &example{name: name}) - } - state = TEST - case strings.HasPrefix(c, "`"): - state = ASSERT_EQ - expected := strings.TrimPrefix(c, "`") - expected = strings.TrimSuffix(expected, "`") - - t := ex.examples[len(ex.examples)-1] - stmt := t.statements[len(t.statements)-1] - stmt.EqAssertion = expected - default: - state = ILLEGAL +func setupState(s *Scanner) stateFn { + if tag, data := parseTag(s.line); tag != UNKNOWN { + switch tag { + case SETUP: + return errorState + case TEARDOWN: + if s.ex.HasTeardown() { + return errorState + } else { + return teardownState } - } else { - switch state { - case SETUP: - ex.setup = append(ex.setup, line) - case TEARDOWN: - ex.teardown = append(ex.teardown, line) - case TEST: - t := ex.examples[len(ex.examples)-1] - t.statements = append(t.statements, &Statement{ - Code: line, - }) - case ASSERT_EQ: - case ILLEGAL: - panic(line) + case TEST: + s.ex.appendTest(data) + return testState + } + } + + s.ex.setup = append(s.ex.setup, s.line) + return setupState +} + +func teardownState(s *Scanner) stateFn { + if tag, data := parseTag(s.line); tag != UNKNOWN { + switch tag { + case SETUP: + if s.ex.HasSetup() { + return errorState + } else { + return setupState } + case TEARDOWN: + return errorState + case TEST: + s.ex.appendTest(data) + return testState + } + } + + s.ex.teardown = append(s.ex.teardown, s.line) + return teardownState +} + +func testState(s *Scanner) stateFn { + if tag, data := parseTag(s.line); tag != UNKNOWN { + switch tag { + case SETUP: + return errorState + case TEARDOWN: + return errorState + case TEST: + s.ex.appendTest(data) + return testState + } + } + + test := s.ex.currentTest() + + if hasMultilineAssertionTag(s.line) { + return multilineAssertionState + } + + if assertion := parseSingleAssertion(s.line); len(assertion) > 0 { + stmt := test.statements[len(test.statements)-1] + stmt.Expectation = assertion + return testState + } + + test.statements = append(test.statements, &Statement{ + Code: s.line, + }) + + return testState +} + +func multilineAssertionState(s *Scanner) stateFn { + re := regexp.MustCompile(`^\s*` + commentPrefix + `\s*(.*)`) + matches := re.FindStringSubmatch(s.line) + + if matches == nil { + return multilineAssertionState + } + + code := strings.TrimRight(matches[1], " \t") + + if code == "```" { + return testState + } + + test := s.ex.currentTest() + test.statements[len(test.statements)-1].Expectation += code + "\n" + return multilineAssertionState +} + +func errorState(s *Scanner) stateFn { + panic(s.line) +} + +func (s *Scanner) Run(io *bufio.Scanner) *Examplar { + s.ex = &Examplar{} + + for state := initialState; io.Scan(); { + s.line = io.Text() + s.line = strings.TrimSpace(s.line) + if s.line == "" { + continue } + state = state(s) + } + + return s.ex +} + +func parseTag(line string) (Tag, string) { + re := regexp.MustCompile(`^\s*` + commentPrefix + `\s*(\w+):\s*(.*)`) + matches := re.FindStringSubmatch(line) + if matches == nil { + return UNKNOWN, "" } - if err := s.Err(); err != nil { - return nil, err + var tag Tag + switch strings.ToLower(matches[1]) { + case "setup": + tag = SETUP + case "teardown": + tag = TEARDOWN + case "test": + tag = TEST + default: + return UNKNOWN, "" } - return &ex, nil + return tag, matches[2] +} + +func parseSingleAssertion(line string) string { + re := regexp.MustCompile(`^\s*` + commentPrefix + `\s*` + "`" + `([^` + "`" + `]+)`) + matches := re.FindStringSubmatch(line) + if matches == nil { + return "" + } + return matches[1] +} + +func hasMultilineAssertionTag(line string) bool { + re := regexp.MustCompile(`^\s*` + commentPrefix + `\s*` + "```" + `(\w*)`) + matches := re.FindStringSubmatch(line) + return matches != nil +} + +func Parse(r io.Reader, name string) (*Examplar, error) { + scanner := &Scanner{} + + ex := scanner.Run(bufio.NewScanner(r)) + ex.name = name + + return ex, nil } func normalizeTestName(name string) string { @@ -109,7 +246,7 @@ func normalizeTestName(name string) string { return strings.ReplaceAll(name, " ", "") } -func generate(ex *examplar, w io.Writer) error { +func Generate(ex *Examplar, w io.Writer) error { tmpl := template.Must(template.ParseFiles("test_template.go.tmpl")) bindings := struct { diff --git a/cmd/examplar/test_template.go.tmpl b/cmd/examplar/test_template.go.tmpl index f788917e1..e11816d8f 100644 --- a/cmd/examplar/test_template.go.tmpl +++ b/cmd/examplar/test_template.go.tmpl @@ -23,7 +23,7 @@ func Test{{ .TestName }}(t *testing.T) { err = db.Exec("{{ .Setup }}") require.NoError(t, err) {{- range .Statements }} - {{- if .EqAssertion }} + {{- if .Expectation }} // extest1.sql:9 doc, err := db.QueryDocument("{{ .Code }}") require.NoError(t, err) @@ -31,7 +31,7 @@ func Test{{ .TestName }}(t *testing.T) { data, err := json.Marshal(doc) require.NoError(t, err) - expected := `{{ .EqAssertion }}` + expected := `{{ .Expectation }}` require.JSONEq(t, expected, string(data)) {{- else}} // extest1.sql:8 From 1443108d493abe43c23ece1c996e8445712718cf Mon Sep 17 00:00:00 2001 From: Jean-Hadrien Chabran Date: Thu, 8 Apr 2021 21:16:53 +0200 Subject: [PATCH 04/10] Add Line no, better indetation and fix template --- cmd/examplar/examplar_test.go | 47 ++++++++--- cmd/examplar/extest1.sql | 8 ++ cmd/examplar/extest1_test.go.gold | 123 +++++++++++++++++++++-------- cmd/examplar/main.go | 99 +++++++++++++++-------- cmd/examplar/test_template.go.tmpl | 90 ++++++++++++++------- 5 files changed, 260 insertions(+), 107 deletions(-) diff --git a/cmd/examplar/examplar_test.go b/cmd/examplar/examplar_test.go index a1afea727..fdc5c92ef 100644 --- a/cmd/examplar/examplar_test.go +++ b/cmd/examplar/examplar_test.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "io/ioutil" "os" "strings" @@ -17,23 +18,41 @@ func TestParse(t *testing.T) { ex, err := Parse(f, "extest1") require.NoError(t, err) - require.Equal(t, ex.setup, []string{"CREATE TABLE foo (a int);"}) - require.Equal(t, ex.teardown, []string{"DROP TABLE foo;"}) + require.Equal(t, []Line{{2, "CREATE TABLE foo (a int);"}}, ex.setup) + require.Equal(t, []Line{{5, "DROP TABLE foo;"}}, ex.teardown) + // first test example := ex.examples[0] require.NotNil(t, example) - require.Equal(t, "insert something", example.name) + require.Equal(t, "insert something", example.Name) - stmt := example.statements[0] - require.Equal(t, "INSERT INTO foo (a) VALUES (1);", stmt.Code) + stmt := example.Statements[0] + require.Equal(t, "INSERT INTO foo (a) VALUES (1);", stmt.Code.Text) - stmt = example.statements[1] - require.Equal(t, "SELECT * FROM foo;", stmt.Code) - require.Equal(t, `{"a": 1}`, stmt.Expectation) + stmt = example.Statements[1] + require.Equal(t, "SELECT * FROM foo;", stmt.Code.Text) + require.Equal(t, `{"a": 1}`, stmt.Expectation[0].Text) - stmt = example.statements[2] - require.Equal(t, "SELECT a, b FROM foo;", stmt.Code) - require.JSONEq(t, `{"a": 1, "b": null}`, stmt.Expectation) + stmt = example.Statements[2] + require.Equal(t, "SELECT a, b FROM foo;", stmt.Code.Text) + fmt.Println("---", len(stmt.Expectation)) + require.JSONEq(t, `{"a": 1, "b": null}`, stmt.expectationText()) + + stmt = example.Statements[3] + require.Equal(t, "SELECT z FROM foo;", stmt.Code.Text) + require.Equal(t, `{"z": null}`, stmt.Expectation[0].Text) + + // second test + example = ex.examples[1] + require.NotNil(t, example) + require.Equal(t, "something else", example.Name) + + stmt = example.Statements[0] + require.Equal(t, "INSERT INTO foo (c) VALUES (3);", stmt.Code.Text) + + stmt = example.Statements[1] + require.Equal(t, "SELECT * FROM foo;", stmt.Code.Text) + require.Equal(t, `{"c": 3}`, stmt.Expectation[0].Text) } func TestTemplate(t *testing.T) { @@ -58,5 +77,11 @@ func TestTemplate(t *testing.T) { err = Generate(ex, &b) require.NoError(t, err) + // some code to generate the gold version + // o, err := os.OpenFile("trace_test.go", os.O_CREATE|os.O_WRONLY, 0777) + // require.NoError(t, err) + // o.WriteString(b.String()) + // defer o.Close() + require.Equal(t, strings.Split(gold, "\n"), strings.Split(b.String(), "\n")) } diff --git a/cmd/examplar/extest1.sql b/cmd/examplar/extest1.sql index d6eeaa659..1f97931f0 100644 --- a/cmd/examplar/extest1.sql +++ b/cmd/examplar/extest1.sql @@ -16,3 +16,11 @@ SELECT a, b FROM foo; --- "b": null --- } --- ``` + +SELECT z FROM foo; +--- `{"z": null}` + +--- test: something else +INSERT INTO foo (c) VALUES (3); +SELECT * FROM foo; +--- `{"c": 3}` diff --git a/cmd/examplar/extest1_test.go.gold b/cmd/examplar/extest1_test.go.gold index ecdf9c661..dfbfc40fb 100644 --- a/cmd/examplar/extest1_test.go.gold +++ b/cmd/examplar/extest1_test.go.gold @@ -1,37 +1,96 @@ -package integration_test +package main import ( - "encoding/json" - "testing" + "encoding/json" + "testing" - "github.com/genjidb/genji" - "github.com/stretchr/testify/require" + "github.com/genjidb/genji" + "github.com/genjidb/genji/document" + "github.com/stretchr/testify/require" ) -func TestInsertSomething(t *testing.T) { - db, err := genji.Open(":memory:") - require.NoError(t, err) - - // teardown extest1.sql:4 - t.Cleanup(func() { - err := db.Exec("DROP TABLE foo;") - require.NoError(t, err) - defer db.Close() - }) - - // setup extest1.sql:1 - err = db.Exec("CREATE TABLE foo (a int);") - require.NoError(t, err) - // extest1.sql:8 - err = db.Exec("INSERT INTO foo (a) VALUES (1);") - require.NoError(t, err) - // extest1.sql:9 - doc, err := db.QueryDocument("SELECT * FROM foo;") - require.NoError(t, err) - - data, err := json.Marshal(doc) - require.NoError(t, err) - - expected := `{"a": 1}` - require.JSONEq(t, expected, string(data)) -} +func TestFooBar(t *testing.T) { + db, err := genji.Open(":memory:") + require.NoError(t, err) + defer db.Close() + + // teardown + teardown := func() { + var err error + err = db.Exec("DROP TABLE foo;") // orig:5 + require.NoError(t, err) + } + + // setup + setup := func() { + err = db.Exec("CREATE TABLE foo (a int);") // orig:2 + require.NoError(t, err) + } + + // orig:7 + t.Run("insert something", func(t *testing.T) { + t.Cleanup(teardown) + setup() + + var err error + var doc document.Document + var data []byte + var expected string + + err = db.Exec("INSERT INTO foo (a) VALUES (1);") // orig:8 + require.NoError(t, err) + doc, err = db.QueryDocument("SELECT * FROM foo;") // orig:9 + require.NoError(t, err) + + data, err = json.Marshal(doc) + require.NoError(t, err) + + expected = `{"a": 1}` // orig:10 + require.JSONEq(t, expected, string(data)) + doc, err = db.QueryDocument("SELECT a, b FROM foo;") // orig:12 + require.NoError(t, err) + + data, err = json.Marshal(doc) + require.NoError(t, err) + + // orig: 14 + expected = ` + { + "a": 1, + "b": null + } + ` + require.JSONEq(t, expected, string(data)) + doc, err = db.QueryDocument("SELECT z FROM foo;") // orig:20 + require.NoError(t, err) + + data, err = json.Marshal(doc) + require.NoError(t, err) + + expected = `{"z": null}` // orig:21 + require.JSONEq(t, expected, string(data)) + }) + + // orig:23 + t.Run("something else", func(t *testing.T) { + t.Cleanup(teardown) + setup() + + var err error + var doc document.Document + var data []byte + var expected string + + err = db.Exec("INSERT INTO foo (c) VALUES (3);") // orig:24 + require.NoError(t, err) + doc, err = db.QueryDocument("SELECT * FROM foo;") // orig:25 + require.NoError(t, err) + + data, err = json.Marshal(doc) + require.NoError(t, err) + + expected = `{"c": 3}` // orig:26 + require.JSONEq(t, expected, string(data)) + }) + + } diff --git a/cmd/examplar/main.go b/cmd/examplar/main.go index dd6eed525..4c04d0052 100644 --- a/cmd/examplar/main.go +++ b/cmd/examplar/main.go @@ -19,23 +19,38 @@ const ( TEST ) +type Line struct { + Num int + Text string +} + // Test is a list of statements. type Test struct { - name string - statements []*Statement + Name string + Num int + Statements []*Statement } // Statement is a pair composed of a line of code and an expectation on its result when evaluated. type Statement struct { - Code string - Expectation string + Code Line + Expectation []Line +} + +func (s Statement) expectationText() string { + var text string + for _, e := range s.Expectation { + text += e.Text + "\n" + } + + return text } // Examplar represents a group of tests and can optionally include setup code and teardown code. type Examplar struct { - name string - setup []string - teardown []string + Name string + setup []Line + teardown []Line examples []*Test } @@ -49,9 +64,10 @@ func (ex *Examplar) HasTeardown() bool { return len(ex.teardown) > 0 } -func (ex *Examplar) appendTest(name string) { +func (ex *Examplar) appendTest(name string, num int) { ex.examples = append(ex.examples, &Test{ - name: name, + Name: name, + Num: num, }) } @@ -59,10 +75,20 @@ func (ex *Examplar) currentTest() *Test { return ex.examples[len(ex.examples)-1] } +func (ex *Examplar) currentStatement() *Statement { + test := ex.currentTest() + return test.Statements[len(test.Statements)-1] +} + +func (ex *Examplar) currentExpectation() *[]Line { + return &ex.currentStatement().Expectation +} + type stateFn func(*Scanner) stateFn type Scanner struct { line string + num int ex *Examplar } @@ -74,7 +100,7 @@ func initialState(s *Scanner) stateFn { case TEARDOWN: return teardownState case TEST: - s.ex.appendTest(data) + s.ex.appendTest(data, s.num) return testState } } @@ -94,12 +120,12 @@ func setupState(s *Scanner) stateFn { return teardownState } case TEST: - s.ex.appendTest(data) + s.ex.appendTest(data, s.num) return testState } } - s.ex.setup = append(s.ex.setup, s.line) + s.ex.setup = append(s.ex.setup, Line{s.num, s.line}) return setupState } @@ -115,12 +141,12 @@ func teardownState(s *Scanner) stateFn { case TEARDOWN: return errorState case TEST: - s.ex.appendTest(data) + s.ex.appendTest(data, s.num) return testState } } - s.ex.teardown = append(s.ex.teardown, s.line) + s.ex.teardown = append(s.ex.teardown, Line{s.num, s.line}) return teardownState } @@ -132,7 +158,7 @@ func testState(s *Scanner) stateFn { case TEARDOWN: return errorState case TEST: - s.ex.appendTest(data) + s.ex.appendTest(data, s.num) return testState } } @@ -144,20 +170,22 @@ func testState(s *Scanner) stateFn { } if assertion := parseSingleAssertion(s.line); len(assertion) > 0 { - stmt := test.statements[len(test.statements)-1] - stmt.Expectation = assertion + exp := s.ex.currentExpectation() + *exp = []Line{{s.num, assertion}} return testState } - test.statements = append(test.statements, &Statement{ - Code: s.line, + test.Statements = append(test.Statements, &Statement{ + Code: Line{s.num, s.line}, }) return testState } +// TODO check that all lines are sharing the same amount of space, if yes +// trim them, so everything is aligned perfectly in the resulting test file. func multilineAssertionState(s *Scanner) stateFn { - re := regexp.MustCompile(`^\s*` + commentPrefix + `\s*(.*)`) + re := regexp.MustCompile(`^\s*` + commentPrefix + `(.*)`) matches := re.FindStringSubmatch(s.line) if matches == nil { @@ -166,12 +194,13 @@ func multilineAssertionState(s *Scanner) stateFn { code := strings.TrimRight(matches[1], " \t") - if code == "```" { + if strings.TrimSpace(matches[1]) == "```" { return testState } - test := s.ex.currentTest() - test.statements[len(test.statements)-1].Expectation += code + "\n" + exp := s.ex.currentExpectation() + *exp = append(*exp, Line{s.num, code}) + return multilineAssertionState } @@ -185,6 +214,8 @@ func (s *Scanner) Run(io *bufio.Scanner) *Examplar { for state := initialState; io.Scan(); { s.line = io.Text() s.line = strings.TrimSpace(s.line) + s.num++ + if s.line == "" { continue } @@ -235,7 +266,7 @@ func Parse(r io.Reader, name string) (*Examplar, error) { scanner := &Scanner{} ex := scanner.Run(bufio.NewScanner(r)) - ex.name = name + ex.Name = name return ex, nil } @@ -250,17 +281,17 @@ func Generate(ex *Examplar, w io.Writer) error { tmpl := template.Must(template.ParseFiles("test_template.go.tmpl")) bindings := struct { - Package string - TestName string - Setup string - Teardown string - Statements []*Statement + Package string + TestName string + Setup []Line + Teardown []Line + Tests []*Test }{ - "integration_test", - normalizeTestName(ex.examples[0].name), - strings.Join(ex.setup, "\n"), - strings.Join(ex.teardown, "\n"), - ex.examples[0].statements, + "main", + normalizeTestName("Foo Bar"), + ex.setup, + ex.teardown, + ex.examples, } return tmpl.Execute(w, bindings) diff --git a/cmd/examplar/test_template.go.tmpl b/cmd/examplar/test_template.go.tmpl index e11816d8f..57a65a063 100644 --- a/cmd/examplar/test_template.go.tmpl +++ b/cmd/examplar/test_template.go.tmpl @@ -1,42 +1,72 @@ package {{ .Package }} import ( - "encoding/json" - "testing" + "encoding/json" + "testing" - "github.com/genjidb/genji" - "github.com/stretchr/testify/require" + "github.com/genjidb/genji" + "github.com/genjidb/genji/document" + "github.com/stretchr/testify/require" ) func Test{{ .TestName }}(t *testing.T) { - db, err := genji.Open(":memory:") - require.NoError(t, err) + db, err := genji.Open(":memory:") + require.NoError(t, err) + defer db.Close() - // teardown extest1.sql:4 - t.Cleanup(func() { - err := db.Exec("{{ .Teardown }}") - require.NoError(t, err) - defer db.Close() - }) + // teardown + teardown := func() { + var err error + {{- range .Teardown }} + err = db.Exec("{{ .Text }}") // orig:{{ .Num }} + require.NoError(t, err) + {{- end }} + } - // setup extest1.sql:1 - err = db.Exec("{{ .Setup }}") - require.NoError(t, err) - {{- range .Statements }} - {{- if .Expectation }} - // extest1.sql:9 - doc, err := db.QueryDocument("{{ .Code }}") - require.NoError(t, err) + // setup + setup := func() { + {{- range .Setup }} + err = db.Exec("{{ .Text }}") // orig:{{ .Num }} + require.NoError(t, err) + {{- end }} + } + {{ "" }} + {{- range .Tests }} + // orig:{{ .Num }} + t.Run("{{ .Name }}", func(t *testing.T) { + t.Cleanup(teardown) + setup() - data, err := json.Marshal(doc) - require.NoError(t, err) + var err error + var doc document.Document + var data []byte + var expected string + {{ "" }} + {{- range .Statements }} + {{- if gt (len .Expectation) 0 }} + doc, err = db.QueryDocument("{{ .Code.Text }}") // orig:{{ .Code.Num }} + require.NoError(t, err) - expected := `{{ .Expectation }}` - require.JSONEq(t, expected, string(data)) - {{- else}} - // extest1.sql:8 - err = db.Exec("{{ .Code }}") - require.NoError(t, err) + data, err = json.Marshal(doc) + require.NoError(t, err) + {{ "" }} + {{- if gt (len .Expectation) 1 }} + // orig: {{ (index .Expectation 0).Num }} + expected = ` + {{- range .Expectation }} + {{ .Text }} + {{- end }} + ` + {{- else }} + expected = `{{ (index .Expectation 0).Text }}` // orig:{{ (index .Expectation 0).Num }} + {{- end }} + require.JSONEq(t, expected, string(data)) + {{- else}} + err = db.Exec("{{ .Code.Text }}") // orig:{{ .Code.Num }} + require.NoError(t, err) + {{- end }} + {{- end }} + }) + {{ "" }} {{- end }} - {{- end }} -} + } From 4a8e8836ffef3ac7de9325941f612a5c62544111 Mon Sep 17 00:00:00 2001 From: Jean-Hadrien Chabran Date: Fri, 9 Apr 2021 10:57:26 +0200 Subject: [PATCH 05/10] Fix indentation --- cmd/examplar/examplar_test.go | 6 ++---- cmd/examplar/extest1_test.go.gold | 2 +- cmd/examplar/test_template.go.tmpl | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/cmd/examplar/examplar_test.go b/cmd/examplar/examplar_test.go index fdc5c92ef..0e2e2b3ea 100644 --- a/cmd/examplar/examplar_test.go +++ b/cmd/examplar/examplar_test.go @@ -77,11 +77,9 @@ func TestTemplate(t *testing.T) { err = Generate(ex, &b) require.NoError(t, err) - // some code to generate the gold version - // o, err := os.OpenFile("trace_test.go", os.O_CREATE|os.O_WRONLY, 0777) + // some code to generate the gold version if needed + // err = ioutil.WriteFile("trace_test.go", []byte(b.String()), 0644) // require.NoError(t, err) - // o.WriteString(b.String()) - // defer o.Close() require.Equal(t, strings.Split(gold, "\n"), strings.Split(b.String(), "\n")) } diff --git a/cmd/examplar/extest1_test.go.gold b/cmd/examplar/extest1_test.go.gold index dfbfc40fb..6ffa60c85 100644 --- a/cmd/examplar/extest1_test.go.gold +++ b/cmd/examplar/extest1_test.go.gold @@ -25,7 +25,7 @@ func TestFooBar(t *testing.T) { setup := func() { err = db.Exec("CREATE TABLE foo (a int);") // orig:2 require.NoError(t, err) - } + } // orig:7 t.Run("insert something", func(t *testing.T) { diff --git a/cmd/examplar/test_template.go.tmpl b/cmd/examplar/test_template.go.tmpl index 57a65a063..d4ccfd55a 100644 --- a/cmd/examplar/test_template.go.tmpl +++ b/cmd/examplar/test_template.go.tmpl @@ -29,7 +29,7 @@ func Test{{ .TestName }}(t *testing.T) { err = db.Exec("{{ .Text }}") // orig:{{ .Num }} require.NoError(t, err) {{- end }} - } + } {{ "" }} {{- range .Tests }} // orig:{{ .Num }} From cda782c9476501b1cd6fc1651006d77d19059602 Mon Sep 17 00:00:00 2001 From: Jean-Hadrien Chabran Date: Fri, 9 Apr 2021 10:57:39 +0200 Subject: [PATCH 06/10] Add README and gitignore --- cmd/examplar/.gitignore | 2 + cmd/examplar/README.md | 121 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 cmd/examplar/.gitignore create mode 100644 cmd/examplar/README.md diff --git a/cmd/examplar/.gitignore b/cmd/examplar/.gitignore new file mode 100644 index 000000000..b7af683f2 --- /dev/null +++ b/cmd/examplar/.gitignore @@ -0,0 +1,2 @@ +examplar +trace_test.go diff --git a/cmd/examplar/README.md b/cmd/examplar/README.md new file mode 100644 index 000000000..c22ad9a59 --- /dev/null +++ b/cmd/examplar/README.md @@ -0,0 +1,121 @@ +# Examplar + +A tool to generate tests from on an annotated SQL file that specifies an example and its expected result. + +A file named `selecting.sql` + +```sql +--- setup: +CREATE TABLE foo; +--- teardown: +DROP TABLE foo; +--- test: insert something +INSERT INTO foo (a) VALUES (1); +SELECT * FROM foo; +--- `{"a": 1}` +``` + +Gets transformed into (abbreviated): + +```go +func TestSelecting(t *testing.T) { + db, err := genji.Open(":memory:") + + teardown := func() { + db.Exec("DROP TABLE foo;") + } + + setup := func() { + err = db.Exec("CREATE TABLE foo (a int);") + } + + t.Run("insert something", func(t *testing.T) { + t.Cleanup(teardown) + setup() + + err := db.Exec("INSERT INTO foo (a) VALUES (1);") + require.NoError(t, err) + + doc, err = db.QueryDocument("SELECT * FROM foo;") + require.NoError(t, err) + + data, err = json.Marshal(doc) + require.NoError(t, err) + expected = `{"a": 1}` + require.JSONEq(t, expected, string(data)) + } +} +``` + +## Usage + +To populate tests living in the `integration` package, create a go file in folder named `integration` with the following code: + +```go +package integration + +// go:generate go run examplar.go -- -package=integration fixtures/sql/* +``` + +Building the code will generate tests files in the `integration` folder. Those tests files can be run like any normal tests. + +## How it works + +Examplar reads a SQL file, line by line (1), looking either for raw text or annotations that specifies what those lines are supposed to do from a testing perpective. + +Once a SQL file has been parsed, it generates a `(...)_test.go` file that looks similar to the handwritten test that would have been written. + +Every original line in the input SQL file is executed and expected to not return any error. + +Those test files have no dependencies on Examplar or the SQL file that has been used to generate the test. + +### Annotations + +Annotations starts with `---` (the SQL comment `--` and an additional `-`) and can +be followed by a keyword specified in the list below followed by a `:` or special symbols to pass data to set expectations. + +- `setup:` + + - all lines up to the next annotation are to be considered as individual statements for the setup block. + +- `teardown:` + - all lines up to the next annotation are to be considered as individual statements for the teardown block. + +:bulb: `setup` and `teardown` blocks will generate code being ran around **each indidual** `test` block. +They are optional and can be declared in no particular order, but there can only be one of each. + + \_ + +- `test: [TEST NAME]` + - all lines up to the next annotation are to be considered as individual statements for this test block. Each line can be followed by the special expectation annotation. + +:bulb: Each `test` block will generate an individual `t.Run("[TEST NAME]", ...)` function. At least one test block must be present. + +- `` `[JSON]` `` + + - the previous line evaluation result will be compared to the given `[JSON]` value. + - Invalid JSON won't yield an error ar generate time, but the generated test will always fail at runtime. + +- ` ``` ` + - all the following lines until another triple backtick annoattion is found are to be considered as a multiline JSON data. + - The line preceding the the opening triple backticks will be evaluation result will be compared to the given JSON data. + - Indentation will be preserved in the generating test file for readablilty. Similarly, invalid JSON will only yield an error when the resulting test is evaluated. + +## Goals and non-goals + +Examplar objective is to provide a clear and simple way to write example of Genji SQL code and its expected results. It has to be easy enough for anyone to edit or write an example SQL file without having to read the present documentation. If this objective succeeds, it opens the path Examplar SQL files being used by users and contributors to showcase a bug or a feature request in a Github issue. + +By being totally independent from Genji itself for the parsing part, it frees itself from needing to be updated when something changes under the hood and allows to keep the code as merely parsing textual data. In other words, if anything changes in Genji on how to execute queries, only the template needs to be updated. + +## Limitations + +A breaking change in the API has only +For now, let's observe how useful Examplar can and what we can make out of it. +Then we can then see if it's worth addressing the following limitations: + +- (1) Multilines SQL statements are not yet supported. + A potential solutions would be to introduce a reducing function, that would multilines statement by the final `;` character on the last line of the statement. + +- Expecting an error instead of JSON is not supported. + +- Error messages on failed expectations do not reference the orignal file directly, which could be useful on complex examples sql files. From f70d8b5863c584aeb3ade0f64faede636b5614d2 Mon Sep 17 00:00:00 2001 From: Jean-Hadrien Chabran Date: Fri, 9 Apr 2021 11:47:16 +0200 Subject: [PATCH 07/10] Implement the CLI interface --- cmd/examplar/.gitignore | 1 + cmd/examplar/README.md | 22 ++- cmd/examplar/examplar.go | 115 ++++++++++++ cmd/examplar/examplar_test.go | 10 +- cmd/examplar/main.go | 321 ++++++---------------------------- cmd/examplar/scanner.go | 196 +++++++++++++++++++++ 6 files changed, 385 insertions(+), 280 deletions(-) create mode 100644 cmd/examplar/examplar.go create mode 100644 cmd/examplar/scanner.go diff --git a/cmd/examplar/.gitignore b/cmd/examplar/.gitignore index b7af683f2..64a5e0c9b 100644 --- a/cmd/examplar/.gitignore +++ b/cmd/examplar/.gitignore @@ -1,2 +1,3 @@ examplar trace_test.go +integration diff --git a/cmd/examplar/README.md b/cmd/examplar/README.md index c22ad9a59..a50330776 100644 --- a/cmd/examplar/README.md +++ b/cmd/examplar/README.md @@ -49,15 +49,29 @@ func TestSelecting(t *testing.T) { ## Usage -To populate tests living in the `integration` package, create a go file in folder named `integration` with the following code: +To populate tests that would be in an `integration` package, create a go file in folder named `integration` with the following code: ```go package integration -// go:generate go run examplar.go -- -package=integration fixtures/sql/* +//go:generate examplar -package=integration fixtures/sql/*.sql ``` -Building the code will generate tests files in the `integration` folder. Those tests files can be run like any normal tests. +Assuming `fixtures/sql/` contains the following files: + +``` +extest1.sql +extest2.sql +``` + +Running `go generate` will generate these tests files in the `integration` folder: + +``` +extest1_test.go +extest2_test.go +``` + +Those tests files can be run like any normal tests. ## How it works @@ -84,8 +98,6 @@ be followed by a keyword specified in the list below followed by a `:` or specia :bulb: `setup` and `teardown` blocks will generate code being ran around **each indidual** `test` block. They are optional and can be declared in no particular order, but there can only be one of each. - \_ - - `test: [TEST NAME]` - all lines up to the next annotation are to be considered as individual statements for this test block. Each line can be followed by the special expectation annotation. diff --git a/cmd/examplar/examplar.go b/cmd/examplar/examplar.go new file mode 100644 index 000000000..dc6dd5fca --- /dev/null +++ b/cmd/examplar/examplar.go @@ -0,0 +1,115 @@ +package main + +import ( + "bufio" + "io" + "strings" + "text/template" +) + +type Line struct { + Num int + Text string +} + +// Test is a list of statements. +type Test struct { + Name string + Num int + Statements []*Statement +} + +// Statement is a pair composed of a line of code and an expectation on its result when evaluated. +type Statement struct { + Code Line + Expectation []Line +} + +func (s Statement) expectationText() string { + var text string + for _, e := range s.Expectation { + text += e.Text + "\n" + } + + return text +} + +// Examplar represents a group of tests and can optionally include setup code and teardown code. +type Examplar struct { + Name string + setup []Line + teardown []Line + examples []*Test +} + +// HasSetup returns true if setup code is provided. +func (ex *Examplar) HasSetup() bool { + return len(ex.setup) > 0 +} + +// HasSetup returns true if teardown code is provided. +func (ex *Examplar) HasTeardown() bool { + return len(ex.teardown) > 0 +} + +func (ex *Examplar) appendTest(name string, num int) { + ex.examples = append(ex.examples, &Test{ + Name: name, + Num: num, + }) +} + +func (ex *Examplar) currentTest() *Test { + return ex.examples[len(ex.examples)-1] +} + +func (ex *Examplar) currentStatement() *Statement { + test := ex.currentTest() + return test.Statements[len(test.Statements)-1] +} + +func (ex *Examplar) currentExpectation() *[]Line { + return &ex.currentStatement().Expectation +} + +// Parse reads annotated textual data and transforms it into a +// structured representation. Only annotations are parsed, the +// textual data itself is irrelevant to this function. +// +// It will panic if an error is encountered. +func Parse(r io.Reader, name string) *Examplar { + scanner := &Scanner{} + + ex := scanner.Run(bufio.NewScanner(r)) + ex.Name = name + + return ex +} + +func normalizeTestName(name string) string { + name = strings.TrimSpace(name) + name = strings.Title(name) + return strings.ReplaceAll(name, " ", "") +} + +// Generate takes a structured representation of the original textual data in order +// to write a valid go test file. +func Generate(ex *Examplar, packageName string, w io.Writer) error { + tmpl := template.Must(template.ParseFS(tmplFS, "test_template.go.tmpl")) + + bindings := struct { + Package string + TestName string + Setup []Line + Teardown []Line + Tests []*Test + }{ + packageName, + normalizeTestName(ex.Name), + ex.setup, + ex.teardown, + ex.examples, + } + + return tmpl.Execute(w, bindings) +} diff --git a/cmd/examplar/examplar_test.go b/cmd/examplar/examplar_test.go index 0e2e2b3ea..008d85970 100644 --- a/cmd/examplar/examplar_test.go +++ b/cmd/examplar/examplar_test.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "io/ioutil" "os" "strings" @@ -15,8 +14,7 @@ func TestParse(t *testing.T) { require.NoError(t, err) defer f.Close() - ex, err := Parse(f, "extest1") - require.NoError(t, err) + ex := Parse(f, "extest1") require.Equal(t, []Line{{2, "CREATE TABLE foo (a int);"}}, ex.setup) require.Equal(t, []Line{{5, "DROP TABLE foo;"}}, ex.teardown) @@ -35,7 +33,6 @@ func TestParse(t *testing.T) { stmt = example.Statements[2] require.Equal(t, "SELECT a, b FROM foo;", stmt.Code.Text) - fmt.Println("---", len(stmt.Expectation)) require.JSONEq(t, `{"a": 1, "b": null}`, stmt.expectationText()) stmt = example.Statements[3] @@ -69,12 +66,11 @@ func TestTemplate(t *testing.T) { require.NoError(t, err) defer f.Close() - ex, err := Parse(f, "extest1") - require.NoError(t, err) + ex := Parse(f, "foo bar") var b strings.Builder - err = Generate(ex, &b) + err = Generate(ex, "main", &b) require.NoError(t, err) // some code to generate the gold version if needed diff --git a/cmd/examplar/main.go b/cmd/examplar/main.go index 4c04d0052..50c98e707 100644 --- a/cmd/examplar/main.go +++ b/cmd/examplar/main.go @@ -1,301 +1,86 @@ package main import ( - "bufio" - "io" - "regexp" + "embed" + "flag" + "fmt" + "log" + "os" + "path" + "path/filepath" "strings" - "text/template" ) -const commentPrefix = "---" +//go:embed "test_template.go.tmpl" +var tmplFS embed.FS -type Tag int +var packageName string -const ( - UNKNOWN Tag = iota - SETUP - TEARDOWN - TEST -) - -type Line struct { - Num int - Text string -} - -// Test is a list of statements. -type Test struct { - Name string - Num int - Statements []*Statement -} - -// Statement is a pair composed of a line of code and an expectation on its result when evaluated. -type Statement struct { - Code Line - Expectation []Line -} - -func (s Statement) expectationText() string { - var text string - for _, e := range s.Expectation { - text += e.Text + "\n" +func init() { + flag.StringVar(&packageName, "package", "", "package name for the generated files") + flag.Usage = func() { + fmt.Println("Usage: ./examplar -package=[NAME] input1 input2 ...") } - - return text -} - -// Examplar represents a group of tests and can optionally include setup code and teardown code. -type Examplar struct { - Name string - setup []Line - teardown []Line - examples []*Test -} - -// HasSetup returns true if setup code is provided. -func (ex *Examplar) HasSetup() bool { - return len(ex.setup) > 0 -} - -// HasSetup returns true if teardown code is provided. -func (ex *Examplar) HasTeardown() bool { - return len(ex.teardown) > 0 -} - -func (ex *Examplar) appendTest(name string, num int) { - ex.examples = append(ex.examples, &Test{ - Name: name, - Num: num, - }) -} - -func (ex *Examplar) currentTest() *Test { - return ex.examples[len(ex.examples)-1] } -func (ex *Examplar) currentStatement() *Statement { - test := ex.currentTest() - return test.Statements[len(test.Statements)-1] -} - -func (ex *Examplar) currentExpectation() *[]Line { - return &ex.currentStatement().Expectation -} - -type stateFn func(*Scanner) stateFn - -type Scanner struct { - line string - num int - ex *Examplar -} +func main() { + flag.Parse() -func initialState(s *Scanner) stateFn { - if tag, data := parseTag(s.line); tag != UNKNOWN { - switch tag { - case SETUP: - return setupState - case TEARDOWN: - return teardownState - case TEST: - s.ex.appendTest(data, s.num) - return testState - } + if packageName == "" { + flag.Usage() + os.Exit(-1) } - return initialState -} - -func setupState(s *Scanner) stateFn { - if tag, data := parseTag(s.line); tag != UNKNOWN { - switch tag { - case SETUP: - return errorState - case TEARDOWN: - if s.ex.HasTeardown() { - return errorState - } else { - return teardownState - } - case TEST: - s.ex.appendTest(data, s.num) - return testState - } + paths := os.Args[2:] + fmt.Println(paths) + if len(paths) < 1 { + flag.Usage() + os.Exit(-1) } - s.ex.setup = append(s.ex.setup, Line{s.num, s.line}) - return setupState -} - -func teardownState(s *Scanner) stateFn { - if tag, data := parseTag(s.line); tag != UNKNOWN { - switch tag { - case SETUP: - if s.ex.HasSetup() { - return errorState - } else { - return setupState - } - case TEARDOWN: - return errorState - case TEST: - s.ex.appendTest(data, s.num) - return testState + for _, p := range paths { + // use globs because when invoked from go generate, there will be no shell + // to expand it for us + gpaths, err := filepath.Glob(p) + if err != nil { + log.Fatal(err) } - } - - s.ex.teardown = append(s.ex.teardown, Line{s.num, s.line}) - return teardownState -} -func testState(s *Scanner) stateFn { - if tag, data := parseTag(s.line); tag != UNKNOWN { - switch tag { - case SETUP: - return errorState - case TEARDOWN: - return errorState - case TEST: - s.ex.appendTest(data, s.num) - return testState + if len(gpaths) < 1 { + log.Fatalf("%s does not exist", p) } - } - test := s.ex.currentTest() - - if hasMultilineAssertionTag(s.line) { - return multilineAssertionState - } - - if assertion := parseSingleAssertion(s.line); len(assertion) > 0 { - exp := s.ex.currentExpectation() - *exp = []Line{{s.num, assertion}} - return testState - } - - test.Statements = append(test.Statements, &Statement{ - Code: Line{s.num, s.line}, - }) - - return testState -} - -// TODO check that all lines are sharing the same amount of space, if yes -// trim them, so everything is aligned perfectly in the resulting test file. -func multilineAssertionState(s *Scanner) stateFn { - re := regexp.MustCompile(`^\s*` + commentPrefix + `(.*)`) - matches := re.FindStringSubmatch(s.line) - - if matches == nil { - return multilineAssertionState - } - - code := strings.TrimRight(matches[1], " \t") - - if strings.TrimSpace(matches[1]) == "```" { - return testState - } - - exp := s.ex.currentExpectation() - *exp = append(*exp, Line{s.num, code}) - - return multilineAssertionState -} - -func errorState(s *Scanner) stateFn { - panic(s.line) -} - -func (s *Scanner) Run(io *bufio.Scanner) *Examplar { - s.ex = &Examplar{} - - for state := initialState; io.Scan(); { - s.line = io.Text() - s.line = strings.TrimSpace(s.line) - s.num++ - - if s.line == "" { - continue + for _, gp := range gpaths { + err := genFile(gp, packageName) + if err != nil { + log.Fatal(err) + } } - state = state(s) } - - return s.ex } -func parseTag(line string) (Tag, string) { - re := regexp.MustCompile(`^\s*` + commentPrefix + `\s*(\w+):\s*(.*)`) - matches := re.FindStringSubmatch(line) - if matches == nil { - return UNKNOWN, "" +func genFile(p string, packageName string) error { + in, err := os.Open(p) + if err != nil { + return err } + defer in.Close() - var tag Tag - switch strings.ToLower(matches[1]) { - case "setup": - tag = SETUP - case "teardown": - tag = TEARDOWN - case "test": - tag = TEST - default: - return UNKNOWN, "" - } + base := path.Base(p) + name := strings.TrimSuffix(base, path.Ext(p)) - return tag, matches[2] -} + ex := Parse(in, name) -func parseSingleAssertion(line string) string { - re := regexp.MustCompile(`^\s*` + commentPrefix + `\s*` + "`" + `([^` + "`" + `]+)`) - matches := re.FindStringSubmatch(line) - if matches == nil { - return "" + out, err := os.OpenFile(name+"_test.go", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + if err != nil { + return err } - return matches[1] -} - -func hasMultilineAssertionTag(line string) bool { - re := regexp.MustCompile(`^\s*` + commentPrefix + `\s*` + "```" + `(\w*)`) - matches := re.FindStringSubmatch(line) - return matches != nil -} - -func Parse(r io.Reader, name string) (*Examplar, error) { - scanner := &Scanner{} + defer out.Close() - ex := scanner.Run(bufio.NewScanner(r)) - ex.Name = name - - return ex, nil -} - -func normalizeTestName(name string) string { - name = strings.TrimSpace(name) - name = strings.Title(name) - return strings.ReplaceAll(name, " ", "") -} - -func Generate(ex *Examplar, w io.Writer) error { - tmpl := template.Must(template.ParseFiles("test_template.go.tmpl")) - - bindings := struct { - Package string - TestName string - Setup []Line - Teardown []Line - Tests []*Test - }{ - "main", - normalizeTestName("Foo Bar"), - ex.setup, - ex.teardown, - ex.examples, + err = Generate(ex, packageName, out) + if err != nil { + return err } - return tmpl.Execute(w, bindings) -} - -func main() { + return nil } diff --git a/cmd/examplar/scanner.go b/cmd/examplar/scanner.go new file mode 100644 index 000000000..0401b0824 --- /dev/null +++ b/cmd/examplar/scanner.go @@ -0,0 +1,196 @@ +package main + +import ( + "bufio" + "regexp" + "strings" +) + +const commentPrefix = "---" + +type Tag int + +const ( + UNKNOWN Tag = iota + SETUP + TEARDOWN + TEST +) + +type stateFn func(*Scanner) stateFn + +type Scanner struct { + line string + num int + ex *Examplar +} + +func initialState(s *Scanner) stateFn { + if tag, data := parseTag(s.line); tag != UNKNOWN { + switch tag { + case SETUP: + return setupState + case TEARDOWN: + return teardownState + case TEST: + s.ex.appendTest(data, s.num) + return testState + } + } + + return initialState +} + +func setupState(s *Scanner) stateFn { + if tag, data := parseTag(s.line); tag != UNKNOWN { + switch tag { + case SETUP: + return errorState + case TEARDOWN: + if s.ex.HasTeardown() { + return errorState + } else { + return teardownState + } + case TEST: + s.ex.appendTest(data, s.num) + return testState + } + } + + s.ex.setup = append(s.ex.setup, Line{s.num, s.line}) + return setupState +} + +func teardownState(s *Scanner) stateFn { + if tag, data := parseTag(s.line); tag != UNKNOWN { + switch tag { + case SETUP: + if s.ex.HasSetup() { + return errorState + } else { + return setupState + } + case TEARDOWN: + return errorState + case TEST: + s.ex.appendTest(data, s.num) + return testState + } + } + + s.ex.teardown = append(s.ex.teardown, Line{s.num, s.line}) + return teardownState +} + +func testState(s *Scanner) stateFn { + if tag, data := parseTag(s.line); tag != UNKNOWN { + switch tag { + case SETUP: + return errorState + case TEARDOWN: + return errorState + case TEST: + s.ex.appendTest(data, s.num) + return testState + } + } + + test := s.ex.currentTest() + + if hasMultilineAssertionTag(s.line) { + return multilineAssertionState + } + + if assertion := parseSingleAssertion(s.line); len(assertion) > 0 { + exp := s.ex.currentExpectation() + *exp = []Line{{s.num, assertion}} + return testState + } + + test.Statements = append(test.Statements, &Statement{ + Code: Line{s.num, s.line}, + }) + + return testState +} + +// TODO check that all lines are sharing the same amount of space, if yes +// trim them, so everything is aligned perfectly in the resulting test file. +func multilineAssertionState(s *Scanner) stateFn { + re := regexp.MustCompile(`^\s*` + commentPrefix + `(.*)`) + matches := re.FindStringSubmatch(s.line) + + if matches == nil { + return multilineAssertionState + } + + code := strings.TrimRight(matches[1], " \t") + + if strings.TrimSpace(matches[1]) == "```" { + return testState + } + + exp := s.ex.currentExpectation() + *exp = append(*exp, Line{s.num, code}) + + return multilineAssertionState +} + +func errorState(s *Scanner) stateFn { + panic(s.line) +} + +func (s *Scanner) Run(io *bufio.Scanner) *Examplar { + s.ex = &Examplar{} + + for state := initialState; io.Scan(); { + s.line = io.Text() + s.line = strings.TrimSpace(s.line) + s.num++ + + if s.line == "" { + continue + } + state = state(s) + } + + return s.ex +} + +func parseTag(line string) (Tag, string) { + re := regexp.MustCompile(`^\s*` + commentPrefix + `\s*(\w+):\s*(.*)`) + matches := re.FindStringSubmatch(line) + if matches == nil { + return UNKNOWN, "" + } + + var tag Tag + switch strings.ToLower(matches[1]) { + case "setup": + tag = SETUP + case "teardown": + tag = TEARDOWN + case "test": + tag = TEST + default: + return UNKNOWN, "" + } + + return tag, matches[2] +} + +func parseSingleAssertion(line string) string { + re := regexp.MustCompile(`^\s*` + commentPrefix + `\s*` + "`" + `([^` + "`" + `]+)`) + matches := re.FindStringSubmatch(line) + if matches == nil { + return "" + } + return matches[1] +} + +func hasMultilineAssertionTag(line string) bool { + re := regexp.MustCompile(`^\s*` + commentPrefix + `\s*` + "```" + `(\w*)`) + matches := re.FindStringSubmatch(line) + return matches != nil +} From c7a893a2a31caf424e0c65d3cd4870b36299e79d Mon Sep 17 00:00:00 2001 From: Jean-Hadrien Chabran Date: Mon, 12 Apr 2021 20:03:36 +0200 Subject: [PATCH 08/10] Make statement multilines --- cmd/examplar/README.md | 35 ++++++------ cmd/examplar/examplar.go | 31 +++++----- cmd/examplar/examplar_test.go | 26 ++++----- cmd/examplar/extest1.sql | 10 ++-- cmd/examplar/extest1_test.go.gold | 90 ++++++++++++++++++++++-------- cmd/examplar/scanner.go | 30 ++++++---- cmd/examplar/test_template.go.tmpl | 53 +++++++++++++----- 7 files changed, 178 insertions(+), 97 deletions(-) diff --git a/cmd/examplar/README.md b/cmd/examplar/README.md index a50330776..4019a2e9f 100644 --- a/cmd/examplar/README.md +++ b/cmd/examplar/README.md @@ -12,7 +12,7 @@ DROP TABLE foo; --- test: insert something INSERT INTO foo (a) VALUES (1); SELECT * FROM foo; ---- `{"a": 1}` +--- `[{"a": 1}]` ``` Gets transformed into (abbreviated): @@ -33,15 +33,16 @@ func TestSelecting(t *testing.T) { t.Cleanup(teardown) setup() - err := db.Exec("INSERT INTO foo (a) VALUES (1);") + res, err := db.Query( + "INSERT INTO foo (a) VALUES (1);" + + "SELECT * FROM foo;" + ) require.NoError(t, err) + defer res.Close() - doc, err = db.QueryDocument("SELECT * FROM foo;") - require.NoError(t, err) + data := jsonResult(t, res) - data, err = json.Marshal(doc) - require.NoError(t, err) - expected = `{"a": 1}` + expected = `[{"a": 1}]` require.JSONEq(t, expected, string(data)) } } @@ -75,7 +76,7 @@ Those tests files can be run like any normal tests. ## How it works -Examplar reads a SQL file, line by line (1), looking either for raw text or annotations that specifies what those lines are supposed to do from a testing perpective. +Examplar reads a SQL file, looking either for raw text or annotations that specifies what those lines are supposed to do from a testing perpective. Once a SQL file has been parsed, it generates a `(...)_test.go` file that looks similar to the handwritten test that would have been written. @@ -90,28 +91,28 @@ be followed by a keyword specified in the list below followed by a `:` or specia - `setup:` - - all lines up to the next annotation are to be considered as individual statements for the setup block. + - all lines up to the next annotation are to be considered as one single statement for the setup block. - `teardown:` - - all lines up to the next annotation are to be considered as individual statements for the teardown block. + - all lines up to the next annotation are to be considered as single statement for the teardown block. :bulb: `setup` and `teardown` blocks will generate code being ran around **each indidual** `test` block. They are optional and can be declared in no particular order, but there can only be one of each. - `test: [TEST NAME]` - - all lines up to the next annotation are to be considered as individual statements for this test block. Each line can be followed by the special expectation annotation. + - a test is composed of one or many statements; a statement composed of one or multiple lines and is terminated by an expectation (see below). :bulb: Each `test` block will generate an individual `t.Run("[TEST NAME]", ...)` function. At least one test block must be present. - `` `[JSON]` `` - - the previous line evaluation result will be compared to the given `[JSON]` value. + - the statement above this annotation will be compared to `[JSON]` when evaluated at the runtime. - Invalid JSON won't yield an error ar generate time, but the generated test will always fail at runtime. - ` ``` ` - - all the following lines until another triple backtick annoattion is found are to be considered as a multiline JSON data. - - The line preceding the the opening triple backticks will be evaluation result will be compared to the given JSON data. - - Indentation will be preserved in the generating test file for readablilty. Similarly, invalid JSON will only yield an error when the resulting test is evaluated. + - the statement above this annotation will be compared to `[JSON]` when evaluated at the runtime. + - all the following lines until another triple backtick annoattion is found are to be considered as part of a single multiline JSON data. + - indentation will be preserved in the generating test file for readablilty. Similarly, invalid JSON will only yield an error when the resulting test is evaluated. ## Goals and non-goals @@ -125,9 +126,5 @@ A breaking change in the API has only For now, let's observe how useful Examplar can and what we can make out of it. Then we can then see if it's worth addressing the following limitations: -- (1) Multilines SQL statements are not yet supported. - A potential solutions would be to introduce a reducing function, that would multilines statement by the final `;` character on the last line of the statement. - - Expecting an error instead of JSON is not supported. - - Error messages on failed expectations do not reference the orignal file directly, which could be useful on complex examples sql files. diff --git a/cmd/examplar/examplar.go b/cmd/examplar/examplar.go index dc6dd5fca..700ffe145 100644 --- a/cmd/examplar/examplar.go +++ b/cmd/examplar/examplar.go @@ -21,7 +21,7 @@ type Test struct { // Statement is a pair composed of a line of code and an expectation on its result when evaluated. type Statement struct { - Code Line + Code []Line Expectation []Line } @@ -52,25 +52,28 @@ func (ex *Examplar) HasTeardown() bool { return len(ex.teardown) > 0 } -func (ex *Examplar) appendTest(name string, num int) { - ex.examples = append(ex.examples, &Test{ +func (ex *Examplar) appendTest(name string, num int) *Test { + test := Test{ Name: name, Num: num, - }) -} + } + ex.examples = append(ex.examples, &test) -func (ex *Examplar) currentTest() *Test { - return ex.examples[len(ex.examples)-1] + return &test } -func (ex *Examplar) currentStatement() *Statement { - test := ex.currentTest() - return test.Statements[len(test.Statements)-1] -} +// func (ex *Examplar) currentTest() *Test { +// return ex.examples[len(ex.examples)-1] +// } -func (ex *Examplar) currentExpectation() *[]Line { - return &ex.currentStatement().Expectation -} +// func (ex *Examplar) currentStatement() *Statement { +// test := ex.currentTest() +// return test.Statements[len(test.Statements)-1] +// } + +// func (ex *Examplar) currentExpectation() *[]Line { +// return &ex.currentStatement().Expectation +// } // Parse reads annotated textual data and transforms it into a // structured representation. Only annotations are parsed, the diff --git a/cmd/examplar/examplar_test.go b/cmd/examplar/examplar_test.go index 008d85970..d0403e878 100644 --- a/cmd/examplar/examplar_test.go +++ b/cmd/examplar/examplar_test.go @@ -23,33 +23,31 @@ func TestParse(t *testing.T) { example := ex.examples[0] require.NotNil(t, example) require.Equal(t, "insert something", example.Name) + require.Equal(t, 3, len(example.Statements)) stmt := example.Statements[0] - require.Equal(t, "INSERT INTO foo (a) VALUES (1);", stmt.Code.Text) + require.Equal(t, "INSERT INTO foo (a) VALUES (1);", stmt.Code[0].Text) + require.Equal(t, "SELECT * FROM foo;", stmt.Code[1].Text) + require.Equal(t, `[{"a": 1}]`, stmt.Expectation[0].Text) stmt = example.Statements[1] - require.Equal(t, "SELECT * FROM foo;", stmt.Code.Text) - require.Equal(t, `{"a": 1}`, stmt.Expectation[0].Text) + require.Equal(t, "SELECT a, b FROM foo;", stmt.Code[0].Text) + require.JSONEq(t, `[{"a": 1, "b": null}]`, stmt.expectationText()) stmt = example.Statements[2] - require.Equal(t, "SELECT a, b FROM foo;", stmt.Code.Text) - require.JSONEq(t, `{"a": 1, "b": null}`, stmt.expectationText()) - - stmt = example.Statements[3] - require.Equal(t, "SELECT z FROM foo;", stmt.Code.Text) - require.Equal(t, `{"z": null}`, stmt.Expectation[0].Text) + require.Equal(t, "SELECT z FROM foo;", stmt.Code[0].Text) + require.Equal(t, `[{"z": null}]`, stmt.Expectation[0].Text) // second test example = ex.examples[1] require.NotNil(t, example) require.Equal(t, "something else", example.Name) + require.Equal(t, 1, len(example.Statements)) stmt = example.Statements[0] - require.Equal(t, "INSERT INTO foo (c) VALUES (3);", stmt.Code.Text) - - stmt = example.Statements[1] - require.Equal(t, "SELECT * FROM foo;", stmt.Code.Text) - require.Equal(t, `{"c": 3}`, stmt.Expectation[0].Text) + require.Equal(t, "INSERT INTO foo (c) VALUES (3);", stmt.Code[0].Text) + require.Equal(t, "SELECT * FROM foo;", stmt.Code[1].Text) + require.Equal(t, `[{"c": 3}]`, stmt.Expectation[0].Text) } func TestTemplate(t *testing.T) { diff --git a/cmd/examplar/extest1.sql b/cmd/examplar/extest1.sql index 1f97931f0..59b55b7d6 100644 --- a/cmd/examplar/extest1.sql +++ b/cmd/examplar/extest1.sql @@ -7,20 +7,20 @@ DROP TABLE foo; --- test: insert something INSERT INTO foo (a) VALUES (1); SELECT * FROM foo; ---- `{"a": 1}` +--- `[{"a": 1}]` SELECT a, b FROM foo; --- ```json ---- { +--- [{ --- "a": 1, --- "b": null ---- } +--- }] --- ``` SELECT z FROM foo; ---- `{"z": null}` +--- `[{"z": null}]` --- test: something else INSERT INTO foo (c) VALUES (3); SELECT * FROM foo; ---- `{"c": 3}` +--- `[{"c": 3}]` diff --git a/cmd/examplar/extest1_test.go.gold b/cmd/examplar/extest1_test.go.gold index 6ffa60c85..4c04702e0 100644 --- a/cmd/examplar/extest1_test.go.gold +++ b/cmd/examplar/extest1_test.go.gold @@ -6,6 +6,7 @@ import ( "github.com/genjidb/genji" "github.com/genjidb/genji/document" + "github.com/genjidb/genji/query" "github.com/stretchr/testify/require" ) @@ -14,16 +15,37 @@ func TestFooBar(t *testing.T) { require.NoError(t, err) defer db.Close() + // query results wrapper + jsonResult := func(t *testing.T, res *query.Result) []byte { + t.Helper() + vb := document.NewValueBuffer() + err = res.Iterate(func(d document.Document) error { + vb = vb.Append(document.NewDocumentValue(d)) + return nil + }) + require.NoError(t, err) + data, err := json.Marshal(vb) + require.NoError(t, err) + return data + } + // teardown teardown := func() { - var err error - err = db.Exec("DROP TABLE foo;") // orig:5 + // orig:5 + q := ` + DROP TABLE foo; + ` + err := db.Exec(q) require.NoError(t, err) } // setup setup := func() { - err = db.Exec("CREATE TABLE foo (a int);") // orig:2 + // orig:2 + q := ` + CREATE TABLE foo (a int); + ` + err := db.Exec(q) require.NoError(t, err) } @@ -33,41 +55,59 @@ func TestFooBar(t *testing.T) { setup() var err error - var doc document.Document var data []byte var expected string + var q string + var res *query.Result - err = db.Exec("INSERT INTO foo (a) VALUES (1);") // orig:8 - require.NoError(t, err) - doc, err = db.QueryDocument("SELECT * FROM foo;") // orig:9 + // orig:8 + q = ` + INSERT INTO foo (a) VALUES (1); + SELECT * FROM foo; + ` + + res, err = db.Query(q) require.NoError(t, err) + defer res.Close() - data, err = json.Marshal(doc) + data = jsonResult(t, res) require.NoError(t, err) - expected = `{"a": 1}` // orig:10 + expected = `[{"a": 1}]` // orig:10 require.JSONEq(t, expected, string(data)) - doc, err = db.QueryDocument("SELECT a, b FROM foo;") // orig:12 + // orig:12 + q = ` + SELECT a, b FROM foo; + ` + + res, err = db.Query(q) require.NoError(t, err) + defer res.Close() - data, err = json.Marshal(doc) + data = jsonResult(t, res) require.NoError(t, err) // orig: 14 expected = ` - { + [{ "a": 1, "b": null - } + }] ` require.JSONEq(t, expected, string(data)) - doc, err = db.QueryDocument("SELECT z FROM foo;") // orig:20 + // orig:20 + q = ` + SELECT z FROM foo; + ` + + res, err = db.Query(q) require.NoError(t, err) + defer res.Close() - data, err = json.Marshal(doc) + data = jsonResult(t, res) require.NoError(t, err) - expected = `{"z": null}` // orig:21 + expected = `[{"z": null}]` // orig:21 require.JSONEq(t, expected, string(data)) }) @@ -77,19 +117,25 @@ func TestFooBar(t *testing.T) { setup() var err error - var doc document.Document var data []byte var expected string + var q string + var res *query.Result - err = db.Exec("INSERT INTO foo (c) VALUES (3);") // orig:24 - require.NoError(t, err) - doc, err = db.QueryDocument("SELECT * FROM foo;") // orig:25 + // orig:24 + q = ` + INSERT INTO foo (c) VALUES (3); + SELECT * FROM foo; + ` + + res, err = db.Query(q) require.NoError(t, err) + defer res.Close() - data, err = json.Marshal(doc) + data = jsonResult(t, res) require.NoError(t, err) - expected = `{"c": 3}` // orig:26 + expected = `[{"c": 3}]` // orig:26 require.JSONEq(t, expected, string(data)) }) diff --git a/cmd/examplar/scanner.go b/cmd/examplar/scanner.go index 0401b0824..5eb9812b8 100644 --- a/cmd/examplar/scanner.go +++ b/cmd/examplar/scanner.go @@ -23,6 +23,9 @@ type Scanner struct { line string num int ex *Examplar + + curTest *Test + curStmt *Statement } func initialState(s *Scanner) stateFn { @@ -33,7 +36,7 @@ func initialState(s *Scanner) stateFn { case TEARDOWN: return teardownState case TEST: - s.ex.appendTest(data, s.num) + s.curTest = s.ex.appendTest(data, s.num) return testState } } @@ -53,7 +56,7 @@ func setupState(s *Scanner) stateFn { return teardownState } case TEST: - s.ex.appendTest(data, s.num) + s.curTest = s.ex.appendTest(data, s.num) return testState } } @@ -74,7 +77,7 @@ func teardownState(s *Scanner) stateFn { case TEARDOWN: return errorState case TEST: - s.ex.appendTest(data, s.num) + s.curTest = s.ex.appendTest(data, s.num) return testState } } @@ -91,26 +94,31 @@ func testState(s *Scanner) stateFn { case TEARDOWN: return errorState case TEST: - s.ex.appendTest(data, s.num) + s.curTest = s.ex.appendTest(data, s.num) return testState } } - test := s.ex.currentTest() + if s.curStmt == nil { + stmt := &Statement{} + s.curTest.Statements = append(s.curTest.Statements, stmt) + s.curStmt = stmt + } if hasMultilineAssertionTag(s.line) { return multilineAssertionState } if assertion := parseSingleAssertion(s.line); len(assertion) > 0 { - exp := s.ex.currentExpectation() + exp := &s.curStmt.Expectation *exp = []Line{{s.num, assertion}} + + // current statement is now finished + s.curStmt = nil return testState } - test.Statements = append(test.Statements, &Statement{ - Code: Line{s.num, s.line}, - }) + s.curStmt.Code = append(s.curStmt.Code, Line{s.num, s.line}) return testState } @@ -127,11 +135,13 @@ func multilineAssertionState(s *Scanner) stateFn { code := strings.TrimRight(matches[1], " \t") + // final triple backtick, go back to testState if strings.TrimSpace(matches[1]) == "```" { + s.curStmt = nil return testState } - exp := s.ex.currentExpectation() + exp := &s.curStmt.Expectation *exp = append(*exp, Line{s.num, code}) return multilineAssertionState diff --git a/cmd/examplar/test_template.go.tmpl b/cmd/examplar/test_template.go.tmpl index d4ccfd55a..cda0a8edd 100644 --- a/cmd/examplar/test_template.go.tmpl +++ b/cmd/examplar/test_template.go.tmpl @@ -6,6 +6,7 @@ import ( "github.com/genjidb/genji" "github.com/genjidb/genji/document" + "github.com/genjidb/genji/query" "github.com/stretchr/testify/require" ) @@ -14,21 +15,42 @@ func Test{{ .TestName }}(t *testing.T) { require.NoError(t, err) defer db.Close() + // query results wrapper + jsonResult := func(t *testing.T, res *query.Result) []byte { + t.Helper() + vb := document.NewValueBuffer() + err = res.Iterate(func(d document.Document) error { + vb = vb.Append(document.NewDocumentValue(d)) + return nil + }) + require.NoError(t, err) + data, err := json.Marshal(vb) + require.NoError(t, err) + return data + } + // teardown teardown := func() { - var err error + // orig:{{ (index .Teardown 0).Num }} + q := ` {{- range .Teardown }} - err = db.Exec("{{ .Text }}") // orig:{{ .Num }} + {{ .Text }} + {{- end}} + ` + err := db.Exec(q) require.NoError(t, err) - {{- end }} } // setup setup := func() { + // orig:{{ (index .Setup 0).Num }} + q := ` {{- range .Setup }} - err = db.Exec("{{ .Text }}") // orig:{{ .Num }} + {{ .Text }} + {{- end}} + ` + err := db.Exec(q) require.NoError(t, err) - {{- end }} } {{ "" }} {{- range .Tests }} @@ -38,16 +60,25 @@ func Test{{ .TestName }}(t *testing.T) { setup() var err error - var doc document.Document var data []byte var expected string + var q string + var res *query.Result {{ "" }} + {{- range .Statements }} - {{- if gt (len .Expectation) 0 }} - doc, err = db.QueryDocument("{{ .Code.Text }}") // orig:{{ .Code.Num }} + // orig:{{ (index .Code 0).Num }} + q = ` + {{- range .Code }} + {{ .Text }} + {{- end}} + ` + + res, err = db.Query(q) require.NoError(t, err) + defer res.Close() - data, err = json.Marshal(doc) + data = jsonResult(t, res) require.NoError(t, err) {{ "" }} {{- if gt (len .Expectation) 1 }} @@ -61,10 +92,6 @@ func Test{{ .TestName }}(t *testing.T) { expected = `{{ (index .Expectation 0).Text }}` // orig:{{ (index .Expectation 0).Num }} {{- end }} require.JSONEq(t, expected, string(data)) - {{- else}} - err = db.Exec("{{ .Code.Text }}") // orig:{{ .Code.Num }} - require.NoError(t, err) - {{- end }} {{- end }} }) {{ "" }} From 13c2ac53d3a0af09203a31ab68aef5b51f78331b Mon Sep 17 00:00:00 2001 From: Jean-Hadrien Chabran Date: Mon, 12 Apr 2021 20:29:00 +0200 Subject: [PATCH 09/10] Use the original filename, rework error handling --- cmd/examplar/examplar.go | 46 ++++++++++++++---------------- cmd/examplar/examplar_test.go | 10 +++---- cmd/examplar/extest1_test.go.gold | 24 ++++++++-------- cmd/examplar/main.go | 3 +- cmd/examplar/scanner.go | 25 +++++++++------- cmd/examplar/test_template.go.tmpl | 12 ++++---- 6 files changed, 60 insertions(+), 60 deletions(-) diff --git a/cmd/examplar/examplar.go b/cmd/examplar/examplar.go index 700ffe145..2374b295f 100644 --- a/cmd/examplar/examplar.go +++ b/cmd/examplar/examplar.go @@ -2,20 +2,21 @@ package main import ( "bufio" + "fmt" "io" "strings" "text/template" ) type Line struct { - Num int + Orig string Text string } // Test is a list of statements. type Test struct { Name string - Num int + Orig string Statements []*Statement } @@ -36,10 +37,15 @@ func (s Statement) expectationText() string { // Examplar represents a group of tests and can optionally include setup code and teardown code. type Examplar struct { - Name string - setup []Line - teardown []Line - examples []*Test + Name string + originalFilename string + setup []Line + teardown []Line + examples []*Test +} + +func (ex *Examplar) origLoc(num int) string { + return fmt.Sprintf("%s:%d", ex.originalFilename, num) } // HasSetup returns true if setup code is provided. @@ -55,38 +61,28 @@ func (ex *Examplar) HasTeardown() bool { func (ex *Examplar) appendTest(name string, num int) *Test { test := Test{ Name: name, - Num: num, + Orig: ex.origLoc(num), } ex.examples = append(ex.examples, &test) return &test } -// func (ex *Examplar) currentTest() *Test { -// return ex.examples[len(ex.examples)-1] -// } - -// func (ex *Examplar) currentStatement() *Statement { -// test := ex.currentTest() -// return test.Statements[len(test.Statements)-1] -// } - -// func (ex *Examplar) currentExpectation() *[]Line { -// return &ex.currentStatement().Expectation -// } - // Parse reads annotated textual data and transforms it into a // structured representation. Only annotations are parsed, the // textual data itself is irrelevant to this function. // // It will panic if an error is encountered. -func Parse(r io.Reader, name string) *Examplar { - scanner := &Scanner{} +func Parse(r io.Reader, name string, originalFilename string) *Examplar { + ex := Examplar{ + Name: name, + originalFilename: originalFilename, + } - ex := scanner.Run(bufio.NewScanner(r)) - ex.Name = name + scanner := &Scanner{ex: &ex} + scanner.Run(bufio.NewScanner(r)) - return ex + return &ex } func normalizeTestName(name string) string { diff --git a/cmd/examplar/examplar_test.go b/cmd/examplar/examplar_test.go index d0403e878..0eef89aa3 100644 --- a/cmd/examplar/examplar_test.go +++ b/cmd/examplar/examplar_test.go @@ -12,12 +12,12 @@ import ( func TestParse(t *testing.T) { f, err := os.Open("extest1.sql") require.NoError(t, err) - defer f.Close() - ex := Parse(f, "extest1") + defer f.Close() + ex := Parse(f, "extest1", "extest1.sql") - require.Equal(t, []Line{{2, "CREATE TABLE foo (a int);"}}, ex.setup) - require.Equal(t, []Line{{5, "DROP TABLE foo;"}}, ex.teardown) + require.Equal(t, []Line{{"extest1.sql:2", "CREATE TABLE foo (a int);"}}, ex.setup) + require.Equal(t, []Line{{"extest1.sql:5", "DROP TABLE foo;"}}, ex.teardown) // first test example := ex.examples[0] @@ -64,7 +64,7 @@ func TestTemplate(t *testing.T) { require.NoError(t, err) defer f.Close() - ex := Parse(f, "foo bar") + ex := Parse(f, "foo bar", "extest1.sql") var b strings.Builder diff --git a/cmd/examplar/extest1_test.go.gold b/cmd/examplar/extest1_test.go.gold index 4c04702e0..64d5d9ab4 100644 --- a/cmd/examplar/extest1_test.go.gold +++ b/cmd/examplar/extest1_test.go.gold @@ -31,7 +31,7 @@ func TestFooBar(t *testing.T) { // teardown teardown := func() { - // orig:5 + // extest1.sql:5 q := ` DROP TABLE foo; ` @@ -41,7 +41,7 @@ func TestFooBar(t *testing.T) { // setup setup := func() { - // orig:2 + // extest1.sql:2 q := ` CREATE TABLE foo (a int); ` @@ -49,7 +49,7 @@ func TestFooBar(t *testing.T) { require.NoError(t, err) } - // orig:7 + // extest1.sql:7 t.Run("insert something", func(t *testing.T) { t.Cleanup(teardown) setup() @@ -60,7 +60,7 @@ func TestFooBar(t *testing.T) { var q string var res *query.Result - // orig:8 + // extest1.sql:8 q = ` INSERT INTO foo (a) VALUES (1); SELECT * FROM foo; @@ -73,9 +73,9 @@ func TestFooBar(t *testing.T) { data = jsonResult(t, res) require.NoError(t, err) - expected = `[{"a": 1}]` // orig:10 + expected = `[{"a": 1}]` // extest1.sql:10 require.JSONEq(t, expected, string(data)) - // orig:12 + // extest1.sql:12 q = ` SELECT a, b FROM foo; ` @@ -87,7 +87,7 @@ func TestFooBar(t *testing.T) { data = jsonResult(t, res) require.NoError(t, err) - // orig: 14 + // extest1.sql:14 expected = ` [{ "a": 1, @@ -95,7 +95,7 @@ func TestFooBar(t *testing.T) { }] ` require.JSONEq(t, expected, string(data)) - // orig:20 + // extest1.sql:20 q = ` SELECT z FROM foo; ` @@ -107,11 +107,11 @@ func TestFooBar(t *testing.T) { data = jsonResult(t, res) require.NoError(t, err) - expected = `[{"z": null}]` // orig:21 + expected = `[{"z": null}]` // extest1.sql:21 require.JSONEq(t, expected, string(data)) }) - // orig:23 + // extest1.sql:23 t.Run("something else", func(t *testing.T) { t.Cleanup(teardown) setup() @@ -122,7 +122,7 @@ func TestFooBar(t *testing.T) { var q string var res *query.Result - // orig:24 + // extest1.sql:24 q = ` INSERT INTO foo (c) VALUES (3); SELECT * FROM foo; @@ -135,7 +135,7 @@ func TestFooBar(t *testing.T) { data = jsonResult(t, res) require.NoError(t, err) - expected = `[{"c": 3}]` // orig:26 + expected = `[{"c": 3}]` // extest1.sql:26 require.JSONEq(t, expected, string(data)) }) diff --git a/cmd/examplar/main.go b/cmd/examplar/main.go index 50c98e707..976070225 100644 --- a/cmd/examplar/main.go +++ b/cmd/examplar/main.go @@ -32,7 +32,6 @@ func main() { } paths := os.Args[2:] - fmt.Println(paths) if len(paths) < 1 { flag.Usage() os.Exit(-1) @@ -69,7 +68,7 @@ func genFile(p string, packageName string) error { base := path.Base(p) name := strings.TrimSuffix(base, path.Ext(p)) - ex := Parse(in, name) + ex := Parse(in, name, base) out, err := os.OpenFile(name+"_test.go", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { diff --git a/cmd/examplar/scanner.go b/cmd/examplar/scanner.go index 5eb9812b8..ef9e595ff 100644 --- a/cmd/examplar/scanner.go +++ b/cmd/examplar/scanner.go @@ -2,6 +2,7 @@ package main import ( "bufio" + "fmt" "regexp" "strings" ) @@ -23,11 +24,16 @@ type Scanner struct { line string num int ex *Examplar + err error curTest *Test curStmt *Statement } +func (s *Scanner) origLoc() string { + return s.ex.origLoc(s.num) +} + func initialState(s *Scanner) stateFn { if tag, data := parseTag(s.line); tag != UNKNOWN { switch tag { @@ -61,7 +67,7 @@ func setupState(s *Scanner) stateFn { } } - s.ex.setup = append(s.ex.setup, Line{s.num, s.line}) + s.ex.setup = append(s.ex.setup, Line{s.origLoc(), s.line}) return setupState } @@ -82,7 +88,7 @@ func teardownState(s *Scanner) stateFn { } } - s.ex.teardown = append(s.ex.teardown, Line{s.num, s.line}) + s.ex.teardown = append(s.ex.teardown, Line{s.origLoc(), s.line}) return teardownState } @@ -111,14 +117,14 @@ func testState(s *Scanner) stateFn { if assertion := parseSingleAssertion(s.line); len(assertion) > 0 { exp := &s.curStmt.Expectation - *exp = []Line{{s.num, assertion}} + *exp = []Line{{s.origLoc(), assertion}} // current statement is now finished s.curStmt = nil return testState } - s.curStmt.Code = append(s.curStmt.Code, Line{s.num, s.line}) + s.curStmt.Code = append(s.curStmt.Code, Line{s.origLoc(), s.line}) return testState } @@ -142,18 +148,17 @@ func multilineAssertionState(s *Scanner) stateFn { } exp := &s.curStmt.Expectation - *exp = append(*exp, Line{s.num, code}) + *exp = append(*exp, Line{s.origLoc(), code}) return multilineAssertionState } func errorState(s *Scanner) stateFn { - panic(s.line) + s.err = fmt.Errorf(s.line) + return errorState } -func (s *Scanner) Run(io *bufio.Scanner) *Examplar { - s.ex = &Examplar{} - +func (s *Scanner) Run(io *bufio.Scanner) error { for state := initialState; io.Scan(); { s.line = io.Text() s.line = strings.TrimSpace(s.line) @@ -165,7 +170,7 @@ func (s *Scanner) Run(io *bufio.Scanner) *Examplar { state = state(s) } - return s.ex + return s.err } func parseTag(line string) (Tag, string) { diff --git a/cmd/examplar/test_template.go.tmpl b/cmd/examplar/test_template.go.tmpl index cda0a8edd..321087137 100644 --- a/cmd/examplar/test_template.go.tmpl +++ b/cmd/examplar/test_template.go.tmpl @@ -31,7 +31,7 @@ func Test{{ .TestName }}(t *testing.T) { // teardown teardown := func() { - // orig:{{ (index .Teardown 0).Num }} + // {{ (index .Teardown 0).Orig }} q := ` {{- range .Teardown }} {{ .Text }} @@ -43,7 +43,7 @@ func Test{{ .TestName }}(t *testing.T) { // setup setup := func() { - // orig:{{ (index .Setup 0).Num }} + // {{ (index .Setup 0).Orig }} q := ` {{- range .Setup }} {{ .Text }} @@ -54,7 +54,7 @@ func Test{{ .TestName }}(t *testing.T) { } {{ "" }} {{- range .Tests }} - // orig:{{ .Num }} + // {{ .Orig }} t.Run("{{ .Name }}", func(t *testing.T) { t.Cleanup(teardown) setup() @@ -67,7 +67,7 @@ func Test{{ .TestName }}(t *testing.T) { {{ "" }} {{- range .Statements }} - // orig:{{ (index .Code 0).Num }} + // {{ (index .Code 0).Orig }} q = ` {{- range .Code }} {{ .Text }} @@ -82,14 +82,14 @@ func Test{{ .TestName }}(t *testing.T) { require.NoError(t, err) {{ "" }} {{- if gt (len .Expectation) 1 }} - // orig: {{ (index .Expectation 0).Num }} + // {{ (index .Expectation 0).Orig }} expected = ` {{- range .Expectation }} {{ .Text }} {{- end }} ` {{- else }} - expected = `{{ (index .Expectation 0).Text }}` // orig:{{ (index .Expectation 0).Num }} + expected = `{{ (index .Expectation 0).Text }}` // {{ (index .Expectation 0).Orig }} {{- end }} require.JSONEq(t, expected, string(data)) {{- end }} From 4bf9ede0d74b333268ed5856b7227b16c0e038b3 Mon Sep 17 00:00:00 2001 From: Jean-Hadrien Chabran Date: Tue, 13 Apr 2021 15:18:11 +0200 Subject: [PATCH 10/10] Remove Go 1.15 from Travis --- .travis.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.travis.yml b/.travis.yml index 8fd856511..8e09b448b 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,7 +4,6 @@ env: - GO111MODULE=on go: - - "1.15.x" - "1.16.x" - tip