Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion dbee/adapters/sqlite.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ func (s *SQLite) Connect(url string) (core.Driver, error) {
}

return &sqliteDriver{
c: builders.NewClient(db),
c: builders.NewClient(db),
currentDatabase: path,
}, nil
}

Expand Down
56 changes: 30 additions & 26 deletions dbee/adapters/sqlite_driver.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,48 +7,52 @@ import (
"github.com/kndndrj/nvim-dbee/dbee/core/builders"
)

var _ core.Driver = (*sqliteDriver)(nil)
var (
_ core.Driver = (*sqliteDriver)(nil)
_ core.DatabaseSwitcher = (*sqliteDriver)(nil)
)

type sqliteDriver struct {
c *builders.Client
c *builders.Client
currentDatabase string
}

func (c *sqliteDriver) Query(ctx context.Context, query string) (core.ResultStream, error) {
func (d *sqliteDriver) Query(ctx context.Context, query string) (core.ResultStream, error) {
// run query, fallback to affected rows
return c.c.QueryUntilNotEmpty(ctx, query, "select changes() as 'Rows Affected'")
return d.c.QueryUntilNotEmpty(ctx, query, "select changes() as 'Rows Affected'")
}

func (c *sqliteDriver) Columns(opts *core.TableOptions) ([]*core.Column, error) {
return c.c.ColumnsFromQuery("SELECT name, type FROM pragma_table_info('%s')", opts.Table)
func (d *sqliteDriver) Columns(opts *core.TableOptions) ([]*core.Column, error) {
return d.c.ColumnsFromQuery("SELECT name, type FROM pragma_table_info('%s')", opts.Table)
}

func (c *sqliteDriver) Structure() ([]*core.Structure, error) {
query := `SELECT name FROM sqlite_schema WHERE type ='table'`
func (d *sqliteDriver) Structure() ([]*core.Structure, error) {
// sqlite is single schema structure, so we hardcode the name of it.
query := "SELECT 'sqlite_schema' as schema, name, type FROM sqlite_schema"

rows, err := c.Query(context.TODO(), query)
rows, err := d.Query(context.Background(), query)
if err != nil {
return nil, err
}

var schema []*core.Structure
for rows.HasNext() {
row, err := rows.Next()
if err != nil {
return nil, err
decodeStructureType := func(typ string) core.StructureType {
switch typ {
case "table":
return core.StructureTypeTable
case "view":
return core.StructureTypeView
default:
return core.StructureTypeNone
}

// We know for a fact there is only one string field (see query above)
table := row[0].(string)
schema = append(schema, &core.Structure{
Name: table,
Schema: "",
Type: core.StructureTypeTable,
})
}

return schema, nil
return core.GetGenericStructure(rows, decodeStructureType)
}

func (c *sqliteDriver) Close() {
c.c.Close()
func (d *sqliteDriver) Close() { d.c.Close() }

func (d *sqliteDriver) ListDatabases() (string, []string, error) {
return d.currentDatabase, []string{"not supported yet"}, nil
}

// SelectDatabase is a no-op, added to make the UI more pleasent.
func (d *sqliteDriver) SelectDatabase(name string) error { return nil }
2 changes: 1 addition & 1 deletion dbee/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/ClickHouse/clickhouse-go/v2 v2.20.0
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/databricks/databricks-sql-go v1.5.3
github.com/docker/docker v27.1.1+incompatible
github.com/go-sql-driver/mysql v1.7.1
github.com/google/uuid v1.6.0
github.com/jedib0t/go-pretty/v6 v6.5.8
Expand Down Expand Up @@ -59,7 +60,6 @@ require (
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/dnephin/pflag v1.0.7 // indirect
github.com/docker/docker v27.1.1+incompatible // indirect
github.com/docker/go-connections v0.5.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
Expand Down
161 changes: 161 additions & 0 deletions dbee/tests/integration/sqlite_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package integration

import (
"context"
"log"
"testing"

"github.com/kndndrj/nvim-dbee/dbee/core"
th "github.com/kndndrj/nvim-dbee/dbee/tests/testhelpers"
"github.com/stretchr/testify/assert"
tsuite "github.com/stretchr/testify/suite"
tc "github.com/testcontainers/testcontainers-go"
)

// SQLiteTestSuite is the test suite for the sqlite adapter.
type SQLiteTestSuite struct {
tsuite.Suite
ctr *th.SQLiteContainer
ctx context.Context
d *core.Connection
}

func TestSQLiteTestSuite(t *testing.T) {
tsuite.Run(t, new(SQLiteTestSuite))
}

func (suite *SQLiteTestSuite) SetupSuite() {
suite.ctx = context.Background()
tempDir := suite.T().TempDir()

params := &core.ConnectionParams{ID: "test-sqlite", Name: "test-sqlite"}
ctr, err := th.NewSQLiteContainer(suite.ctx, params, tempDir)
if err != nil {
log.Fatal(err)
}

suite.ctr, suite.d = ctr, ctr.Driver
}

func (suite *SQLiteTestSuite) TeardownSuite() {
tc.CleanupContainer(suite.T(), suite.ctr)
}

func (suite *SQLiteTestSuite) TestShouldErrorInvalidQuery() {
t := suite.T()

want := "syntax error"

call := suite.d.Execute("invalid sql", func(cs core.CallState, c *core.Call) {
if cs == core.CallStateExecutingFailed {
assert.ErrorContains(t, c.Err(), want)
}
})
assert.NotNil(t, call)
}

func (suite *SQLiteTestSuite) TestShouldCancelQuery() {
t := suite.T()
want := []core.CallState{core.CallStateExecuting, core.CallStateCanceled}

_, got, err := th.GetResultWithCancel(t, suite.d, "SELECT 1")
assert.NoError(t, err)

assert.Equal(t, want, got)
}

func (suite *SQLiteTestSuite) TestShouldReturnManyRows() {
t := suite.T()

wantStates := []core.CallState{
core.CallStateExecuting, core.CallStateRetrieving, core.CallStateArchived,
}
wantCols := []string{"id", "username"}
wantRows := []core.Row{
{int64(1), "john_doe"},
{int64(2), "jane_smith"},
{int64(3), "bob_wilson"},
}

query := "SELECT id, username FROM test_table"

gotRows, gotCols, gotStates, err := th.GetResult(t, suite.d, query)
assert.NoError(t, err)

assert.ElementsMatch(t, wantCols, gotCols)
assert.ElementsMatch(t, wantStates, gotStates)
assert.Equal(t, wantRows, gotRows)
}

func (suite *SQLiteTestSuite) TestShouldReturnOneRow() {
t := suite.T()

wantStates := []core.CallState{
core.CallStateExecuting, core.CallStateRetrieving, core.CallStateArchived,
}
wantCols := []string{"id", "username"}
wantRows := []core.Row{{int64(2), "jane_smith"}}

query := "SELECT id, username FROM test_view"

gotRows, gotCols, gotStates, err := th.GetResult(t, suite.d, query)
assert.NoError(t, err)

assert.ElementsMatch(t, wantCols, gotCols)
assert.ElementsMatch(t, wantStates, gotStates)
assert.Equal(t, wantRows, gotRows)
}

func (suite *SQLiteTestSuite) TestShouldReturnStructure() {
t := suite.T()

var (
wantSchema = "sqlite_schema"
wantSomeTable = "test_table"
wantSomeView = "test_view"
)

structure, err := suite.d.GetStructure()
assert.NoError(t, err)

gotSchemas := th.GetSchemas(t, structure)
assert.Contains(t, gotSchemas, wantSchema)

gotTables := th.GetModels(t, structure, core.StructureTypeTable)
assert.Contains(t, gotTables, wantSomeTable)

gotViews := th.GetModels(t, structure, core.StructureTypeView)
assert.Contains(t, gotViews, wantSomeView)
}

func (suite *SQLiteTestSuite) TestShouldReturnColumns() {
t := suite.T()

want := []*core.Column{
{Name: "id", Type: "INTEGER"},
{Name: "username", Type: "TEXT"},
{Name: "email", Type: "TEXT"},
}

got, err := suite.d.GetColumns(&core.TableOptions{
Table: "test_table",
Schema: "sqlite_schema",
Materialization: core.StructureTypeTable,
})

assert.NoError(t, err)
assert.Equal(t, want, got)
}

func (suite *SQLiteTestSuite) TestShouldNoOperationSwitchDatabase() {
t := suite.T()

driver, err := suite.ctr.NewDriver(&core.ConnectionParams{
ID: "test-sqlite-2",
Name: "test-sqlite-2",
})
assert.NoError(t, err)

err = driver.SelectDatabase("no-op")
assert.Nil(t, err)
}
16 changes: 16 additions & 0 deletions dbee/tests/testdata/sqlite_seed.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
CREATE TABLE IF NOT EXISTS test_table (
id INTEGER PRIMARY KEY,
username TEXT,
email TEXT
);

INSERT INTO test_table (id, username, email) VALUES
(1, 'john_doe', 'john@example.com'),
(2, 'jane_smith', 'jane@example.com'),
(3, 'bob_wilson', 'bob@example.com');

CREATE VIEW IF NOT EXISTS test_view AS
SELECT id, username, email
FROM test_table
WHERE id = 2;

100 changes: 100 additions & 0 deletions dbee/tests/testhelpers/sqlite.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package testhelpers

import (
"context"
"fmt"
"path/filepath"
"strings"
"time"

"github.com/docker/docker/api/types/container"
"github.com/kndndrj/nvim-dbee/dbee/adapters"
"github.com/kndndrj/nvim-dbee/dbee/core"
tc "github.com/testcontainers/testcontainers-go"
"github.com/testcontainers/testcontainers-go/wait"
)

type SQLiteContainer struct {
tc.Container
ConnURL string
Driver *core.Connection
TempDir string
}

// NewSQLiteContainer creates a new sqlite container with
// default adapter and connection. The params.URL is overwritten.
// It uses a temporary directory (usually the test suite tempDir) to store the db file.
// The tmpDir is then mounted to the container and all the dependencies are installed
// in the container file, while still being able to connect to the db file in the host.
func NewSQLiteContainer(ctx context.Context, params *core.ConnectionParams, tmpDir string) (*SQLiteContainer, error) {
seedFile, err := GetTestDataFile("sqlite_seed.sql")
if err != nil {
return nil, err
}

dbName, containerDBPath := "test.db", "/container/db"
entrypointCmd := []string{
"apk add sqlite=3.48.0-r0",
fmt.Sprintf("sqlite3 %s/%s < %s", containerDBPath, dbName, seedFile.Name()),
"echo 'ready'",
"tail -f /dev/null", // hack to keep the container running indefinitely
}

req := tc.ContainerRequest{
Image: "alpine:3.21",
Files: []tc.ContainerFile{
{
Reader: seedFile,
ContainerFilePath: seedFile.Name(),
FileMode: 0o755,
},
},
HostConfigModifier: func(hc *container.HostConfig) {
hc.Binds = append(hc.Binds, fmt.Sprintf("%s:%s", tmpDir, containerDBPath))
},
Cmd: []string{"sh", "-c", strings.Join(entrypointCmd, " && ")},
WaitingFor: wait.ForLog("ready").WithStartupTimeout(5 * time.Second),
}

ctr, err := tc.GenericContainer(ctx, tc.GenericContainerRequest{
ContainerRequest: req,
ProviderType: GetContainerProvider(),
Started: true,
})
if err != nil {
return nil, err
}

if params.Type == "" {
params.Type = "sqlite"
}

connURL := filepath.Join(tmpDir, dbName)
if params.URL == "" {
params.URL = connURL
}

driver, err := adapters.NewConnection(params)
if err != nil {
return nil, err
}

return &SQLiteContainer{
Container: ctr,
ConnURL: connURL,
Driver: driver,
TempDir: tmpDir,
}, nil
}

// NewDriver helper function to create a new driver with the connection URL.
func (p *SQLiteContainer) NewDriver(params *core.ConnectionParams) (*core.Connection, error) {
if params.URL == "" {
params.URL = p.ConnURL
}
if params.Type == "" {
params.Type = "sqlite"
}

return adapters.NewConnection(params)
}