Skip to content

Commit 791ee07

Browse files
committed
Added SQLREPLTestSuite
Signed-off-by: Lou Marvin Caraig <[email protected]>
1 parent 2c1ba84 commit 791ee07

File tree

2 files changed

+277
-46
lines changed

2 files changed

+277
-46
lines changed

cmdtests/common.go

+95
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ func (s *IntegrationSuite) SetupTest() {
4040
s.Require().NoError(r.Error, r.Combined())
4141
}
4242

43+
func (s *IntegrationSuite) Bin() string {
44+
return srcdBin
45+
}
46+
4347
func (s *IntegrationSuite) RunCmd(cmd string, args []string, cmdOperators ...icmd.CmdOp) *icmd.Result {
4448
args = append([]string{cmd}, args...)
4549
return icmd.RunCmd(icmd.Command(srcdBin, args...), cmdOperators...)
@@ -120,3 +124,94 @@ func (s *IntegrationTmpDirSuite) SetupTest() {
120124
func (s *IntegrationTmpDirSuite) TearDownTest() {
121125
os.RemoveAll(s.TestDir)
122126
}
127+
128+
type ChannelWriter struct {
129+
ch chan string
130+
}
131+
132+
func NewChannelWriter(ch chan string) *ChannelWriter {
133+
return &ChannelWriter{ch: ch}
134+
}
135+
136+
func (cr *ChannelWriter) Write(b []byte) (int, error) {
137+
cr.ch <- string(b)
138+
return len(b), nil
139+
}
140+
141+
var newLineFormatter = regexp.MustCompile(`(\r\n|\r|\n)`)
142+
143+
func normalizeNewLine(s string) string {
144+
return newLineFormatter.ReplaceAllString(s, "\n")
145+
}
146+
147+
// StreamLinifier is useful when we have a stream of messages, where each message
148+
// can contain multiple lines, and we want to transform it into a stream of messages,
149+
// where each message is a single line.
150+
// Example:
151+
// - input: "foo", "bar\nbaz", "qux\nquux\n"
152+
// - output: "foo", "bar", "baz", "qux", "quux"
153+
//
154+
// This transformation is done through the `Linify` method that reads the input from
155+
// the channel passed as argument and writes the output into the returned channel.
156+
//
157+
// Corner case:
158+
// given the input message "foo\nbar\baz", the lines "foo" and "bar" are written to
159+
// the output channel ASAP, but notice that it's not possible to do the same for
160+
// "baz" which is then marked as *pending*.
161+
// That's because it doesn't end with a new line. In fact, two cases may hold with
162+
// the following message:
163+
// 1. the following message starts with a new line, let's say "\nqux\n",
164+
// 2. the following message doesn't start with a new line, let'say "qux\n".
165+
//
166+
// In the first case, "baz" can be written to the output channel, but in the second
167+
// case, "qux" is the continuation of the same line of "baz", so "bazqux" is the
168+
// message to be written.
169+
// To avoid losing to write the last line, if there's a pending line and and
170+
// an amount of time equal to `newLineTimeout` elapses, then we consider it
171+
// as a completed line and we write the message to the output channel.
172+
type StreamLinifier struct {
173+
newLineTimeout time.Duration
174+
pending string
175+
}
176+
177+
// NewStreamLinifier returns a `StreamLinifier` configure with a given timeout
178+
func NewStreamLinifier(timeout time.Duration) *StreamLinifier {
179+
return &StreamLinifier{newLineTimeout: timeout}
180+
}
181+
182+
// Linify returns a channel to read lines from.
183+
// Messages coming from `in` containing multiple newlines (`(\r\n|\r|\n)`), will
184+
// be sent to the returned channel as multiple messages, one per line.
185+
func (sl *StreamLinifier) Linify(in chan string) chan string {
186+
out := make(chan string)
187+
188+
go func() {
189+
for {
190+
select {
191+
case <-time.After(sl.newLineTimeout):
192+
if sl.pending != "" {
193+
out <- sl.pending
194+
sl.pending = ""
195+
}
196+
case s, ok := <-in:
197+
if !ok {
198+
close(out)
199+
return
200+
}
201+
202+
lines := strings.Split(sl.pending+normalizeNewLine(s), "\n")
203+
sl.pending = ""
204+
205+
for i, l := range lines {
206+
if i == len(lines) && l != "" {
207+
sl.pending = l
208+
break
209+
}
210+
out <- l
211+
}
212+
}
213+
}
214+
}()
215+
216+
return out
217+
}

cmdtests/sql_test.go

+182-46
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,28 @@
33
package cmdtests_test
44

55
import (
6+
"fmt"
7+
"io"
68
"os"
79
"os/exec"
810
"path"
911
"path/filepath"
12+
"regexp"
13+
"runtime"
1014
"strings"
1115
"testing"
1216
"time"
1317

18+
"github.com/kr/pty"
1419
"github.com/src-d/engine/cmdtests"
20+
"github.com/src-d/engine/components"
21+
"github.com/src-d/engine/docker"
1522
"github.com/stretchr/testify/assert"
1623
"github.com/stretchr/testify/require"
1724
"github.com/stretchr/testify/suite"
1825
"gotest.tools/icmd"
1926
)
2027

21-
type SQLTestSuite struct {
22-
cmdtests.IntegrationTmpDirSuite
23-
}
24-
25-
func TestSQLTestSuite(t *testing.T) {
26-
s := SQLTestSuite{}
27-
suite.Run(t, &s)
28-
}
29-
3028
var showTablesOutput = sqlOutput(`+--------------+
3129
| Table |
3230
+--------------+
@@ -44,6 +42,181 @@ var showTablesOutput = sqlOutput(`+--------------+
4442
+--------------+
4543
`)
4644

45+
var showRepoTableDescOutput = sqlOutput(`+---------------+------+
46+
| name | type |
47+
+---------------+------+
48+
| repository_id | TEXT |
49+
+---------------+------+
50+
`)
51+
52+
type SQLREPLTestSuite struct {
53+
cmdtests.IntegrationTmpDirSuite
54+
testDir string
55+
}
56+
57+
func TestSQLREPLTestSuite(t *testing.T) {
58+
s := SQLREPLTestSuite{}
59+
suite.Run(t, &s)
60+
}
61+
62+
func (s *SQLREPLTestSuite) TestREPL() {
63+
// When it is not in a terminal, the command reads stdin and exits.
64+
// You can see it running:
65+
// $ echo "show tables;" | ./srcd sql
66+
// So this test does not really interacts with the REPL prompt, but we still
67+
// test that the code that processes each read line is working as expected.
68+
69+
require := s.Require()
70+
71+
input := "show tables;\n" + "describe table repositories;\n"
72+
r := s.RunCmd("sql", nil, icmd.WithStdin(strings.NewReader(input)))
73+
require.NoError(r.Error, r.Combined())
74+
require.Contains(r.Combined(), showTablesOutput)
75+
require.Contains(r.Combined(), showRepoTableDescOutput)
76+
}
77+
78+
func (s *SQLREPLTestSuite) TestInteractiveREPL() {
79+
if runtime.GOOS == "windows" {
80+
s.T().Skip("Testing interactive REPL on Windows is not supported")
81+
}
82+
83+
require := s.Require()
84+
85+
command, in, out, err := s.runInteractiveRepl()
86+
require.NoError(err)
87+
88+
res := s.runInteractiveQuery(in, "show tables;\n", out)
89+
require.Contains(res, showTablesOutput)
90+
91+
res = s.runInteractiveQuery(in, "describe table repositories;\n", out)
92+
require.Contains(res, showRepoTableDescOutput)
93+
94+
require.NoError(s.exitInteractiveAndWait(10*time.Second, in, out))
95+
require.NoError(s.waitMysqlCliContainerStopped(10, 1*time.Second))
96+
97+
command.Wait()
98+
}
99+
100+
func (s *SQLREPLTestSuite) runInteractiveRepl() (*exec.Cmd, io.Writer, <-chan string, error) {
101+
s.T().Helper()
102+
103+
// cannot use `icmd` here, please see: https://github.com/gotestyourself/gotest.tools/issues/151
104+
command := exec.Command(s.Bin(), "sql")
105+
106+
ch := make(chan string)
107+
cr := cmdtests.NewChannelWriter(ch)
108+
109+
command.Stdout = cr
110+
command.Stderr = cr
111+
112+
in, err := pty.Start(command)
113+
if err != nil {
114+
panic(err)
115+
}
116+
117+
linifier := cmdtests.NewStreamLinifier(1 * time.Second)
118+
out := linifier.Linify(ch)
119+
for s := range out {
120+
if strings.HasPrefix(s, "mysql>") {
121+
return command, in, out, nil
122+
}
123+
}
124+
125+
return nil, nil, nil, fmt.Errorf("Mysql cli prompt never started")
126+
}
127+
128+
func (s *SQLREPLTestSuite) runInteractiveQuery(in io.Writer, query string, out <-chan string) string {
129+
io.WriteString(in, query)
130+
131+
var res strings.Builder
132+
for c := range out {
133+
if strings.HasPrefix(c, "Empty set") {
134+
return ""
135+
}
136+
137+
res.WriteString(c + "\r\n")
138+
if s.containsSQLOutput(res.String()) {
139+
break
140+
}
141+
}
142+
143+
return res.String()
144+
}
145+
146+
func (s *SQLREPLTestSuite) exitInteractiveAndWait(timeout time.Duration, in io.Writer, out <-chan string) error {
147+
io.WriteString(in, "exit;\n")
148+
149+
done := make(chan struct{})
150+
go func() {
151+
for c := range out {
152+
if strings.Contains(c, "Bye") {
153+
done <- struct{}{}
154+
// don't return in order to consume all output and let the process exit
155+
}
156+
}
157+
}()
158+
159+
select {
160+
case <-done:
161+
return nil
162+
case <-time.After(timeout):
163+
return fmt.Errorf("timeout of %v elapsed while waiting to exit", timeout)
164+
}
165+
}
166+
167+
func (s *SQLREPLTestSuite) waitMysqlCliContainerStopped(retries int, retryTimeout time.Duration) error {
168+
for i := 0; i < retries; i++ {
169+
running, err := docker.IsRunning(components.MysqlCli.Name, "")
170+
if !running {
171+
return nil
172+
}
173+
174+
if err != nil {
175+
return err
176+
}
177+
178+
time.Sleep(retryTimeout)
179+
}
180+
181+
return fmt.Errorf("maximum number of retries (%d) reached while waiting to stop container", retries)
182+
}
183+
184+
// containsSQLOutput returns `true` if the given string is a SQL output table.
185+
// To detect whether the `out` is a SQL output table, this checks that there
186+
// are exactly 3 separators matching this regex ``\+-+\+`.
187+
// In fact an example of SQL output is the following:
188+
//
189+
// +--------------+ <-- first separator
190+
// | Table |
191+
// +--------------+ <-- second separator
192+
// | blobs |
193+
// | commit_blobs |
194+
// | commit_files |
195+
// | commit_trees |
196+
// | commits |
197+
// | files |
198+
// | ref_commits |
199+
// | refs |
200+
// | remotes |
201+
// | repositories |
202+
// | tree_entries |
203+
// +--------------+ <-- third separator
204+
//
205+
func (s *SQLREPLTestSuite) containsSQLOutput(out string) bool {
206+
sep := regexp.MustCompile(`\+-+\+`)
207+
matches := sep.FindAllStringIndex(out, -1)
208+
return len(matches) == 3
209+
}
210+
211+
type SQLTestSuite struct {
212+
cmdtests.IntegrationTmpDirSuite
213+
}
214+
215+
func TestSQLTestSuite(t *testing.T) {
216+
s := SQLTestSuite{}
217+
suite.Run(t, &s)
218+
}
219+
47220
func (s *SQLTestSuite) TestInit() {
48221
require := s.Require()
49222

@@ -146,43 +319,6 @@ func (s *SQLTestSuite) TestWrongQuery() {
146319
}
147320
}
148321

149-
func (s *SQLTestSuite) TestREPL() {
150-
// When it is not in a terminal, the command reads stdin and exits.
151-
// You can see it running:
152-
// $ echo "show tables;" | ./srcd sql
153-
// So this test does not really interacts with the REPL prompt, but we still
154-
// test that the code that processes each read line is working as expected.
155-
156-
require := s.Require()
157-
158-
input := "show tables;\n" + "describe table repositories;\n"
159-
r := s.RunCmd("sql", nil, icmd.WithStdin(strings.NewReader(input)))
160-
require.NoError(r.Error, r.Combined())
161-
162-
expected := sqlOutput(`+--------------+
163-
| Table |
164-
+--------------+
165-
| blobs |
166-
| commit_blobs |
167-
| commit_files |
168-
| commit_trees |
169-
| commits |
170-
| files |
171-
| ref_commits |
172-
| refs |
173-
| remotes |
174-
| repositories |
175-
| tree_entries |
176-
+--------------+
177-
+---------------+------+
178-
| name | type |
179-
+---------------+------+
180-
| repository_id | TEXT |
181-
+---------------+------+`)
182-
183-
require.Contains(r.Stdout(), expected)
184-
}
185-
186322
func (s *SQLTestSuite) TestIndexesWorkdirChange() {
187323
require := s.Require()
188324

0 commit comments

Comments
 (0)