Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2163b46907 | |||
| 81b6b08e7b | |||
| f31724a110 | |||
| 063a8ca837 | |||
| 1cfc94a42b | |||
| 053e07c319 | |||
| 0b91c379b8 | |||
| 7b4cc885f5 | |||
| 3b17ddd8a4 | |||
| 8d051a14e5 | |||
| 4735c391bd | |||
| 0866e5edac | |||
| 5c44dc5f54 | |||
| a7dd1ca340 | |||
| abcf7dbfe5 | |||
| d359f42b24 | |||
| 7cec5cee4c | |||
| be91cd54c6 | |||
| b141aaaa6c | |||
| 493ab846b9 | |||
| d3ebcb4666 | |||
| 50cf207eae | |||
| e5cbbb6822 | |||
| 18674568dd | |||
| 748dd96267 | |||
| c5578daa9f | |||
| 3bc7f539ad |
12
Makefile
12
Makefile
@@ -3,30 +3,32 @@ SHELL:=/bin/bash
|
|||||||
SOURCES=$(find . -name '*.go' -type f)
|
SOURCES=$(find . -name '*.go' -type f)
|
||||||
|
|
||||||
liblcl-2.2.3.zip:
|
liblcl-2.2.3.zip:
|
||||||
|
rm -f liblcl-2.2.3.zip
|
||||||
wget 'https://github.com/ying32/govcl/releases/download/v2.2.3/liblcl-2.2.3.zip'
|
wget 'https://github.com/ying32/govcl/releases/download/v2.2.3/liblcl-2.2.3.zip'
|
||||||
|
|
||||||
liblcl.so: liblcl-2.2.3.zip
|
liblcl.so: liblcl-2.2.3.zip
|
||||||
echo "154b4e4a1d5137a2ffe51cb4d0bf152dd997c12616ae30862775c0e4f0928e88 liblcl-2.2.3.zip" | sha256sum -c
|
echo "154b4e4a1d5137a2ffe51cb4d0bf152dd997c12616ae30862775c0e4f0928e88 liblcl-2.2.3.zip" | sha256sum -c
|
||||||
|
rm -f liblcl.so
|
||||||
unzip -j liblcl-2.2.3.zip linux64-gtk2/liblcl.so -d .
|
unzip -j liblcl-2.2.3.zip linux64-gtk2/liblcl.so -d .
|
||||||
touch liblcl.so
|
touch liblcl.so
|
||||||
|
|
||||||
liblcl.dll: liblcl-2.2.3.zip
|
liblcl.dll: liblcl-2.2.3.zip
|
||||||
echo "154b4e4a1d5137a2ffe51cb4d0bf152dd997c12616ae30862775c0e4f0928e88 liblcl-2.2.3.zip" | sha256sum -c
|
echo "154b4e4a1d5137a2ffe51cb4d0bf152dd997c12616ae30862775c0e4f0928e88 liblcl-2.2.3.zip" | sha256sum -c
|
||||||
|
rm -f liblcl.dll
|
||||||
unzip -j liblcl-2.2.3.zip win64/liblcl.dll -d .
|
unzip -j liblcl-2.2.3.zip win64/liblcl.dll -d .
|
||||||
touch liblcl.dll
|
touch liblcl.dll
|
||||||
|
|
||||||
yvbolt: $(SOURCES)
|
yvbolt: $(SOURCES)
|
||||||
GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -trimpath -ldflags '-s -w'
|
CGO_CFLAGS='-O2 -Wno-return-local-addr' GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -trimpath -ldflags '-s -w'
|
||||||
chmod 755 yvbolt
|
chmod 755 yvbolt
|
||||||
upx --best yvbolt
|
|
||||||
|
|
||||||
yvbolt.exe: $(SOURCES)
|
yvbolt.exe: $(SOURCES)
|
||||||
GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc-win32 go build -trimpath -ldflags '-s -w'
|
CGO_CFLAGS='-O2 -Wno-return-local-addr' GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc-win32 go build -trimpath -ldflags '-s -w -H windowsgui'
|
||||||
upx --best yvbolt.exe
|
upx --lzma yvbolt.exe
|
||||||
|
|
||||||
yvbolt.linux64.tar.xz: yvbolt liblcl.so
|
yvbolt.linux64.tar.xz: yvbolt liblcl.so
|
||||||
rm -f yvbolt.linux64.tar.xz
|
rm -f yvbolt.linux64.tar.xz
|
||||||
XZ_OPT='--best' tar caf yvbolt.linux64.tar.xz --owner=0 --group=0 yvbolt liblcl.so
|
XZ_OPT='-T0 -9' tar caf yvbolt.linux64.tar.xz --owner=0 --group=0 yvbolt liblcl.so
|
||||||
|
|
||||||
yvbolt.win64.zip: yvbolt.exe liblcl.dll
|
yvbolt.win64.zip: yvbolt.exe liblcl.dll
|
||||||
rm -f yvbolt.win64.zip
|
rm -f yvbolt.win64.zip
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -18,6 +18,7 @@ This is an experimental application and you should generally prefer to use [qbol
|
|||||||
- Bolt
|
- Bolt
|
||||||
- Recursive bucket support
|
- Recursive bucket support
|
||||||
- Option to open as readonly
|
- Option to open as readonly
|
||||||
|
- Debconf
|
||||||
- Pebble
|
- Pebble
|
||||||
- Redis
|
- Redis
|
||||||
- SQLite
|
- SQLite
|
||||||
@@ -40,6 +41,16 @@ This project includes trademarked logo images for each supported database type.
|
|||||||
|
|
||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
2024-06-30 v0.6.0
|
||||||
|
|
||||||
|
- Debconf: Add as supported database
|
||||||
|
- SQLite: Support table names containing special characters
|
||||||
|
- SQLite: Improvements for experimental command-line driver
|
||||||
|
- Redis: Improve connection dialog window position
|
||||||
|
- App: Cosmetic fixes for frame borders, help dialog, and Windows fonts+colours
|
||||||
|
- Build: Change compression parameters for release builds
|
||||||
|
- Build: Compile CGO with -O2 for release builds
|
||||||
|
|
||||||
2024-06-29 v0.5.0
|
2024-06-29 v0.5.0
|
||||||
|
|
||||||
- Pebble: Add as supported database
|
- Pebble: Add as supported database
|
||||||
@@ -53,6 +64,10 @@ This project includes trademarked logo images for each supported database type.
|
|||||||
- App: Add image icons for refresh and close context menu actions
|
- App: Add image icons for refresh and close context menu actions
|
||||||
- Build: Add makefile for cross-compiling release binaries
|
- Build: Add makefile for cross-compiling release binaries
|
||||||
|
|
||||||
|
[⬇️ Download for Windows x86_64](https://git.ivysaur.me/code.ivysaur.me/yvbolt/releases/download/v0.5.0/yvbolt.win64.zip)
|
||||||
|
|
||||||
|
[⬇️ Download for Linux x86_64](https://git.ivysaur.me/code.ivysaur.me/yvbolt/releases/download/v0.5.0/yvbolt.linux64.tar.xz)
|
||||||
|
|
||||||
2024-06-23 v0.4.0
|
2024-06-23 v0.4.0
|
||||||
|
|
||||||
- Redis: Add as supported database
|
- Redis: Add as supported database
|
||||||
|
|||||||
6
TODO
6
TODO
@@ -9,6 +9,7 @@
|
|||||||
- CLI using psql
|
- CLI using psql
|
||||||
- MSSQL (recursive navigation for instances)
|
- MSSQL (recursive navigation for instances)
|
||||||
- Other K/V stores from https://github.com/smallnest/kvbench
|
- Other K/V stores from https://github.com/smallnest/kvbench
|
||||||
|
- Windows registry
|
||||||
- SSH tunnels
|
- SSH tunnels
|
||||||
- Badger encryption key dialog
|
- Badger encryption key dialog
|
||||||
- Pebble: connection options dialog
|
- Pebble: connection options dialog
|
||||||
@@ -17,16 +18,15 @@
|
|||||||
- SQLite: drop table action
|
- SQLite: drop table action
|
||||||
- SQLite: show views, triggers, indexes in nav
|
- SQLite: show views, triggers, indexes in nav
|
||||||
- SQLite CLI driver:
|
- SQLite CLI driver:
|
||||||
- Basic error handling
|
|
||||||
- Better lexing
|
|
||||||
- Attach to SSH tunnel
|
- Attach to SSH tunnel
|
||||||
- Configure binary path
|
- Configure binary path
|
||||||
- https://ricardoanderegg.com/posts/sqlite-remote-explorer-gui/
|
- https://ricardoanderegg.com/posts/sqlite-remote-explorer-gui/
|
||||||
- https://github.com/litements/litexplore
|
- https://github.com/litements/litexplore
|
||||||
|
- Debconf: separate groups by first slash in name
|
||||||
- Build
|
- Build
|
||||||
- Makefile to cross-compile release binaries in docker
|
|
||||||
- Build own liblcl binaries in docker
|
- Build own liblcl binaries in docker
|
||||||
- Win32 icon resource
|
- Win32 icon resource
|
||||||
|
- https://github.com/ying32/govcl/tree/master/Tools/winRes
|
||||||
- Performance
|
- Performance
|
||||||
- Warning if data table is filtered to 1000 rows, or add pagination
|
- Warning if data table is filtered to 1000 rows, or add pagination
|
||||||
- Context/interrupt slow queries
|
- Context/interrupt slow queries
|
||||||
|
|||||||
BIN
assets/vendor_debian.png
Normal file
BIN
assets/vendor_debian.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 831 B |
142
db_debconf.go
Normal file
142
db_debconf.go
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"yvbolt/debconf"
|
||||||
|
|
||||||
|
"github.com/ying32/govcl/vcl"
|
||||||
|
)
|
||||||
|
|
||||||
|
type debconfLoadedDatabase struct {
|
||||||
|
displayName string
|
||||||
|
db *debconf.Database
|
||||||
|
nav *vcl.TTreeNode
|
||||||
|
|
||||||
|
arena []*navData // keepalive
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *debconfLoadedDatabase) DisplayName() string {
|
||||||
|
return ld.displayName
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *debconfLoadedDatabase) DriverName() string {
|
||||||
|
return "debconf"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *debconfLoadedDatabase) RootElement() *vcl.TTreeNode {
|
||||||
|
return ld.nav
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *debconfLoadedDatabase) Keepalive(ndata *navData) {
|
||||||
|
ld.arena = append(ld.arena, ndata)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *debconfLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
|
||||||
|
|
||||||
|
// Load properties
|
||||||
|
|
||||||
|
content := fmt.Sprintf("Entries: %d\nUnique attributes: %d\n", len(ld.db.Entries), len(ld.db.AllColumnNames))
|
||||||
|
f.propertiesBox.SetText(content)
|
||||||
|
|
||||||
|
// Load data
|
||||||
|
|
||||||
|
f.contentBox.SetEnabled(false)
|
||||||
|
f.contentBox.Clear()
|
||||||
|
|
||||||
|
// debconf always uses Key + Value as the columns
|
||||||
|
|
||||||
|
indexes := make(map[string]int)
|
||||||
|
|
||||||
|
f.contentBox.Columns().Clear()
|
||||||
|
for i, cname := range ld.db.AllColumnNames {
|
||||||
|
indexes[cname] = i
|
||||||
|
|
||||||
|
col := f.contentBox.Columns().Add()
|
||||||
|
col.SetCaption(cname)
|
||||||
|
col.SetWidth(MY_WIDTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, entry := range ld.db.Entries {
|
||||||
|
|
||||||
|
cell := f.contentBox.Items().Add()
|
||||||
|
cell.SetCaption(entry.Name)
|
||||||
|
|
||||||
|
texts := make([]string, len(ld.db.AllColumnNames))
|
||||||
|
for _, proppair := range entry.Properties {
|
||||||
|
texts[indexes[proppair[0]]-1 /* compensate for 'Name' always being first */] = proppair[1]
|
||||||
|
}
|
||||||
|
cell.SubItems().AddStrings2(texts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid
|
||||||
|
f.contentBox.SetEnabled(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *debconfLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
|
||||||
|
// In the debconf implementation, there is only one child: "Data"
|
||||||
|
if len(ndata.bucketPath) == 0 {
|
||||||
|
return []string{"Data"}, nil
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// No children deeper than that
|
||||||
|
return []string{}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *debconfLoadedDatabase) NavContext(ndata *navData) ([]contextAction, error) {
|
||||||
|
return nil, nil // No special actions are supported
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *debconfLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
|
||||||
|
vcl.ShowMessage("debconf doesn't support querying")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ld *debconfLoadedDatabase) Close() {
|
||||||
|
ld.arena = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var _ loadedDatabase = &debconfLoadedDatabase{} // interface assertion
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
func (f *TMainForm) debconfAddDatabaseFrom(path string) {
|
||||||
|
// TODO load in background thread to stop blocking the UI
|
||||||
|
|
||||||
|
fh, err := os.OpenFile(path, os.O_RDONLY, 0400)
|
||||||
|
if err != nil {
|
||||||
|
vcl.ShowMessage(fmt.Sprintf("Failed to load database: %s", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer fh.Close()
|
||||||
|
|
||||||
|
db, err := debconf.Parse(fh)
|
||||||
|
if err != nil {
|
||||||
|
vcl.ShowMessage(fmt.Sprintf("Failed to load database: %s", err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ld := &debconfLoadedDatabase{
|
||||||
|
db: db,
|
||||||
|
displayName: filepath.Base(path),
|
||||||
|
}
|
||||||
|
|
||||||
|
ld.nav = f.Buckets.Items().Add(nil, ld.displayName)
|
||||||
|
ld.nav.SetHasChildren(true) // dynamically populate in OnNavExpanding
|
||||||
|
ld.nav.SetImageIndex(imgDatabase)
|
||||||
|
ld.nav.SetSelectedIndex(imgDatabase)
|
||||||
|
navData := &navData{
|
||||||
|
ld: ld,
|
||||||
|
childrenLoaded: false, // will be loaded dynamically
|
||||||
|
bucketPath: []string{}, // empty = root
|
||||||
|
}
|
||||||
|
ld.nav.SetData(unsafe.Pointer(navData))
|
||||||
|
|
||||||
|
f.dbs = append(f.dbs, ld)
|
||||||
|
f.Buckets.SetSelected(ld.nav) // Select new element
|
||||||
|
|
||||||
|
ld.Keepalive(navData)
|
||||||
|
}
|
||||||
@@ -85,7 +85,7 @@ func (ld *sqliteLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
|
|||||||
// TODO
|
// TODO
|
||||||
|
|
||||||
// Select * with small limit
|
// Select * with small limit
|
||||||
datar, err := ld.db.Query(`SELECT * FROM ` + tableName + ` LIMIT 1000`) // WARNING can't prepare this parameter, but it comes from the DB (trusted)
|
datar, err := ld.db.Query(`SELECT * FROM "` + tableName + `" LIMIT 1000`) // WARNING can't prepare this parameter, but it comes from the DB (trusted)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
vcl.ShowMessageFmt("Failed to load data for table %q: %s", tableName, err.Error())
|
vcl.ShowMessageFmt("Failed to load data for table %q: %s", tableName, err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -3,10 +3,16 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"yvbolt/sqliteclidriver"
|
||||||
|
|
||||||
sqlite3 "github.com/mattn/go-sqlite3"
|
sqlite3 "github.com/mattn/go-sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (ld *sqliteLoadedDatabase) DriverName() string {
|
func (ld *sqliteLoadedDatabase) DriverName() string {
|
||||||
|
if _, ok := ld.db.Driver().(*sqliteclidriver.SCDriver); ok {
|
||||||
|
return "SQLite (sqliteclidriver)"
|
||||||
|
}
|
||||||
|
|
||||||
ver1, _, _ := sqlite3.Version()
|
ver1, _, _ := sqlite3.Version()
|
||||||
return "SQLite " + ver1
|
return "SQLite " + ver1
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"yvbolt/sqliteclidriver"
|
||||||
|
|
||||||
_ "modernc.org/sqlite"
|
_ "modernc.org/sqlite"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (ld *sqliteLoadedDatabase) DriverName() string {
|
func (ld *sqliteLoadedDatabase) DriverName() string {
|
||||||
|
if _, ok := ld.db.Driver().(*sqliteclidriver.SCDriver); ok {
|
||||||
|
return "SQLite (sqliteclidriver)"
|
||||||
|
}
|
||||||
|
|
||||||
return "SQLite (modernc.org)"
|
return "SQLite (modernc.org)"
|
||||||
}
|
}
|
||||||
|
|||||||
94
debconf/debconf.go
Normal file
94
debconf/debconf.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package debconf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefaultConfigDat = `/var/cache/debconf/config.dat`
|
||||||
|
DefaultPasswordsDat = `/var/cache/debconf/passwords.dat`
|
||||||
|
DefaultTemplatesDat = `/var/cache/debconf/templates.dat`
|
||||||
|
)
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
Name string
|
||||||
|
Properties [][2]string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Database struct {
|
||||||
|
Entries []Entry
|
||||||
|
AllColumnNames []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Parse(r io.Reader) (*Database, error) {
|
||||||
|
sc := bufio.NewScanner(r)
|
||||||
|
|
||||||
|
var entries []Entry
|
||||||
|
var wip Entry
|
||||||
|
var linenum int = 0
|
||||||
|
|
||||||
|
knownColumnNames := map[string]struct{}{
|
||||||
|
"Name": struct{}{},
|
||||||
|
}
|
||||||
|
var discoveredColumns []string = []string{"Name"}
|
||||||
|
|
||||||
|
for sc.Scan() {
|
||||||
|
linenum++
|
||||||
|
line := sc.Text()
|
||||||
|
|
||||||
|
if line == "" {
|
||||||
|
if wip.Name != "" {
|
||||||
|
entries = append(entries, wip)
|
||||||
|
wip = Entry{}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if line[0] == ' ' {
|
||||||
|
// continuation of last text entry
|
||||||
|
if len(wip.Properties) == 0 {
|
||||||
|
return nil, fmt.Errorf("Continuation of nonexistent entry on line %d", linenum)
|
||||||
|
}
|
||||||
|
|
||||||
|
wip.Properties[len(wip.Properties)-1][1] += line[1:]
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// New pair on current element
|
||||||
|
key, rest, ok := strings.Cut(line, `:`)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("Missing : on line %d", linenum)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, ok := knownColumnNames[key]; !ok {
|
||||||
|
knownColumnNames[key] = struct{}{}
|
||||||
|
discoveredColumns = append(discoveredColumns, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
rest = strings.TrimLeft(rest, " \t")
|
||||||
|
|
||||||
|
if key == `Name` {
|
||||||
|
wip.Name = rest
|
||||||
|
} else {
|
||||||
|
wip.Properties = append(wip.Properties, [2]string{key, rest})
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
if sc.Err() != nil {
|
||||||
|
return nil, sc.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
if wip.Name != "" {
|
||||||
|
entries = append(entries, wip)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Database{
|
||||||
|
Entries: entries,
|
||||||
|
AllColumnNames: discoveredColumns,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
30
debconf/debconf_test.go
Normal file
30
debconf/debconf_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package debconf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDebconfParse(t *testing.T) {
|
||||||
|
src, err := os.Open(DefaultConfigDat)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
t.Skip(err)
|
||||||
|
}
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer src.Close()
|
||||||
|
|
||||||
|
db, err := Parse(src)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Parse: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(db.Entries) == 0 {
|
||||||
|
t.Errorf("expected >0 entries, got %v", len(db.Entries))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(db.AllColumnNames) == 0 {
|
||||||
|
t.Errorf("expected >0 column names, got %v", len(db.AllColumnNames))
|
||||||
|
}
|
||||||
|
}
|
||||||
2
go.mod
2
go.mod
@@ -5,6 +5,7 @@ go 1.19
|
|||||||
require (
|
require (
|
||||||
github.com/cockroachdb/pebble v1.0.0
|
github.com/cockroachdb/pebble v1.0.0
|
||||||
github.com/dgraph-io/badger/v4 v4.2.0
|
github.com/dgraph-io/badger/v4 v4.2.0
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.22
|
github.com/mattn/go-sqlite3 v1.14.22
|
||||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
|
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
|
||||||
github.com/redis/go-redis/v9 v9.5.3
|
github.com/redis/go-redis/v9 v9.5.3
|
||||||
@@ -32,7 +33,6 @@ require (
|
|||||||
github.com/golang/protobuf v1.5.3 // indirect
|
github.com/golang/protobuf v1.5.3 // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
github.com/google/flatbuffers v1.12.1 // indirect
|
github.com/google/flatbuffers v1.12.1 // indirect
|
||||||
github.com/google/uuid v1.3.0 // indirect
|
|
||||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
github.com/klauspost/compress v1.16.0 // indirect
|
github.com/klauspost/compress v1.16.0 // indirect
|
||||||
github.com/kr/pretty v0.3.1 // indirect
|
github.com/kr/pretty v0.3.1 // indirect
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -160,8 +160,8 @@ github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hf
|
|||||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const (
|
|||||||
imgTableDelete
|
imgTableDelete
|
||||||
imgTableSave
|
imgTableSave
|
||||||
imgVendorCockroach
|
imgVendorCockroach
|
||||||
|
imgVendorDebian
|
||||||
imgVendorDgraph
|
imgVendorDgraph
|
||||||
imgVendorGithub
|
imgVendorGithub
|
||||||
imgVendorMySQL
|
imgVendorMySQL
|
||||||
@@ -60,6 +61,7 @@ func loadImages(owner vcl.IComponent) *vcl.TImageList {
|
|||||||
ilist.Add(mustLoad("assets/table_delete.png"), nil)
|
ilist.Add(mustLoad("assets/table_delete.png"), nil)
|
||||||
ilist.Add(mustLoad("assets/table_save.png"), nil)
|
ilist.Add(mustLoad("assets/table_save.png"), nil)
|
||||||
ilist.Add(mustLoad("assets/vendor_cockroach.png"), nil)
|
ilist.Add(mustLoad("assets/vendor_cockroach.png"), nil)
|
||||||
|
ilist.Add(mustLoad("assets/vendor_debian.png"), nil)
|
||||||
ilist.Add(mustLoad("assets/vendor_dgraph.png"), nil)
|
ilist.Add(mustLoad("assets/vendor_dgraph.png"), nil)
|
||||||
ilist.Add(mustLoad("assets/vendor_github.png"), nil)
|
ilist.Add(mustLoad("assets/vendor_github.png"), nil)
|
||||||
ilist.Add(mustLoad("assets/vendor_mysql.png"), nil)
|
ilist.Add(mustLoad("assets/vendor_mysql.png"), nil)
|
||||||
|
|||||||
@@ -65,6 +65,15 @@ func Fields(input string) ([]string, error) {
|
|||||||
} else if c == '\\' {
|
} else if c == '\\' {
|
||||||
return nil, fmt.Errorf(`Unexpected \ at char %d`, pos)
|
return nil, fmt.Errorf(`Unexpected \ at char %d`, pos)
|
||||||
|
|
||||||
|
} else if c == '(' || c == ')' || c == '?' || c == ',' || c == '+' || c == '*' || c == '-' || c == '/' || c == '%' || c == ';' || c == '=' {
|
||||||
|
// Tokenize separately, even if they appear touching another top-level token
|
||||||
|
// Should still be safe to re-join
|
||||||
|
if len(wip) != 0 {
|
||||||
|
ret = append(ret, wip)
|
||||||
|
wip = ""
|
||||||
|
}
|
||||||
|
ret = append(ret, string(c))
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
wip += string(c)
|
wip += string(c)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,24 @@ func TestLexer(t *testing.T) {
|
|||||||
expectErr: false,
|
expectErr: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Special characters lexed as separate tokens, but only at top level
|
||||||
|
|
||||||
|
testCase{
|
||||||
|
input: `3+5*(2.3/6);`,
|
||||||
|
expect: []string{"3", `+`, "5", "*", "(", "2.3", "/", "6", ")", ";"},
|
||||||
|
expectErr: false,
|
||||||
|
},
|
||||||
|
testCase{
|
||||||
|
input: `SELECT "3+5*(2.3/6)" AS expression;`,
|
||||||
|
expect: []string{"SELECT", `"3+5*(2.3/6)"`, "AS", "expression", ";"},
|
||||||
|
expectErr: false,
|
||||||
|
},
|
||||||
|
testCase{
|
||||||
|
input: `INSERT INTO foo (bar, baz) VALUES (?, ?);`,
|
||||||
|
expect: []string{"INSERT", "INTO", "foo", "(", "bar", ",", "baz", ")", "VALUES", "(", "?", ",", "?", ")", ";"},
|
||||||
|
expectErr: false,
|
||||||
|
},
|
||||||
|
|
||||||
// Errors
|
// Errors
|
||||||
|
|
||||||
testCase{
|
testCase{
|
||||||
@@ -99,7 +117,7 @@ func TestLexer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !reflect.DeepEqual(out, tc.expect) {
|
if !reflect.DeepEqual(out, tc.expect) {
|
||||||
t.Errorf("Test %q got %v, expected %v", tc.input, out, tc.expect)
|
t.Errorf("Test %q\n- got: %#v\n- expected %#v", tc.input, out, tc.expect)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
42
main.go
42
main.go
@@ -3,6 +3,7 @@ package main
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"runtime"
|
||||||
"runtime/debug"
|
"runtime/debug"
|
||||||
"strings"
|
"strings"
|
||||||
"unsafe"
|
"unsafe"
|
||||||
@@ -10,7 +11,6 @@ import (
|
|||||||
"github.com/pkg/browser"
|
"github.com/pkg/browser"
|
||||||
"github.com/ying32/govcl/vcl"
|
"github.com/ying32/govcl/vcl"
|
||||||
"github.com/ying32/govcl/vcl/types"
|
"github.com/ying32/govcl/vcl/types"
|
||||||
"github.com/ying32/govcl/vcl/types/colors"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -79,6 +79,15 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
|
|||||||
|
|
||||||
//
|
//
|
||||||
|
|
||||||
|
mnuFileDebconf := vcl.NewMenuItem(mnuFile)
|
||||||
|
mnuFileDebconf.SetCaption("Debconf")
|
||||||
|
mnuFileDebconf.SetImageIndex(imgVendorDebian)
|
||||||
|
mnuFile.Add(mnuFileDebconf)
|
||||||
|
|
||||||
|
vcl_menuitem(mnuFileDebconf, "Open database...", imgDatabaseAdd, f.OnMnuFileDebianOpenClick)
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
mnuFilePebble := vcl.NewMenuItem(mnuFile)
|
mnuFilePebble := vcl.NewMenuItem(mnuFile)
|
||||||
mnuFilePebble.SetCaption("Pebble")
|
mnuFilePebble.SetCaption("Pebble")
|
||||||
mnuFilePebble.SetImageIndex(imgVendorCockroach)
|
mnuFilePebble.SetImageIndex(imgVendorCockroach)
|
||||||
@@ -187,8 +196,8 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
|
|||||||
f.propertiesBox.BorderSpacing().SetAround(MY_SPACING)
|
f.propertiesBox.BorderSpacing().SetAround(MY_SPACING)
|
||||||
f.propertiesBox.SetAlign(types.AlClient) // fill remaining space
|
f.propertiesBox.SetAlign(types.AlClient) // fill remaining space
|
||||||
f.propertiesBox.SetReadOnly(true)
|
f.propertiesBox.SetReadOnly(true)
|
||||||
f.propertiesBox.SetEnabled(true) // Need to leave it enabled so scrolling works
|
f.propertiesBox.SetEnabled(true) // Need to leave it enabled so scrolling works
|
||||||
f.propertiesBox.SetColor(colors.ClForm) // 0x00f0f0f0
|
f.propertiesBox.SetColor(vcl_default_tab_background())
|
||||||
|
|
||||||
f.propertiesBox.SetBorderStyle(types.BsNone)
|
f.propertiesBox.SetBorderStyle(types.BsNone)
|
||||||
f.propertiesBox.SetScrollBars(types.SsAutoVertical)
|
f.propertiesBox.SetScrollBars(types.SsAutoVertical)
|
||||||
@@ -217,8 +226,9 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
|
|||||||
queryButtonBar.SetAlign(types.AlTop)
|
queryButtonBar.SetAlign(types.AlTop)
|
||||||
queryButtonBar.BorderSpacing().SetLeft(MY_SPACING)
|
queryButtonBar.BorderSpacing().SetLeft(MY_SPACING)
|
||||||
queryButtonBar.BorderSpacing().SetTop(MY_SPACING)
|
queryButtonBar.BorderSpacing().SetTop(MY_SPACING)
|
||||||
queryButtonBar.BorderSpacing().SetBottom(0)
|
//queryButtonBar.BorderSpacing().SetBottom(1)
|
||||||
queryButtonBar.BorderSpacing().SetRight(MY_SPACING)
|
queryButtonBar.BorderSpacing().SetRight(MY_SPACING)
|
||||||
|
queryButtonBar.SetEdgeBorders(0)
|
||||||
queryButtonBar.SetImages(f.ImageList)
|
queryButtonBar.SetImages(f.ImageList)
|
||||||
queryButtonBar.SetShowCaptions(true)
|
queryButtonBar.SetShowCaptions(true)
|
||||||
|
|
||||||
@@ -233,10 +243,15 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
|
|||||||
f.queryInput.SetHeight(MY_HEIGHT)
|
f.queryInput.SetHeight(MY_HEIGHT)
|
||||||
f.queryInput.SetAlign(types.AlTop)
|
f.queryInput.SetAlign(types.AlTop)
|
||||||
f.queryInput.SetTop(1)
|
f.queryInput.SetTop(1)
|
||||||
f.queryInput.Font().SetName("monospace")
|
if runtime.GOOS == "windows" {
|
||||||
|
f.queryInput.Font().SetName("Consolas")
|
||||||
|
} else {
|
||||||
|
f.queryInput.Font().SetName("monospace")
|
||||||
|
}
|
||||||
f.queryInput.BorderSpacing().SetLeft(MY_SPACING)
|
f.queryInput.BorderSpacing().SetLeft(MY_SPACING)
|
||||||
f.queryInput.BorderSpacing().SetTop(0)
|
//f.queryInput.BorderSpacing().SetTop(1)
|
||||||
f.queryInput.BorderSpacing().SetRight(MY_SPACING)
|
f.queryInput.BorderSpacing().SetRight(MY_SPACING)
|
||||||
|
f.queryInput.SetBorderStyle(types.BsFrame)
|
||||||
|
|
||||||
vsplit := vcl.NewSplitter(queryTab)
|
vsplit := vcl.NewSplitter(queryTab)
|
||||||
vsplit.SetParent(queryTab)
|
vsplit.SetParent(queryTab)
|
||||||
@@ -322,6 +337,19 @@ func (f *TMainForm) OnMnuFilePebbleOpenClick(sender vcl.IObject) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *TMainForm) OnMnuFileDebianOpenClick(sender vcl.IObject) {
|
||||||
|
dlg := vcl.NewOpenDialog(f)
|
||||||
|
dlg.SetTitle("Select a database file...")
|
||||||
|
dlg.SetFilter("Debconf database|*.dat|All files|*.*")
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
dlg.SetInitialDir(`/var/cache/debconf/`)
|
||||||
|
}
|
||||||
|
ret := dlg.Execute() // Fake blocking
|
||||||
|
if ret {
|
||||||
|
f.debconfAddDatabaseFrom(dlg.FileName())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (f *TMainForm) OnMnuFilePebbleMemoryClick(sender vcl.IObject) {
|
func (f *TMainForm) OnMnuFilePebbleMemoryClick(sender vcl.IObject) {
|
||||||
f.pebbleAddDatabaseFromMemory()
|
f.pebbleAddDatabaseFromMemory()
|
||||||
}
|
}
|
||||||
@@ -368,7 +396,7 @@ func (f *TMainForm) OnMenuHelpVersion(sender vcl.IObject) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
info := "This version of " + APPNAME + " was compiled with:\n"
|
info := "This version of " + APPNAME + " was compiled with:\n\n"
|
||||||
for _, dep := range bi.Deps {
|
for _, dep := range bi.Deps {
|
||||||
|
|
||||||
// Filter to only interesting things
|
// Filter to only interesting things
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ type TRedisConnectionDialog struct {
|
|||||||
func (f *TRedisConnectionDialog) OnFormCreate(sender vcl.IObject) {
|
func (f *TRedisConnectionDialog) OnFormCreate(sender vcl.IObject) {
|
||||||
|
|
||||||
f.SetCaption("Connect to Redis...")
|
f.SetCaption("Connect to Redis...")
|
||||||
f.ScreenCenter()
|
|
||||||
f.SetWidth(320)
|
f.SetWidth(320)
|
||||||
f.SetHeight(160)
|
f.SetHeight(160)
|
||||||
|
f.SetPosition(types.PoOwnerFormCenter)
|
||||||
|
|
||||||
// row 1
|
// row 1
|
||||||
|
|
||||||
|
|||||||
115
sqliteclidriver/eventcmd.go
Normal file
115
sqliteclidriver/eventcmd.go
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
package sqliteclidriver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
evtypeStdout int = iota
|
||||||
|
evtypeStderr
|
||||||
|
evtypeExit
|
||||||
|
)
|
||||||
|
|
||||||
|
type processEvent struct {
|
||||||
|
evtype int
|
||||||
|
data []byte
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pe processEvent) Error() string {
|
||||||
|
if pe.err != nil {
|
||||||
|
return pe.err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
if pe.evtype == evtypeStderr {
|
||||||
|
return string(pe.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
return "<no error>"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pe processEvent) Unwrap() error {
|
||||||
|
return pe.err
|
||||||
|
}
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
|
func ExecEvents(cmd *exec.Cmd) (<-chan processEvent, io.WriteCloser, error) {
|
||||||
|
|
||||||
|
pw, err := cmd.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pr, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pe, err := cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
chEvents := make(chan processEvent, 0)
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
processEventWorker(pr, evtypeStdout, chEvents)
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
processEventWorker(pe, evtypeStderr, chEvents)
|
||||||
|
}()
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// Only call cmd.Wait() after pipes are closed
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
err = cmd.Wait()
|
||||||
|
chEvents <- processEvent{
|
||||||
|
evtype: evtypeExit,
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
|
||||||
|
close(chEvents)
|
||||||
|
}()
|
||||||
|
|
||||||
|
return chEvents, pw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processEventWorker(p io.Reader, evtype int, dest chan<- processEvent) {
|
||||||
|
for {
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
n, err := p.Read(buf)
|
||||||
|
|
||||||
|
if n > 0 {
|
||||||
|
dest <- processEvent{
|
||||||
|
evtype: evtype,
|
||||||
|
data: buf[0:n],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
dest <- processEvent{
|
||||||
|
evtype: evtype,
|
||||||
|
err: err,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assume all errors are permanent
|
||||||
|
// Ordering can produce either io.EOF, ErrClosedPipe, or PathError{"file already closed"}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
sqliteclidriver/eventcmd_test.go
Normal file
69
sqliteclidriver/eventcmd_test.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package sqliteclidriver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEventCmd(t *testing.T) {
|
||||||
|
cmd := exec.Command("/bin/bash", "-c", `echo "hello world"`)
|
||||||
|
|
||||||
|
ch, _, err := ExecEvents(cmd)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var consume []processEvent
|
||||||
|
for ev := range ch {
|
||||||
|
consume = append(consume, ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect := []processEvent{
|
||||||
|
processEvent{evtype: evtypeStdout, data: []byte("hello world\n")},
|
||||||
|
processEvent{evtype: evtypeStdout, err: io.EOF},
|
||||||
|
processEvent{evtype: evtypeStderr, err: io.EOF},
|
||||||
|
processEvent{evtype: evtypeExit, err: nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.EqualValues(t, expect, consume)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEventCmdStdin(t *testing.T) {
|
||||||
|
cmd := exec.Command("/usr/bin/tr", "a-z", "A-Z")
|
||||||
|
|
||||||
|
ch, pw, err := ExecEvents(cmd)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg := sync.WaitGroup{}
|
||||||
|
wg.Add(1)
|
||||||
|
var consume []processEvent
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
for ev := range ch {
|
||||||
|
if ev.err != nil && errors.Is(ev.err, io.EOF) {
|
||||||
|
continue // skip flakey ordering of two EOF statements
|
||||||
|
}
|
||||||
|
consume = append(consume, ev)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
pw.Write([]byte("hello world"))
|
||||||
|
pw.Close()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
expect := []processEvent{
|
||||||
|
processEvent{evtype: evtypeStdout, data: []byte("HELLO WORLD")},
|
||||||
|
processEvent{evtype: evtypeExit, err: nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.EqualValues(t, expect, consume)
|
||||||
|
}
|
||||||
85
sqliteclidriver/orderedkv.go
Normal file
85
sqliteclidriver/orderedkv.go
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
package sqliteclidriver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Pair struct {
|
||||||
|
Key string
|
||||||
|
Value any
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderedKV []Pair
|
||||||
|
|
||||||
|
func (o *OrderedKV) UnmarshalJSON(data []byte) error {
|
||||||
|
// Rough estimate malloc size based on number of `:`
|
||||||
|
// This is a lower bound since there might be nested elements
|
||||||
|
var elCount int64
|
||||||
|
for _, c := range data {
|
||||||
|
if c == ':' {
|
||||||
|
elCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*o = make([]Pair, 0, elCount)
|
||||||
|
|
||||||
|
// Parse the initial opening { delimiter from the JSON stream
|
||||||
|
|
||||||
|
reader := bytes.NewReader(data)
|
||||||
|
dec := json.NewDecoder(reader)
|
||||||
|
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("expected '{': %w", err)
|
||||||
|
}
|
||||||
|
if d, ok := tok.(json.Delim); !ok || (rune(d) != '{') {
|
||||||
|
return fmt.Errorf("expected '{', got %v", tok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read remaining content
|
||||||
|
|
||||||
|
for dec.More() {
|
||||||
|
|
||||||
|
var p Pair
|
||||||
|
|
||||||
|
// Parse key: either string or Delim('}')
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("expected '{': %w", err)
|
||||||
|
}
|
||||||
|
switch tok := tok.(type) {
|
||||||
|
case json.Delim:
|
||||||
|
if rune(tok) == '}' {
|
||||||
|
// Finished
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// Something else
|
||||||
|
return fmt.Errorf("expected string or }, got %v", tok)
|
||||||
|
|
||||||
|
case string:
|
||||||
|
// Valid key
|
||||||
|
p.Key = tok
|
||||||
|
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("expected string or }, got %v", tok)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse value (any)
|
||||||
|
err = dec.Decode(&p.Value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
*o = append(*o, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert that there is no remaining content
|
||||||
|
if reader.Len() != 0 {
|
||||||
|
return fmt.Errorf("Unexpected trailing data (%d bytes remaining)", reader.Len())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Done
|
||||||
|
return nil
|
||||||
|
}
|
||||||
30
sqliteclidriver/orderedkv_test.go
Normal file
30
sqliteclidriver/orderedkv_test.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
package sqliteclidriver
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOrderedKV(t *testing.T) {
|
||||||
|
|
||||||
|
input := `
|
||||||
|
{
|
||||||
|
"zzz": "foo",
|
||||||
|
"aaa": "bar"
|
||||||
|
}
|
||||||
|
`
|
||||||
|
var got OrderedKV
|
||||||
|
err := json.Unmarshal([]byte(input), &got)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expect := OrderedKV{
|
||||||
|
Pair{Key: "zzz", Value: "foo"},
|
||||||
|
Pair{Key: "aaa", Value: "bar"},
|
||||||
|
}
|
||||||
|
|
||||||
|
require.EqualValues(t, expect, got)
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@
|
|||||||
// Functionality is limited.
|
// Functionality is limited.
|
||||||
//
|
//
|
||||||
// Known caveats:
|
// Known caveats:
|
||||||
// - Lexer only understands ? if it's separated by spaces
|
|
||||||
// - Bad error handling
|
// - Bad error handling
|
||||||
// - Few supported types
|
// - Few supported types
|
||||||
// - Has to escape parameters for CLI instead of preparing them, so not safe for untrusted usage
|
// - Has to escape parameters for CLI instead of preparing them, so not safe for untrusted usage
|
||||||
@@ -23,7 +22,10 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
"yvbolt/lexer"
|
"yvbolt/lexer"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
var ErrNotSupported = errors.New("Not supported")
|
var ErrNotSupported = errors.New("Not supported")
|
||||||
@@ -37,29 +39,13 @@ func (d *SCDriver) Open(connectionString string) (driver.Conn, error) {
|
|||||||
|
|
||||||
cmd := exec.Command(`/usr/bin/sqlite3`, `-noheader`, `-json`, connectionString) // n.b. doesn't support `--`
|
cmd := exec.Command(`/usr/bin/sqlite3`, `-noheader`, `-json`, connectionString) // n.b. doesn't support `--`
|
||||||
|
|
||||||
pw, err := cmd.StdinPipe()
|
chEvents, pw, err := ExecEvents(cmd)
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
pr, err := cmd.StdoutPipe()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
pe, err := cmd.StderrPipe()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = cmd.Start()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &SCConn{
|
return &SCConn{
|
||||||
stdout: pr,
|
listen: chEvents,
|
||||||
stderr: pe,
|
|
||||||
w: pw,
|
w: pw,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
@@ -90,8 +76,7 @@ var _ driver.Connector = &SCConnector{} // interface assertion
|
|||||||
//
|
//
|
||||||
|
|
||||||
type SCConn struct {
|
type SCConn struct {
|
||||||
stdout io.Reader
|
listen <-chan processEvent
|
||||||
stderr io.Reader
|
|
||||||
w io.WriteCloser
|
w io.WriteCloser
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +90,10 @@ func (c *SCConn) Prepare(query string) (driver.Stmt, error) {
|
|||||||
return nil, errors.New("Empty query")
|
return nil, errors.New("Empty query")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if f[len(f)-1] != ";" {
|
||||||
|
f = append(f, ";") // Query must end in semicolon
|
||||||
|
}
|
||||||
|
|
||||||
return &SCStmt{
|
return &SCStmt{
|
||||||
conn: c,
|
conn: c,
|
||||||
query: f,
|
query: f,
|
||||||
@@ -216,24 +205,102 @@ func (s *SCStmt) Exec(args []driver.Value) (driver.Result, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *SCStmt) Query(args []driver.Value) (driver.Rows, error) {
|
func (s *SCStmt) Query(args []driver.Value) (driver.Rows, error) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
submit, err := s.buildQuery(args)
|
submit, err := s.buildQuery(args)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If there are no results to the query, the sqlite3 -json mode does not
|
||||||
|
// print anything on stdout at all and we would hang forever
|
||||||
|
// Add a followup sentinel query that we can detect
|
||||||
|
const sentinelKey = `__sqliteclidriver_sentinel`
|
||||||
|
sentinelVal := uuid.Must(uuid.NewRandom()).String()
|
||||||
|
submit = append(submit, []byte(fmt.Sprintf("SELECT \"%s\" AS %s;\n", sentinelVal, sentinelKey))...)
|
||||||
|
|
||||||
|
//
|
||||||
|
|
||||||
_, err = io.CopyN(s.conn.w, bytes.NewReader(submit), int64(len(submit)))
|
_, err = io.CopyN(s.conn.w, bytes.NewReader(submit), int64(len(submit)))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("Write: %w", err)
|
return nil, fmt.Errorf("Write: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Consume process events until either error or the json decoder is satisfied
|
||||||
|
pr, pw := io.Pipe()
|
||||||
|
|
||||||
|
listenContext, listenContextCancel := context.WithCancel(ctx) // Use to stop signalling once json decoder is satisfied
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer pw.Close()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case msg, ok := <-s.conn.listen:
|
||||||
|
if !ok {
|
||||||
|
pw.CloseWithError(fmt.Errorf("process already closed"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if msg.err != nil {
|
||||||
|
pw.CloseWithError(msg.err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if msg.evtype == evtypeStdout {
|
||||||
|
_, err := io.CopyN(pw, bytes.NewReader(msg.data), int64(len(msg.data)))
|
||||||
|
if err != nil {
|
||||||
|
pw.CloseWithError(msg.err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Anything else (process event / stderr)
|
||||||
|
// Throw
|
||||||
|
pw.CloseWithError(msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-listenContext.Done():
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// We expect some kind of thing on stdout
|
// We expect some kind of thing on stdout
|
||||||
ret := []map[string]any{}
|
// If something happens on stderr, or to the process, pr will read an error
|
||||||
err = json.NewDecoder(s.conn.stdout).Decode(&ret)
|
ret := []OrderedKV{}
|
||||||
|
decoder := json.NewDecoder(pr)
|
||||||
|
err = decoder.Decode(&ret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if this was the data or the sentinel
|
||||||
|
wasSentinel := false
|
||||||
|
if len(ret) > 0 && len(ret[0]) > 0 && ret[0][0].Key == sentinelKey {
|
||||||
|
if check, ok := ret[0][0].Value.(string); ok && check == sentinelVal {
|
||||||
|
// It was the sentinel
|
||||||
|
wasSentinel = true
|
||||||
|
// Nothing more to parse
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if wasSentinel {
|
||||||
|
// There was no data.
|
||||||
|
// Wipe out `ret`
|
||||||
|
ret = nil
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// There was data.
|
||||||
|
// Need to decode again (from the same decoder reader) until we find the sentinel
|
||||||
|
surplus := []map[string]any{}
|
||||||
|
err = decoder.Decode(&surplus)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listenContextCancel()
|
||||||
|
|
||||||
// Drain stderr
|
// Drain stderr
|
||||||
// TODO
|
// TODO
|
||||||
|
|
||||||
@@ -243,8 +310,8 @@ func (s *SCStmt) Query(args []driver.Value) (driver.Rows, error) {
|
|||||||
|
|
||||||
var columnNames []string
|
var columnNames []string
|
||||||
if len(ret) > 0 {
|
if len(ret) > 0 {
|
||||||
for k, _ := range ret[0] {
|
for _, cell := range ret[0] {
|
||||||
columnNames = append(columnNames, k)
|
columnNames = append(columnNames, cell.Key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,7 +345,7 @@ var _ driver.Result = &SCResult{} // interface assertion
|
|||||||
type SCRows struct {
|
type SCRows struct {
|
||||||
idx int
|
idx int
|
||||||
columns []string
|
columns []string
|
||||||
data []map[string]any
|
data []OrderedKV
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *SCRows) Columns() []string {
|
func (r *SCRows) Columns() []string {
|
||||||
@@ -301,12 +368,9 @@ func (r *SCRows) Next(dest []driver.Value) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for i := 0; i < len(dest); i++ {
|
for i := 0; i < len(dest); i++ {
|
||||||
cell, ok := r.data[r.idx][r.columns[i]]
|
cell := r.data[r.idx][i]
|
||||||
if !ok {
|
|
||||||
return fmt.Errorf("Result row %d is missing column #%d %q, unexpected", r.idx, i, r.columns[i])
|
|
||||||
}
|
|
||||||
|
|
||||||
dest[i] = cell
|
dest[i] = cell.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
r.idx++
|
r.idx++
|
||||||
|
|||||||
@@ -55,3 +55,24 @@ func TestSqliteCliDriver(t *testing.T) {
|
|||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSqliteCliDriverNoResults(t *testing.T) {
|
||||||
|
db, err := sql.Open("sqliteclidriver", ":memory:")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Repeat this part to ensure we can make followup queries on the same connection
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
_, err = db.Query(`SELECT 1 AS expect_no_result WHERE 1=2`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Mix of results and no-results
|
||||||
|
rr := db.QueryRow(`SELECT 1 AS expect_result WHERE 1=1`)
|
||||||
|
require.NoError(t, rr.Err())
|
||||||
|
|
||||||
|
var result int64
|
||||||
|
err = rr.Scan(&result)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.EqualValues(t, result, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
13
util_vcl.go
13
util_vcl.go
@@ -1,8 +1,11 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"runtime"
|
||||||
|
|
||||||
"github.com/ying32/govcl/vcl"
|
"github.com/ying32/govcl/vcl"
|
||||||
"github.com/ying32/govcl/vcl/types"
|
"github.com/ying32/govcl/vcl/types"
|
||||||
|
"github.com/ying32/govcl/vcl/types/colors"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -49,3 +52,13 @@ func vcl_menuseparator(parent *vcl.TMenuItem) *vcl.TMenuItem {
|
|||||||
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func vcl_default_tab_background() types.TColor {
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
// Assuming that uxtheme is loaded
|
||||||
|
// @ref https://stackoverflow.com/a/20332712
|
||||||
|
return colors.ClBtnHighlight
|
||||||
|
} else {
|
||||||
|
return colors.ClBtnFace // 0x00f0f0f0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user