Compare commits

..

No commits in common. "master" and "v0.5.0" have entirely different histories.

39 changed files with 258 additions and 1569 deletions

View File

@ -1,34 +1,32 @@
SHELL:=/bin/bash SHELL:=/bin/bash
SOURCES=$(find . -name '*.go' -type f) SOURCES=$(find . -name '*.go' -type f)
.DEFAULT_GOAL := dist
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)
CGO_CFLAGS='-O2 -Wno-return-local-addr' GOOS=linux GOARCH=amd64 CGO_ENABLED=1 go build -trimpath -ldflags '-s -w' 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)
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' GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc-win32 go build -trimpath -ldflags '-s -w'
upx --lzma yvbolt.exe upx --best 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='-T0 -9' tar caf yvbolt.linux64.tar.xz --owner=0 --group=0 yvbolt liblcl.so XZ_OPT='--best' 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

View File

@ -1,11 +1,13 @@
# yvbolt # yvbolt
A graphical interface for multiple databases using [GoVCL](https://z-kit.cc/en/). A graphical browser for multiple databases using [GoVCL](https://z-kit.cc/en/).
This is an experimental application and you should generally prefer to use [qbolt](https://code.ivysaur.me/qbolt).
## Features ## Features
- Native desktop application, running on Linux, Windows, and macOS - Native desktop application, running on Linux, Windows, and macOS
- Connect to multiple databases at once - Connect to multiple databases
- Browse table/bucket content - Browse table/bucket content
- Use context menu to perform special table/bucket actions - Use context menu to perform special table/bucket actions
- Run custom SQL queries - Run custom SQL queries
@ -15,16 +17,11 @@ A graphical interface for multiple databases using [GoVCL](https://z-kit.cc/en/)
- Badger v4 - Badger v4
- Bolt - Bolt
- Recursive bucket support - Recursive bucket support
- Option to open as readonly for shared access - Option to open as readonly
- Supports editing
- See also [qbolt](https://code.ivysaur.me/qbolt) for more/different functionality
- Debconf
- Pebble - Pebble
- Redis - Redis
- SQLite - SQLite
- Drivers: mattn (CGo), modernc.org (no cgo), experimental command-line driver - Drivers: mattn (CGo), modernc.org (no cgo), experimental command-line driver
- Supports editing
- Integrated vacuum and export commands
## License ## License
@ -34,7 +31,7 @@ This project redistributes images from the famfamfam/silk icon set under the [CC
This project includes trademarked logo images for each supported database type. This project includes trademarked logo images for each supported database type.
## Compiling ## Usage
1. `CGO_ENABLED=1 go build` 1. `CGO_ENABLED=1 go build`
2. [Download liblcl](https://github.com/ying32/govcl/releases/download/v2.2.3/liblcl-2.2.3.zip) for your platform, or [compile it yourself](https://github.com/ying32/liblcl) (tested with v2.2.3) 2. [Download liblcl](https://github.com/ying32/govcl/releases/download/v2.2.3/liblcl-2.2.3.zip) for your platform, or [compile it yourself](https://github.com/ying32/liblcl) (tested with v2.2.3)
@ -43,34 +40,6 @@ This project includes trademarked logo images for each supported database type.
## Changelog ## Changelog
2024-07-18 v0.7.0
- SQLite, Bolt: Initial support for editing data (insert, per-cell update, delete)
- SQLite: Add context menu actions for compact (vacuum), export, and drop table
- App: New grid widget
- App: Add refresh button
- App: Bigger window size, use icons for toolbars, better UI colours for Windows
- App: Prevent submitting blank queries to database
- Refactor database interface and error handling
[⬇️ Download for Windows x86_64](https://git.ivysaur.me/code.ivysaur.me/yvbolt/releases/download/v0.7.0/yvbolt.win64.zip)
[⬇️ Download for Linux x86_64](https://git.ivysaur.me/code.ivysaur.me/yvbolt/releases/download/v0.7.0/yvbolt.linux64.tar.xz)
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
[⬇️ Download for Windows x86_64](https://git.ivysaur.me/code.ivysaur.me/yvbolt/releases/download/v0.6.0/yvbolt.win64.zip)
[⬇️ Download for Linux x86_64](https://git.ivysaur.me/code.ivysaur.me/yvbolt/releases/download/v0.6.0/yvbolt.linux64.tar.xz)
2024-06-29 v0.5.0 2024-06-29 v0.5.0
- Pebble: Add as supported database - Pebble: Add as supported database
@ -84,10 +53,6 @@ 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

33
TODO
View File

@ -1,10 +1,6 @@
- Syntax highlighting in editor - Insert
- Mutation - Update cell
- Get real primary key for mutation instead of string approximation - Delete row(s)
- Badger: Support insert/update/delete
- Pebble: Support insert/update/delete
- Debconf: Support insert/update/delete
- Redis: Support insert/update/delete
- Binary data viewer - Binary data viewer
- Detect jpg/png and show as image - Detect jpg/png and show as image
- More DB types - More DB types
@ -13,29 +9,26 @@
- 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
- LDAP - Badger encryption key dialog
- Connection dialog - Pebble: connection options dialog
- SSH tunnels - SQLite: DB compact action
- Badger encryption key dialog - SQLite: DB export backup action
- Pebble: connection options dialog - SQLite: drop table action
- SQLite: show views, triggers, indexes in nav
- SQLite CLI driver: - SQLite CLI driver:
- Context support - 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
- SQLite: drop table doesn't autorefresh nav since callback is late
- 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
- Faster virtual rendering - Faster virtual rendering
- https://github.com/ying32/govcl/blob/master/samples/listviewvirtualdata/main.go - https://github.com/ying32/govcl/blob/master/samples/listviewvirtualdata/main.go
- Query history
- Test suite
- `CREATE TABLE foo (id integer primary key, aaa text not null, bbb text not null);`

Binary file not shown.

Before

Width:  |  Height:  |  Size: 733 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 715 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 589 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 603 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 666 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 831 B

View File

@ -7,6 +7,7 @@ import (
"github.com/dgraph-io/badger/v4" "github.com/dgraph-io/badger/v4"
"github.com/ying32/govcl/vcl" "github.com/ying32/govcl/vcl"
"github.com/ying32/govcl/vcl/types"
) )
type badgerLoadedDatabase struct { type badgerLoadedDatabase struct {
@ -33,7 +34,7 @@ func (ld *badgerLoadedDatabase) Keepalive(ndata *navData) {
ld.arena = append(ld.arena, ndata) ld.arena = append(ld.arena, ndata)
} }
func (ld *badgerLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error { func (ld *badgerLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
// Load properties // Load properties
@ -41,15 +42,25 @@ func (ld *badgerLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error
f.propertiesBox.SetText(content) f.propertiesBox.SetText(content)
// Load data // Load data
f.contentBox.SetEnabled(false)
f.contentBox.Clear()
// Badger always uses Key + Value as the columns // Badger always uses Key + Value as the columns
f.contentBox.Columns().Clear()
colKey := f.contentBox.Columns().Add() colKey := f.contentBox.Columns().Add()
colKey.Title().SetCaption("Key") colKey.SetCaption("Key")
colKey.SetWidth(MY_WIDTH)
colKey.SetAlignment(types.TaLeftJustify)
colVal := f.contentBox.Columns().Add() colVal := f.contentBox.Columns().Add()
colVal.Title().SetCaption("Value") colVal.SetCaption("Value")
err := ld.db.View(func(txn *badger.Txn) error { err := ld.db.View(func(txn *badger.Txn) error {
// Valid
f.contentBox.Clear()
// Create iterator // Create iterator
opts := badger.DefaultIteratorOptions opts := badger.DefaultIteratorOptions
opts.PrefetchSize = 64 opts.PrefetchSize = 64
@ -60,10 +71,9 @@ func (ld *badgerLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error
item := it.Item() item := it.Item()
k := item.Key() k := item.Key()
err := item.Value(func(v []byte) error { err := item.Value(func(v []byte) error {
rpos := f.contentBox.RowCount() dataEntry := f.contentBox.Items().Add()
f.contentBox.SetRowCount(rpos + 1) dataEntry.SetCaption(formatUtf8(k))
f.contentBox.SetCells(0, rpos, formatUtf8(k)) dataEntry.SubItems().Add(formatUtf8(v))
f.contentBox.SetCells(1, rpos, formatUtf8(v))
return nil return nil
}) })
@ -74,13 +84,12 @@ func (ld *badgerLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error
return nil return nil
}) })
if err != nil { if err != nil {
return err vcl.ShowMessage(fmt.Sprintf("Failed to load data: %s", err.Error()))
return
} }
// Valid // Valid
vcl_stringgrid_columnwidths(f.contentBox)
f.contentBox.SetEnabled(true) f.contentBox.SetEnabled(true)
return nil
} }
func (ld *badgerLoadedDatabase) NavChildren(ndata *navData) ([]string, error) { func (ld *badgerLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
@ -98,6 +107,10 @@ func (ld *badgerLoadedDatabase) NavContext(ndata *navData) ([]contextAction, err
return nil, nil // No special actions are supported return nil, nil // No special actions are supported
} }
func (ld *badgerLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
vcl.ShowMessage("Badger doesn't support querying")
}
func (ld *badgerLoadedDatabase) Close() { func (ld *badgerLoadedDatabase) Close() {
_ = ld.db.Close() _ = ld.db.Close()
ld.arena = nil ld.arena = nil

View File

@ -1,7 +1,6 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"sort" "sort"
@ -10,14 +9,11 @@ import (
"unsafe" "unsafe"
"github.com/ying32/govcl/vcl" "github.com/ying32/govcl/vcl"
"github.com/ying32/govcl/vcl/types"
"go.etcd.io/bbolt" "go.etcd.io/bbolt"
"go.etcd.io/bbolt/version" "go.etcd.io/bbolt/version"
) )
const (
boltFilter = "Bolt database|*.db|All files|*.*"
)
type boltLoadedDatabase struct { type boltLoadedDatabase struct {
displayName string displayName string
path string path string
@ -43,7 +39,7 @@ func (ld *boltLoadedDatabase) Keepalive(ndata *navData) {
ld.arena = append(ld.arena, ndata) ld.arena = append(ld.arena, ndata)
} }
func (ld *boltLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error { func (ld *boltLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
// Load properties // Load properties
@ -52,11 +48,19 @@ func (ld *boltLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error {
f.propertiesBox.SetText(content) f.propertiesBox.SetText(content)
// Load data // Load data
f.contentBox.SetEnabled(false)
f.contentBox.Clear()
// Bolt always uses Key + Value as the columns // Bolt always uses Key + Value as the columns
f.contentBox.Columns().Clear()
colKey := f.contentBox.Columns().Add() colKey := f.contentBox.Columns().Add()
colKey.Title().SetCaption("Key") colKey.SetCaption("Key")
colKey.SetWidth(MY_WIDTH)
colKey.SetAlignment(types.TaLeftJustify)
colVal := f.contentBox.Columns().Add() colVal := f.contentBox.Columns().Add()
colVal.Title().SetCaption("Value") colVal.SetCaption("Value")
err := ld.db.View(func(tx *bbolt.Tx) error { err := ld.db.View(func(tx *bbolt.Tx) error {
b := boltTargetBucket(tx, ndata.bucketPath) b := boltTargetBucket(tx, ndata.bucketPath)
@ -66,74 +70,23 @@ func (ld *boltLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error {
} }
// Valid // Valid
f.contentBox.Clear()
c := b.Cursor() c := b.Cursor()
for k, v := c.First(); k != nil; k, v = c.Next() { for k, v := c.First(); k != nil; k, v = c.Next() {
rpos := f.contentBox.RowCount() dataEntry := f.contentBox.Items().Add()
f.contentBox.SetRowCount(rpos + 1) dataEntry.SetCaption(formatUtf8(k))
f.contentBox.SetCells(0, rpos, formatUtf8(k)) dataEntry.SubItems().Add(formatUtf8(v))
f.contentBox.SetCells(1, rpos, formatUtf8(v))
} }
return nil return nil
}) })
if err != nil { if err != nil {
return err vcl.ShowMessage(fmt.Sprintf("Failed to load data for bucket %q: %s", bucketDisplayName, err.Error()))
return
} }
// Valid // Valid
vcl_stringgrid_columnwidths(f.contentBox)
f.contentBox.SetEnabled(true) f.contentBox.SetEnabled(true)
return nil
}
func (n *boltLoadedDatabase) ApplyChanges(f *TMainForm, ndata *navData) error {
if n.db.IsReadOnly() {
return errors.New("Database was opened read-only")
}
// We have rendered row IDs, need to convert back to a bolt primary key
// TODO stash the real key inside f.contentBox.Objects()
// FIXME breaks if you try and edit the primary key(!)
primaryKeyForRendered := func(rowid int32) []byte {
return []byte(f.contentBox.Cells(0, rowid))
}
return n.db.Update(func(tx *bbolt.Tx) error {
// Get current bucket handle
b := boltTargetBucket(tx, ndata.bucketPath)
// Edit
for rowid, _ /*editcells*/ := range f.updateRows {
k := primaryKeyForRendered(rowid)
v := f.contentBox.Cells(1, rowid) // There's only one value cell
err := b.Put(k, []byte(v))
if err != nil {
return fmt.Errorf("Updating cell %q: %w", formatUtf8(k), err)
}
}
// Delete by key (affects rowids after re-render)
for rowid, _ := range f.deleteRows {
k := primaryKeyForRendered(rowid)
err := b.Delete(k)
if err != nil {
return fmt.Errorf("Deleting cell %q: %w", formatUtf8(k), err)
}
}
// Insert all new entries
for rowid, _ := range f.insertRows {
k := primaryKeyForRendered(rowid)
v := f.contentBox.Cells(1, rowid) // There's only one value cell
err := b.Put(k, []byte(v))
if err != nil {
return fmt.Errorf("Inserting cell %q: %w", formatUtf8(k), err)
}
}
// Done
return nil
})
} }
func (ld *boltLoadedDatabase) NavChildren(ndata *navData) ([]string, error) { func (ld *boltLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
@ -147,14 +100,13 @@ func (ld *boltLoadedDatabase) NavContext(ndata *navData) (ret []contextAction, e
if len(ndata.bucketPath) > 0 { if len(ndata.bucketPath) > 0 {
ret = append(ret, contextAction{"Delete bucket", ld.DeleteBucket}) ret = append(ret, contextAction{"Delete bucket", ld.DeleteBucket})
} }
return return
} }
func (ld *boltLoadedDatabase) AddChildBucket(sender vcl.IComponent, ndata *navData) error { func (ld *boltLoadedDatabase) AddChildBucket(ndata *navData) {
bucketName := "" bucketName := ""
if !vcl.InputQuery(APPNAME, "Enter a name for the new bucket:", &bucketName) { if !vcl.InputQuery(APPNAME, "Enter a name for the new bucket:", &bucketName) {
return nil // cancel return // cancel
} }
err := ld.db.Update(func(tx *bbolt.Tx) error { err := ld.db.Update(func(tx *bbolt.Tx) error {
@ -169,12 +121,11 @@ func (ld *boltLoadedDatabase) AddChildBucket(sender vcl.IComponent, ndata *navDa
return err return err
}) })
if err != nil { if err != nil {
return fmt.Errorf("Error adding bucket: %w", err) vcl.ShowMessageFmt("Error adding bucket: %v", err)
} }
return nil
} }
func (ld *boltLoadedDatabase) DeleteBucket(sender vcl.IComponent, ndata *navData) error { func (ld *boltLoadedDatabase) DeleteBucket(ndata *navData) {
err := ld.db.Update(func(tx *bbolt.Tx) error { err := ld.db.Update(func(tx *bbolt.Tx) error {
// Find parent of this bucket. // Find parent of this bucket.
if len(ndata.bucketPath) >= 2 { if len(ndata.bucketPath) >= 2 {
@ -187,9 +138,12 @@ func (ld *boltLoadedDatabase) DeleteBucket(sender vcl.IComponent, ndata *navData
} }
}) })
if err != nil { if err != nil {
return fmt.Errorf("Error deleting bucket %q: %w", strings.Join(ndata.bucketPath, `/`), err) vcl.ShowMessageFmt("Error deleting bucket %q: %v", strings.Join(ndata.bucketPath, `/`), err)
} }
return nil }
func (ld *boltLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
vcl.ShowMessage("Bolt doesn't support querying")
} }
func (ld *boltLoadedDatabase) Close() { func (ld *boltLoadedDatabase) Close() {

View File

@ -1,132 +0,0 @@
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) error {
// 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
indexes := make(map[string]int)
for i, cname := range ld.db.AllColumnNames {
indexes[cname] = i
col := f.contentBox.Columns().Add()
col.Title().SetCaption(cname)
}
for _, entry := range ld.db.Entries {
rpos := f.contentBox.RowCount()
f.contentBox.SetRowCount(rpos + 1)
f.contentBox.SetCells(0, rpos, entry.Name)
for _, proppair := range entry.Properties {
f.contentBox.SetCells(int32(indexes[proppair[0]]), rpos, proppair[1])
}
}
// Valid
vcl_stringgrid_columnwidths(f.contentBox)
f.contentBox.SetEnabled(true)
return nil
}
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) 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)
}

View File

@ -18,9 +18,13 @@ func (n *noLoadedDatabase) RootElement() *vcl.TTreeNode {
return nil return nil
} }
func (n *noLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error { func (n *noLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
f.propertiesBox.SetText("Open a database to get started...") f.propertiesBox.SetText("Open a database to get started...")
return nil f.contentBox.SetEnabled(false)
f.contentBox.Clear()
}
func (n *noLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
} }
func (n *noLoadedDatabase) NavChildren(ndata *navData) ([]string, error) { func (n *noLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {

View File

@ -9,6 +9,7 @@ import (
"github.com/cockroachdb/pebble" "github.com/cockroachdb/pebble"
"github.com/cockroachdb/pebble/vfs" "github.com/cockroachdb/pebble/vfs"
"github.com/ying32/govcl/vcl" "github.com/ying32/govcl/vcl"
"github.com/ying32/govcl/vcl/types"
) )
type pebbleLoadedDatabase struct { type pebbleLoadedDatabase struct {
@ -35,7 +36,7 @@ func (ld *pebbleLoadedDatabase) Keepalive(ndata *navData) {
ld.arena = append(ld.arena, ndata) ld.arena = append(ld.arena, ndata)
} }
func (ld *pebbleLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error { func (ld *pebbleLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
ctx := context.Background() ctx := context.Background()
@ -45,12 +46,19 @@ func (ld *pebbleLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error
f.propertiesBox.SetText(content) f.propertiesBox.SetText(content)
// Load data // Load data
f.contentBox.SetEnabled(false)
f.contentBox.Clear()
// pebble always uses Key + Value as the columns // pebble always uses Key + Value as the columns
f.contentBox.Columns().Clear()
colKey := f.contentBox.Columns().Add() colKey := f.contentBox.Columns().Add()
colKey.Title().SetCaption("Key") colKey.SetCaption("Key")
colKey.SetWidth(MY_WIDTH)
colKey.SetAlignment(types.TaLeftJustify)
colVal := f.contentBox.Columns().Add() colVal := f.contentBox.Columns().Add()
colVal.Title().SetCaption("Value") colVal.SetCaption("Value")
itr := ld.db.NewIterWithContext(ctx, nil) itr := ld.db.NewIterWithContext(ctx, nil)
defer itr.Close() defer itr.Close()
@ -59,19 +67,17 @@ func (ld *pebbleLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error
k := itr.Key() k := itr.Key()
v, err := itr.ValueAndErr() v, err := itr.ValueAndErr()
if err != nil { if err != nil {
return fmt.Errorf("Failed to load data for key %q: %w", formatAny(k), err) vcl.ShowMessage(fmt.Sprintf("Failed to load data for key %q: %s", formatAny(k), err.Error()))
return
} }
rpos := f.contentBox.RowCount() dataEntry := f.contentBox.Items().Add()
f.contentBox.SetRowCount(rpos + 1) dataEntry.SetCaption(formatUtf8(k))
f.contentBox.SetCells(0, rpos, formatUtf8(k)) dataEntry.SubItems().Add(formatUtf8(v))
f.contentBox.SetCells(0, rpos, formatUtf8(v))
} }
// Valid // Valid
vcl_stringgrid_columnwidths(f.contentBox)
f.contentBox.SetEnabled(true) f.contentBox.SetEnabled(true)
return nil
} }
func (ld *pebbleLoadedDatabase) NavChildren(ndata *navData) ([]string, error) { func (ld *pebbleLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
@ -89,6 +95,10 @@ func (ld *pebbleLoadedDatabase) NavContext(ndata *navData) ([]contextAction, err
return nil, nil // No special actions are supported return nil, nil // No special actions are supported
} }
func (ld *pebbleLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
vcl.ShowMessage("pebble doesn't support querying")
}
func (ld *pebbleLoadedDatabase) Close() { func (ld *pebbleLoadedDatabase) Close() {
_ = ld.db.Close() _ = ld.db.Close()
ld.arena = nil ld.arena = nil

View File

@ -10,6 +10,7 @@ import (
"github.com/redis/go-redis/v9" "github.com/redis/go-redis/v9"
"github.com/ying32/govcl/vcl" "github.com/ying32/govcl/vcl"
"github.com/ying32/govcl/vcl/types"
) )
type redisLoadedDatabase struct { type redisLoadedDatabase struct {
@ -40,71 +41,83 @@ func (ld *redisLoadedDatabase) Keepalive(ndata *navData) {
ld.arena = append(ld.arena, ndata) ld.arena = append(ld.arena, ndata)
} }
func (ld *redisLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error { func (ld *redisLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
ctx := context.Background() ctx := context.Background()
if len(ndata.bucketPath) == 0 { if len(ndata.bucketPath) == 0 {
// Top-level: Show info() on main Properties tab // Top-level: Show info() on main Properties tab
infostr, err := ld.db.Info(ctx).Result() infostr, err := ld.db.Info(ctx).Result()
if err != nil { if err != nil {
return fmt.Errorf("Retreiving database info: %w", err) vcl.ShowMessage(fmt.Sprintf("Retreiving database info: %v", err))
return
} }
f.propertiesBox.SetText(infostr) f.propertiesBox.SetText(infostr)
// Leave data tab disabled (default behaviour) // Disable data tab
return nil f.contentBox.SetEnabled(false)
f.contentBox.Clear()
} else if len(ndata.bucketPath) == 1 { } else if len(ndata.bucketPath) == 1 {
// One selected database // One selected database
// Figure out its content // Figure out its content
err := ld.db.Do(ctx, "SELECT", ndata.bucketPath[0]).Err() err := ld.db.Do(ctx, "SELECT", ndata.bucketPath[0]).Err()
if err != nil { if err != nil {
return fmt.Errorf("Switching to database %q: %w", ndata.bucketPath[0], err) vcl.ShowMessage(fmt.Sprintf("Switching to database %q: %v", ndata.bucketPath[0], err))
return
} }
allKeys, err := ld.db.Keys(ctx, "*").Result() allKeys, err := ld.db.Keys(ctx, "*").Result()
if err != nil { if err != nil {
return fmt.Errorf("Listing keys in database %q: %w", ndata.bucketPath[0], err) vcl.ShowMessage(fmt.Sprintf("Listing keys in database %q: %v", ndata.bucketPath[0], err))
return
} }
f.propertiesBox.SetText(fmt.Sprintf("Database %s\nTotal keys: %d\n", ndata.bucketPath[0], len(allKeys))) f.propertiesBox.SetText(fmt.Sprintf("Database %s\nTotal keys: %d\n", ndata.bucketPath[0], len(allKeys)))
// Redis always uses Key + Value as the columns // Redis always uses Key + Value as the columns
f.contentBox.SetEnabled(false)
f.contentBox.Clear()
f.contentBox.Columns().Clear()
colKey := f.contentBox.Columns().Add() colKey := f.contentBox.Columns().Add()
colKey.Title().SetCaption("Key") colKey.SetCaption("Key")
colKey.SetWidth(MY_WIDTH)
colKey.SetAlignment(types.TaLeftJustify)
colType := f.contentBox.Columns().Add() colType := f.contentBox.Columns().Add()
colType.Title().SetCaption("Type") colType.SetCaption("Type")
colVal := f.contentBox.Columns().Add() colVal := f.contentBox.Columns().Add()
colVal.Title().SetCaption("Value") colVal.SetCaption("Value")
for _, key := range allKeys { for _, key := range allKeys {
typeName, err := ld.db.Type(ctx, key).Result() typeName, err := ld.db.Type(ctx, key).Result()
if err != nil { if err != nil {
return fmt.Errorf("Loading %q/%q: %w", ndata.bucketPath[0], key, err) vcl.ShowMessage(fmt.Sprintf("Loading %q/%q: %v", ndata.bucketPath[0], key, err))
return
} }
rpos := f.contentBox.RowCount() dataEntry := f.contentBox.Items().Add()
f.contentBox.SetRowCount(rpos + 1) dataEntry.SetCaption(key) // formatUtf8
f.contentBox.SetCells(0, rpos, formatUtf8([]byte(key))) dataEntry.SubItems().Add(typeName)
f.contentBox.SetCells(1, rpos, typeName)
switch typeName { switch typeName {
case "string": case "string":
val, err := ld.db.Get(ctx, key).Result() val, err := ld.db.Get(ctx, key).Result()
if err != nil { if err != nil {
return fmt.Errorf("Loading %q/%q: %w", ndata.bucketPath[0], key, err) vcl.ShowMessage(fmt.Sprintf("Loading %q/%q: %v", ndata.bucketPath[0], key, err))
return
} }
f.contentBox.SetCells(2, rpos, val) dataEntry.SubItems().Add(val)
case "hash": case "hash":
val, err := ld.db.HGetAll(ctx, key).Result() val, err := ld.db.HGetAll(ctx, key).Result()
if err != nil { if err != nil {
return fmt.Errorf("Loading %q/%q: %w", ndata.bucketPath[0], key, err) vcl.ShowMessage(fmt.Sprintf("Loading %q/%q: %v", ndata.bucketPath[0], key, err))
return
} }
// It's a map[string]string // It's a map[string]string
f.contentBox.SetCells(2, rpos, formatAny(val)) dataEntry.SubItems().Add(formatAny(val))
case "lists": case "lists":
fallthrough fallthrough
@ -116,18 +129,17 @@ func (ld *redisLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error
fallthrough fallthrough
default: default:
f.contentBox.SetCells(2, rpos, "<<<other object type>>>") dataEntry.SubItems().Add("<<<other object type>>>")
} }
} }
// Valid // Valid
vcl_stringgrid_columnwidths(f.contentBox)
f.contentBox.SetEnabled(true) f.contentBox.SetEnabled(true)
return nil
} else { } else {
return fmt.Errorf("Unexpected nav position %q", ndata.bucketPath) vcl.ShowMessage(fmt.Sprintf("Unexpected nav position %q", ndata.bucketPath))
return
} }
} }
@ -158,50 +170,48 @@ func (ld *redisLoadedDatabase) NavContext(ndata *navData) ([]contextAction, erro
return nil, nil // No special actions are supported return nil, nil // No special actions are supported
} }
func (ld *redisLoadedDatabase) ExecQuery(query string, resultArea *vcl.TStringGrid) error { func (ld *redisLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
ctx := context.Background() ctx := context.Background()
// Need to parse the query into separate string+args fields for the protocol // Need to parse the query into separate string+args fields for the protocol
fields, err := lexer.Fields(query) fields, err := lexer.Fields(query)
if err != nil { if err != nil {
return fmt.Errorf("Parsing the query: %w", err) vcl.ShowMessage(fmt.Sprintf("Parsing the query: %v", err))
return
} }
fields_boxed := box_interface(fields) fields_boxed := box_interface(fields)
ret, err := ld.db.Do(ctx, fields_boxed...).Result() ret, err := ld.db.Do(ctx, fields_boxed...).Result()
if err != nil { if err != nil {
return fmt.Errorf("The redis query returned an error: %w", err) vcl.ShowMessage(fmt.Sprintf("The redis query returned an error: %v", err))
return
} }
vcl_stringgrid_clear(resultArea) resultArea.SetEnabled(false)
resultArea.Clear()
resultArea.Columns().Clear()
colVal := resultArea.Columns().Add() colVal := resultArea.Columns().Add()
colVal.Title().SetCaption("Result") colVal.SetCaption("Result")
// The result is probably a single value or a string slice // The result is probably a single value or a string slice
switch ret := ret.(type) { switch ret := ret.(type) {
case []string: case []string:
// Multiple values // Multiple values
for _, single := range ret { for _, single := range ret {
cell := resultArea.Items().Add()
rpos := resultArea.RowCount() cell.SetCaption(single) // formatUtf8
resultArea.SetRowCount(rpos + 1)
resultArea.SetCells(0, rpos, formatUtf8([]byte(single)))
} }
default: default:
// Single value // Single value
rpos := resultArea.RowCount() dataEntry := resultArea.Items().Add()
resultArea.SetRowCount(rpos + 1) dataEntry.SetCaption(formatAny(ret)) // formatUtf8
resultArea.SetCells(0, rpos, formatAny(ret)) // formatUtf8
} }
vcl_stringgrid_columnwidths(resultArea)
resultArea.SetEnabled(true) resultArea.SetEnabled(true)
return nil
} }
func (ld *redisLoadedDatabase) Close() { func (ld *redisLoadedDatabase) Close() {

View File

@ -1,22 +1,19 @@
package main package main
import ( import (
"context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings"
"unsafe" "unsafe"
_ "yvbolt/sqliteclidriver" _ "yvbolt/sqliteclidriver"
"github.com/ying32/govcl/vcl" "github.com/ying32/govcl/vcl"
"github.com/ying32/govcl/vcl/types"
) )
const ( const (
sqliteTablesCaption = "Tables" sqliteTablesCaption = "Tables"
sqliteFilter = "SQLite database|*.db;*.db3;*.sqlite;*.sqlite3|All files|*.*"
) )
type sqliteLoadedDatabase struct { type sqliteLoadedDatabase struct {
@ -40,17 +37,19 @@ func (ld *sqliteLoadedDatabase) Keepalive(ndata *navData) {
ld.arena = append(ld.arena, ndata) ld.arena = append(ld.arena, ndata)
} }
func (ld *sqliteLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error { func (ld *sqliteLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
if len(ndata.bucketPath) == 0 { if len(ndata.bucketPath) == 0 {
// Top-level // Top-level
f.propertiesBox.SetText("Please select...") f.propertiesBox.SetText("Please select...")
return nil f.contentBox.SetEnabled(false)
f.contentBox.Clear()
} else if len(ndata.bucketPath) == 1 { } else if len(ndata.bucketPath) == 1 {
// Category (tables, ...) // Category (tables, ...)
f.propertiesBox.SetText("Please select...") f.propertiesBox.SetText("Please select...")
return nil f.contentBox.SetEnabled(false)
f.contentBox.Clear()
} else if len(ndata.bucketPath) == 2 && ndata.bucketPath[0] == sqliteTablesCaption { } else if len(ndata.bucketPath) == 2 && ndata.bucketPath[0] == sqliteTablesCaption {
// Render for specific table // Render for specific table
@ -67,13 +66,17 @@ func (ld *sqliteLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error
// Display table properties // Display table properties
f.propertiesBox.SetText(fmt.Sprintf("Selected table %q\n\nSchema:\n\n%s", tableName, schemaStmt)) f.propertiesBox.SetText(fmt.Sprintf("Selected table %q\n\nSchema:\n\n%s", tableName, schemaStmt))
f.contentBox.SetEnabled(false)
f.contentBox.Clear()
// Load column details // Load column details
// Use SELECT form instead of common PRAGMA table_info so we can just get names // Use SELECT form instead of common PRAGMA table_info so we can just get names
// We could possibly get this from the main data select, but this will // We could possibly get this from the main data select, but this will
// work even when there are 0 results // work even when there are 0 results
columnNames, err := ld.sqliteGetColumnNamesForTable(tableName) columnNames, err := ld.sqliteGetColumnNamesForTable(tableName)
if err != nil { if err != nil {
return fmt.Errorf("Failed to load columns for table %q: %w", tableName) vcl.ShowMessageFmt("Failed to load columns for table %q: %s", tableName, err.Error())
return
} }
populateColumns(columnNames, f.contentBox) populateColumns(columnNames, f.contentBox)
@ -82,21 +85,19 @@ func (ld *sqliteLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error
// 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 {
return fmt.Errorf("Failed to load data for table %q: %w", tableName, err) vcl.ShowMessageFmt("Failed to load data for table %q: %s", tableName, err.Error())
return
} }
defer datar.Close() defer datar.Close()
populateRows(datar, f.contentBox) populateRows(datar, f.contentBox)
// We successfully populated the data grid // We successfully populated the data grid
vcl_stringgrid_columnwidths(f.contentBox)
f.contentBox.SetEnabled(true) f.contentBox.SetEnabled(true)
return nil
} else { } else {
// ??? unknown // ??? unknown
return errors.New("?")
} }
@ -128,14 +129,17 @@ func (ld *sqliteLoadedDatabase) sqliteGetColumnNamesForTable(tableName string) (
return ret, nil return ret, nil
} }
func populateColumns(names []string, dest *vcl.TStringGrid) { func populateColumns(names []string, dest *vcl.TListView) {
dest.Columns().Clear()
for _, columnName := range names { for _, columnName := range names {
col := dest.Columns().Add() col := dest.Columns().Add()
col.Title().SetCaption(columnName) col.SetCaption(columnName)
col.SetWidth(MY_WIDTH)
col.SetAlignment(types.TaLeftJustify)
} }
} }
func populateRows(rr *sql.Rows, dest *vcl.TStringGrid) { func populateRows(rr *sql.Rows, dest *vcl.TListView) {
numColumns := int(dest.Columns().Count()) numColumns := int(dest.Columns().Count())
@ -152,10 +156,10 @@ func populateRows(rr *sql.Rows, dest *vcl.TStringGrid) {
return return
} }
rpos := dest.RowCount() dataEntry := dest.Items().Add()
dest.SetRowCount(rpos + 1) dataEntry.SetCaption(formatAny(fields[0]))
for i := 0; i < len(fields); i += 1 { for i := 1; i < len(fields); i += 1 {
dest.SetCells(int32(i), rpos, formatAny(fields[i])) dataEntry.SubItems().Add(formatAny(fields[i]))
} }
} }
@ -165,149 +169,29 @@ func populateRows(rr *sql.Rows, dest *vcl.TStringGrid) {
} }
} }
func (ld *sqliteLoadedDatabase) ExecQuery(query string, resultArea *vcl.TStringGrid) error { func (ld *sqliteLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
rr, err := ld.db.Query(query) rr, err := ld.db.Query(query)
if err != nil { if err != nil {
return err vcl.ShowMessage(err.Error())
return
} }
defer rr.Close() defer rr.Close()
vcl_stringgrid_clear(resultArea) resultArea.SetEnabled(false)
resultArea.Clear()
columns, err := rr.Columns() columns, err := rr.Columns()
if err != nil { if err != nil {
return err vcl.ShowMessage(err.Error())
return
} }
populateColumns(columns, resultArea) populateColumns(columns, resultArea)
populateRows(rr, resultArea) populateRows(rr, resultArea)
vcl_stringgrid_columnwidths(resultArea)
resultArea.SetEnabled(true) resultArea.SetEnabled(true)
return nil
}
func (n *sqliteLoadedDatabase) ApplyChanges(f *TMainForm, ndata *navData) (retErr error) {
if len(ndata.bucketPath) != 2 {
return errors.New("invalid selection")
}
tableName := ndata.bucketPath[1]
// We have rendered row IDs, need to convert back to an SQLite primary key
// TODO stash the real key inside f.contentBox.Objects()
// FIXME breaks if you try and edit the primary key(!)
ctx := context.Background()
tx, err := n.db.BeginTx(ctx, nil)
if err != nil {
return err
}
var commitOK bool = false
defer func() {
if !commitOK {
err := tx.Rollback()
if err != nil {
retErr = err
}
}
}()
// Data grid properties
var columnNames []string
for i := int32(0); i < f.contentBox.ColCount(); i++ {
columnNames = append(columnNames, f.contentBox.Columns().Items(i).Title().Caption())
}
// Query sqlite table metadata to determine which of these is the PRIMARY KEY
var primaryColumnName string
err = tx.QueryRowContext(ctx, `SELECT l.name FROM pragma_table_info(?) as l WHERE l.pk = 1;`, tableName).Scan(&primaryColumnName)
if err != nil {
return fmt.Errorf("Finding primary key for update: %w", err)
}
// Convert it to an index
var primaryColumnIdx int = -1
for i := 0; i < len(columnNames); i++ {
if columnNames[i] == primaryColumnName {
primaryColumnIdx = i
break
}
}
if primaryColumnIdx == -1 {
return fmt.Errorf("Primary key %q missing from available columns", primaryColumnName)
}
// SQLite can only LIMIT 1 on update/delete if it was compiled with
// SQLITE_ENABLE_UPDATE_DELETE_LIMIT, which isn't the case for the mattn
// cgo library
// Skip that, and just rely on primary key uniqueness
// Edit
for rowid, editcells := range f.updateRows {
stmt := `UPDATE "` + tableName + `" SET `
params := []interface{}{} // FIXME reinstate types for the driver (although SQLite doesn't mind)
for ct, cell := range editcells {
if ct > 0 {
stmt += `, `
}
stmt += `"` + columnNames[cell] + `" = ?`
params = append(params, f.contentBox.Cells(cell, rowid))
}
stmt += ` WHERE "` + primaryColumnName + `" = ?`
pkVal := f.contentBox.Cells(int32(primaryColumnIdx), rowid)
params = append(params, pkVal)
_, err = tx.ExecContext(ctx, stmt, params...)
if err != nil {
return fmt.Errorf("Updating row %q: %w", pkVal, err)
}
}
// Delete by key (affects rowids after re-render)
for rowid, _ := range f.deleteRows {
pkVal := f.contentBox.Cells(int32(primaryColumnIdx), rowid)
stmt := `DELETE FROM "` + tableName + `" WHERE "` + primaryColumnName + `" = ?`
_, err = tx.ExecContext(ctx, stmt, pkVal)
if err != nil {
return fmt.Errorf("Deleting row %q: %w", pkVal, err)
}
}
// Insert all new entries
for rowid, _ := range f.insertRows {
stmt := `INSERT INTO "` + tableName + `" (` + strings.Join(columnNames, `, `) + `) VALUES (`
params := []interface{}{} // FIXME reinstate types for the driver (although SQLite doesn't mind)
for colid := 0; colid < len(columnNames); colid++ {
if colid > 0 {
stmt += `, `
}
stmt += "?"
params = append(params, f.contentBox.Cells(int32(colid), rowid))
}
stmt += `)`
_, err = tx.ExecContext(ctx, stmt, params...)
if err != nil {
return fmt.Errorf("Inserting row: %w", err)
}
}
err = tx.Commit()
if err != nil {
return err
}
commitOK = true // no need for rollback
return nil
} }
func (ld *sqliteLoadedDatabase) NavChildren(ndata *navData) ([]string, error) { func (ld *sqliteLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
@ -348,52 +232,8 @@ func (ld *sqliteLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
return nil, fmt.Errorf("unknown nav path %#v", ndata.bucketPath) return nil, fmt.Errorf("unknown nav path %#v", ndata.bucketPath)
} }
func (ld *sqliteLoadedDatabase) NavContext(ndata *navData) (ret []contextAction, err error) { func (ld *sqliteLoadedDatabase) NavContext(ndata *navData) ([]contextAction, error) {
return nil, nil // No special actions are supported
if len(ndata.bucketPath) == 0 {
ret = append(ret, contextAction{"Compact database", ld.CompactDatabase})
ret = append(ret, contextAction{"Export backup...", ld.ExportBackup})
}
if len(ndata.bucketPath) == 2 {
ret = append(ret, contextAction{"Drop table", ld.DropTable})
}
return
}
func (ld *sqliteLoadedDatabase) CompactDatabase(sender vcl.IComponent, ndata *navData) error {
_, err := ld.db.Exec(`VACUUM;`)
return err
}
func (ld *sqliteLoadedDatabase) ExportBackup(sender vcl.IComponent, ndata *navData) error {
// Popup for output file
dlg := vcl.NewSaveDialog(sender)
dlg.SetTitle("Save backup as...")
dlg.SetFilter(sqliteFilter)
ret := dlg.Execute() // Fake blocking
if !ret {
return nil // cancelled
}
_, err := ld.db.Exec(`VACUUM INTO ?`, dlg.FileName())
return err
}
func (ld *sqliteLoadedDatabase) DropTable(sender vcl.IComponent, ndata *navData) error {
if len(ndata.bucketPath) != 2 {
return errors.New("Invalid selection")
}
//
tableName := ndata.bucketPath[1]
if !vcl_confirm_dialog(sender, "Drop table", fmt.Sprintf("Are you sure you want to drop the table %q?", tableName)) {
return nil // cancelled
}
_, err := ld.db.Exec(`DROP TABLE "` + tableName + `"`) // WARNING can't prepare this parameter, but it comes from the DB (trusted)
return err
} }
func (ld *sqliteLoadedDatabase) Close() { func (ld *sqliteLoadedDatabase) Close() {

View File

@ -3,16 +3,10 @@
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
} }

View File

@ -3,15 +3,9 @@
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)"
} }

View File

@ -1,94 +0,0 @@
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
}

View File

@ -1,30 +0,0 @@
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
View File

@ -5,7 +5,6 @@ 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
@ -33,6 +32,7 @@ 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
View File

@ -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.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.3.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=

View File

@ -10,28 +10,19 @@ import (
var assetsFs embed.FS var assetsFs embed.FS
const ( const (
imgAdd int32 = iota imgArrowRefresh int32 = iota
imgArrowRefresh
imgChartBar imgChartBar
imgDatabase imgDatabase
imgDatabaseAdd imgDatabaseAdd
imgDatabaseDelete imgDatabaseDelete
imgDatabaseLightning imgDatabaseLightning
imgDatabaseSave imgDatabaseSave
imgDelete
imgLightning imgLightning
imgLightningGo
imgPencil
imgPencilAdd
imgPencilDelete
imgPencilGo
imgResultsetNext
imgTable imgTable
imgTableAdd imgTableAdd
imgTableDelete imgTableDelete
imgTableSave imgTableSave
imgVendorCockroach imgVendorCockroach
imgVendorDebian
imgVendorDgraph imgVendorDgraph
imgVendorGithub imgVendorGithub
imgVendorMySQL imgVendorMySQL
@ -56,9 +47,6 @@ func loadImages(owner vcl.IComponent) *vcl.TImageList {
} }
ilist := vcl.NewImageList(owner) ilist := vcl.NewImageList(owner)
// ls assets | sort | sed -re 's~(.+)~ilist.Add(mustLoad("assets/\1"), nil)~'
ilist.Add(mustLoad("assets/add.png"), nil)
ilist.Add(mustLoad("assets/arrow_refresh.png"), nil) ilist.Add(mustLoad("assets/arrow_refresh.png"), nil)
ilist.Add(mustLoad("assets/chart_bar.png"), nil) ilist.Add(mustLoad("assets/chart_bar.png"), nil)
ilist.Add(mustLoad("assets/database.png"), nil) ilist.Add(mustLoad("assets/database.png"), nil)
@ -66,26 +54,17 @@ func loadImages(owner vcl.IComponent) *vcl.TImageList {
ilist.Add(mustLoad("assets/database_delete.png"), nil) ilist.Add(mustLoad("assets/database_delete.png"), nil)
ilist.Add(mustLoad("assets/database_lightning.png"), nil) ilist.Add(mustLoad("assets/database_lightning.png"), nil)
ilist.Add(mustLoad("assets/database_save.png"), nil) ilist.Add(mustLoad("assets/database_save.png"), nil)
ilist.Add(mustLoad("assets/delete.png"), nil)
ilist.Add(mustLoad("assets/lightning.png"), nil) ilist.Add(mustLoad("assets/lightning.png"), nil)
ilist.Add(mustLoad("assets/lightning_go.png"), nil)
ilist.Add(mustLoad("assets/pencil.png"), nil)
ilist.Add(mustLoad("assets/pencil_add.png"), nil)
ilist.Add(mustLoad("assets/pencil_delete.png"), nil)
ilist.Add(mustLoad("assets/pencil_go.png"), nil)
ilist.Add(mustLoad("assets/resultset_next.png"), nil)
ilist.Add(mustLoad("assets/table.png"), nil) ilist.Add(mustLoad("assets/table.png"), nil)
ilist.Add(mustLoad("assets/table_add.png"), nil) ilist.Add(mustLoad("assets/table_add.png"), nil)
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)
ilist.Add(mustLoad("assets/vendor_redis.png"), nil) ilist.Add(mustLoad("assets/vendor_redis.png"), nil)
ilist.Add(mustLoad("assets/vendor_sqlite.png"), nil) ilist.Add(mustLoad("assets/vendor_sqlite.png"), nil)
return ilist return ilist
} }

View File

@ -65,15 +65,6 @@ 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)
} }

View File

@ -59,24 +59,6 @@ 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{
@ -117,7 +99,7 @@ func TestLexer(t *testing.T) {
} }
if !reflect.DeepEqual(out, tc.expect) { if !reflect.DeepEqual(out, tc.expect) {
t.Errorf("Test %q\n- got: %#v\n- expected %#v", tc.input, out, tc.expect) t.Errorf("Test %q got %v, expected %v", tc.input, out, tc.expect)
} }
} }

View File

@ -10,7 +10,7 @@ var ErrNavNotExist error = errors.New("The selected item no longer exists")
type contextAction struct { type contextAction struct {
Name string Name string
Callback func(sender vcl.IComponent, ndata *navData) error Callback func(ndata *navData)
} }
// loadedDatabase is a DB-agnostic interface for each loaded database. // loadedDatabase is a DB-agnostic interface for each loaded database.
@ -18,21 +18,14 @@ type loadedDatabase interface {
DisplayName() string DisplayName() string
DriverName() string DriverName() string
RootElement() *vcl.TTreeNode RootElement() *vcl.TTreeNode
RenderForNav(f *TMainForm, ndata *navData) error RenderForNav(f *TMainForm, ndata *navData)
ExecQuery(query string, resultArea *vcl.TListView)
NavChildren(ndata *navData) ([]string, error) NavChildren(ndata *navData) ([]string, error)
NavContext(ndata *navData) ([]contextAction, error) NavContext(ndata *navData) ([]contextAction, error)
Keepalive(ndata *navData) Keepalive(ndata *navData)
Close() Close()
} }
type queryableLoadedDatabase interface {
ExecQuery(query string, resultArea *vcl.TStringGrid) error
}
type editableLoadedDatabase interface {
ApplyChanges(f *TMainForm, ndata *navData) error
}
// navData is the .Data() pointer for each TTreeNode in the left-hand tree. // navData is the .Data() pointer for each TTreeNode in the left-hand tree.
type navData struct { type navData struct {
ld loadedDatabase ld loadedDatabase

381
main.go
View File

@ -3,7 +3,6 @@ package main
import ( import (
"errors" "errors"
"fmt" "fmt"
"runtime"
"runtime/debug" "runtime/debug"
"strings" "strings"
"unsafe" "unsafe"
@ -17,11 +16,6 @@ import (
const ( const (
APPNAME = "yvbolt" APPNAME = "yvbolt"
HOMEPAGE_URL = "https://code.ivysaur.me/yvbolt" HOMEPAGE_URL = "https://code.ivysaur.me/yvbolt"
CO_INSERT = colors.ClYellow
CO_EDIT_IMPLICIT = colors.ClLightgreen
CO_EDIT_EXPLICIT = colors.ClGreen
CO_DELETE = colors.ClRed
) )
type TMainForm struct { type TMainForm struct {
@ -36,17 +30,9 @@ type TMainForm struct {
Buckets *vcl.TTreeView Buckets *vcl.TTreeView
Tabs *vcl.TPageControl Tabs *vcl.TPageControl
propertiesBox *vcl.TMemo propertiesBox *vcl.TMemo
contentBox *vcl.TStringGrid contentBox *vcl.TListView
dataInsertBtn *vcl.TToolButton queryInput *vcl.TMemo
dataDelRowBtn *vcl.TToolButton queryResult *vcl.TListView
dataCommitBtn *vcl.TToolButton
isEditing bool
insertRows map[int32]struct{} // Rows in the StringGrid that are to-be-inserted
deleteRows map[int32]struct{}
updateRows map[int32][]int32 // Row->cells that are to-be-updated
queryExecBtn *vcl.TToolButton
queryInput *vcl.TRichEdit
queryResult *vcl.TStringGrid
none *noLoadedDatabase none *noLoadedDatabase
dbs []loadedDatabase dbs []loadedDatabase
@ -65,8 +51,6 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
f.SetCaption(APPNAME) f.SetCaption(APPNAME)
f.ScreenCenter() f.ScreenCenter()
f.SetWidth(1280)
f.SetHeight(640)
f.ImageList.GetIcon(imgDatabaseLightning, f.Icon()) f.ImageList.GetIcon(imgDatabaseLightning, f.Icon())
mnuFile := vcl.NewMenuItem(f) mnuFile := vcl.NewMenuItem(f)
@ -95,15 +79,6 @@ 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)
@ -151,7 +126,7 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
mnuQueryExecute.SetCaption("Execute") mnuQueryExecute.SetCaption("Execute")
mnuQueryExecute.SetShortCutFromString("F5") mnuQueryExecute.SetShortCutFromString("F5")
mnuQueryExecute.SetOnClick(f.OnQueryExecute) mnuQueryExecute.SetOnClick(f.OnQueryExecute)
mnuQueryExecute.SetImageIndex(imgResultsetNext) mnuQueryExecute.SetImageIndex(imgLightning)
mnuQuery.Add(mnuQueryExecute) mnuQuery.Add(mnuQueryExecute)
mnuHelp := vcl.NewMenuItem(f) mnuHelp := vcl.NewMenuItem(f)
@ -212,8 +187,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(vcl_default_tab_background()) f.propertiesBox.SetColor(colors.ClForm) // 0x00f0f0f0
f.propertiesBox.SetBorderStyle(types.BsNone) f.propertiesBox.SetBorderStyle(types.BsNone)
f.propertiesBox.SetScrollBars(types.SsAutoVertical) f.propertiesBox.SetScrollBars(types.SsAutoVertical)
@ -223,56 +198,14 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
dataTab.SetCaption("Data") dataTab.SetCaption("Data")
dataTab.SetImageIndex(imgTable) dataTab.SetImageIndex(imgTable)
dataButtonBar := vcl.NewToolBar(dataTab) f.contentBox = vcl.NewListView(dataTab)
dataButtonBar.SetParent(dataTab)
dataButtonBar.SetAlign(types.AlTop)
dataButtonBar.BorderSpacing().SetLeft(MY_SPACING)
dataButtonBar.BorderSpacing().SetTop(MY_SPACING)
dataButtonBar.BorderSpacing().SetBottom(1)
dataButtonBar.BorderSpacing().SetRight(MY_SPACING)
dataButtonBar.SetEdgeBorders(0)
dataButtonBar.SetImages(f.ImageList)
dataRefreshBtn := vcl.NewToolButton(dataButtonBar)
dataRefreshBtn.SetParent(dataButtonBar)
dataRefreshBtn.SetHint("Refresh")
dataRefreshBtn.SetShowHint(true)
dataRefreshBtn.SetImageIndex(imgArrowRefresh)
dataRefreshBtn.SetOnClick(func(sender vcl.IObject) { f.RefreshCurrentItem() })
f.dataInsertBtn = vcl.NewToolButton(dataButtonBar)
f.dataInsertBtn.SetParent(dataButtonBar)
f.dataInsertBtn.SetImageIndex(imgAdd)
f.dataInsertBtn.SetHint("Insert")
f.dataInsertBtn.SetShowHint(true)
f.dataInsertBtn.SetOnClick(f.OnDataInsertClick)
f.dataDelRowBtn = vcl.NewToolButton(dataButtonBar)
f.dataDelRowBtn.SetParent(dataButtonBar)
f.dataDelRowBtn.SetImageIndex(imgDelete)
f.dataDelRowBtn.SetHint("Delete Row")
f.dataDelRowBtn.SetShowHint(true)
f.dataDelRowBtn.SetOnClick(f.OnDataDeleteRowClick)
f.dataCommitBtn = vcl.NewToolButton(dataButtonBar)
f.dataCommitBtn.SetParent(dataButtonBar)
f.dataCommitBtn.SetImageIndex(imgPencilGo)
f.dataCommitBtn.SetHint("Commit")
f.dataCommitBtn.SetShowHint(true)
f.dataCommitBtn.SetOnClick(f.OnDataCommitClick)
f.contentBox = vcl.NewStringGrid(dataTab)
f.contentBox.SetParent(dataTab) f.contentBox.SetParent(dataTab)
f.contentBox.BorderSpacing().SetLeft(MY_SPACING) f.contentBox.BorderSpacing().SetAround(MY_SPACING)
f.contentBox.BorderSpacing().SetRight(MY_SPACING) f.contentBox.SetAlign(types.AlClient) // fill remaining space
f.contentBox.BorderSpacing().SetBottom(MY_SPACING) f.contentBox.SetViewStyle(types.VsReport) // "Report style" i.e. has columns
f.contentBox.SetAlign(types.AlClient) // fill remaining space f.contentBox.SetAutoWidthLastColumn(true)
f.contentBox.SetOptions(f.contentBox.Options().Include(types.GoThumbTracking, types.GoColSizing, types.GoDblClickAutoSize, types.GoEditing)) f.contentBox.SetReadOnly(true)
f.contentBox.SetOnPrepareCanvas(f.OnDataPrepareCanvas) f.contentBox.Columns().Clear()
f.contentBox.SetOnEditingDone(f.OnDataCellEdited)
f.contentBox.SetOnGetEditText(f.OnDataCellEditStarting)
f.contentBox.SetDefaultColWidth(MY_WIDTH)
vcl_stringgrid_clear(f.contentBox)
queryTab := vcl.NewTabSheet(f.Tabs) queryTab := vcl.NewTabSheet(f.Tabs)
queryTab.SetParent(f.Tabs) queryTab.SetParent(f.Tabs)
@ -284,50 +217,42 @@ 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(1) queryButtonBar.BorderSpacing().SetBottom(0)
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)
f.queryExecBtn = vcl.NewToolButton(queryButtonBar) queryExecBtn := vcl.NewToolButton(queryButtonBar)
f.queryExecBtn.SetParent(queryButtonBar) queryExecBtn.SetParent(queryButtonBar)
f.queryExecBtn.SetHint("Execute") queryExecBtn.SetCaption("Execute")
f.queryExecBtn.SetShowHint(true) // queryExecBtn.SetImageIndex(imgLightning)
f.queryExecBtn.SetImageIndex(imgResultsetNext) queryExecBtn.SetOnClick(f.OnQueryExecute)
f.queryExecBtn.SetOnClick(f.OnQueryExecute)
f.queryInput = vcl.NewRichEdit(queryTab) f.queryInput = vcl.NewMemo(queryTab)
f.queryInput.SetParent(queryTab) f.queryInput.SetParent(queryTab)
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)
if runtime.GOOS == "windows" { f.queryInput.Font().SetName("monospace")
f.queryInput.Font().SetName("Consolas")
} else {
f.queryInput.Font().SetName("monospace")
}
f.queryInput.SetCursor(types.CrIBeam) // Use text cursor instead of default pointer cursor
f.queryInput.BorderSpacing().SetLeft(MY_SPACING) f.queryInput.BorderSpacing().SetLeft(MY_SPACING)
//f.queryInput.BorderSpacing().SetTop(1) f.queryInput.BorderSpacing().SetTop(0)
f.queryInput.BorderSpacing().SetRight(MY_SPACING) f.queryInput.BorderSpacing().SetRight(MY_SPACING)
f.queryInput.SetBorderStyle(types.BsFrame)
// f.queryInput.SetOnKeyUp(f.OnQueryTextChanged) // Performs extremely slowly
vsplit := vcl.NewSplitter(queryTab) vsplit := vcl.NewSplitter(queryTab)
vsplit.SetParent(queryTab) vsplit.SetParent(queryTab)
vsplit.SetAlign(types.AlTop) vsplit.SetAlign(types.AlTop)
vsplit.SetTop(2) vsplit.SetTop(2)
f.queryResult = vcl.NewStringGrid(queryTab) f.queryResult = vcl.NewListView(queryTab)
f.queryResult.SetParent(queryTab) f.queryResult.SetParent(queryTab)
f.queryResult.SetAlign(types.AlClient) // fill remaining space f.queryResult.SetAlign(types.AlClient) // fill remaining space
f.queryResult.SetViewStyle(types.VsReport) // "Report style" i.e. has columns
f.queryResult.SetAutoWidthLastColumn(true)
f.queryResult.SetReadOnly(true)
f.queryResult.Columns().Clear()
f.queryResult.BorderSpacing().SetLeft(MY_SPACING) f.queryResult.BorderSpacing().SetLeft(MY_SPACING)
f.queryResult.BorderSpacing().SetRight(MY_SPACING) f.queryResult.BorderSpacing().SetRight(MY_SPACING)
f.queryResult.BorderSpacing().SetBottom(MY_SPACING) f.queryResult.BorderSpacing().SetBottom(MY_SPACING)
f.queryResult.SetOptions(f.queryResult.Options().Include(types.GoThumbTracking))
f.queryResult.SetDefaultColWidth(MY_WIDTH)
vcl_stringgrid_clear(f.queryResult)
f.none = &noLoadedDatabase{} f.none = &noLoadedDatabase{}
f.OnNavChange(f, nil) // calls f.none.RenderForNav and sets up status bar content f.OnNavChange(f, nil) // calls f.none.RenderForNav and sets up status bar content
@ -336,7 +261,7 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
func (f *TMainForm) OnMnuFileBoltNewClick(sender vcl.IObject) { func (f *TMainForm) OnMnuFileBoltNewClick(sender vcl.IObject) {
dlg := vcl.NewSaveDialog(f) dlg := vcl.NewSaveDialog(f)
dlg.SetTitle("Save database as...") dlg.SetTitle("Save database as...")
dlg.SetFilter(boltFilter) dlg.SetFilter("Bolt database|*.db|All files|*.*")
ret := dlg.Execute() // Fake blocking ret := dlg.Execute() // Fake blocking
if ret { if ret {
f.boltAddDatabaseFromFile(dlg.FileName(), false) f.boltAddDatabaseFromFile(dlg.FileName(), false)
@ -346,7 +271,7 @@ func (f *TMainForm) OnMnuFileBoltNewClick(sender vcl.IObject) {
func (f *TMainForm) OnMnuFileBoltOpenClick(sender vcl.IObject) { func (f *TMainForm) OnMnuFileBoltOpenClick(sender vcl.IObject) {
dlg := vcl.NewOpenDialog(f) dlg := vcl.NewOpenDialog(f)
dlg.SetTitle("Select a database file...") dlg.SetTitle("Select a database file...")
dlg.SetFilter(boltFilter) dlg.SetFilter("Bolt database|*.db|All files|*.*")
ret := dlg.Execute() // Fake blocking ret := dlg.Execute() // Fake blocking
if ret { if ret {
f.boltAddDatabaseFromFile(dlg.FileName(), false) f.boltAddDatabaseFromFile(dlg.FileName(), false)
@ -356,7 +281,7 @@ func (f *TMainForm) OnMnuFileBoltOpenClick(sender vcl.IObject) {
func (f *TMainForm) OnMnuFileBoltOpenReadonlyClick(sender vcl.IObject) { func (f *TMainForm) OnMnuFileBoltOpenReadonlyClick(sender vcl.IObject) {
dlg := vcl.NewOpenDialog(f) dlg := vcl.NewOpenDialog(f)
dlg.SetTitle("Select a database file...") dlg.SetTitle("Select a database file...")
dlg.SetFilter(boltFilter) dlg.SetFilter("Bolt database|*.db|All files|*.*")
ret := dlg.Execute() // Fake blocking ret := dlg.Execute() // Fake blocking
if ret { if ret {
f.boltAddDatabaseFromFile(dlg.FileName(), true) f.boltAddDatabaseFromFile(dlg.FileName(), true)
@ -368,7 +293,7 @@ func (f *TMainForm) OnMnuFileSqliteOpenClick(sender vcl.IObject) {
dlg := vcl.NewOpenDialog(f) dlg := vcl.NewOpenDialog(f)
dlg.SetTitle("Select a database file...") dlg.SetTitle("Select a database file...")
dlg.SetFilter(sqliteFilter) dlg.SetFilter("SQLite database|*.db;*.db3;*.sqlite;*.sqlite3|All files|*.*")
ret := dlg.Execute() // Fake blocking ret := dlg.Execute() // Fake blocking
if ret { if ret {
f.sqliteAddDatabaseFromFile(dlg.FileName(), cliDriver) f.sqliteAddDatabaseFromFile(dlg.FileName(), cliDriver)
@ -397,19 +322,6 @@ 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()
} }
@ -456,7 +368,7 @@ func (f *TMainForm) OnMenuHelpVersion(sender vcl.IObject) {
return return
} }
info := "This version of " + APPNAME + " was compiled with:\n\n" info := "This version of " + APPNAME + " was compiled with:\n"
for _, dep := range bi.Deps { for _, dep := range bi.Deps {
// Filter to only interesting things // Filter to only interesting things
@ -511,10 +423,7 @@ func (f *TMainForm) OnNavContextPopup(sender vcl.IObject, mousePos types.TPoint,
mnuAction.SetCaption(action.Name) mnuAction.SetCaption(action.Name)
cb := action.Callback // Copy to avoid reuse of loop variable cb := action.Callback // Copy to avoid reuse of loop variable
mnuAction.SetOnClick(func(sender vcl.IObject) { mnuAction.SetOnClick(func(sender vcl.IObject) {
err = cb(f, ndata) cb(ndata)
if err != nil {
vcl.ShowMessage(err.Error())
}
f.OnNavContextRefresh(curItem, ndata) f.OnNavContextRefresh(curItem, ndata)
}) })
mnu.Items().Add(mnuAction) mnu.Items().Add(mnuAction)
@ -538,18 +447,6 @@ func (f *TMainForm) OnNavContextPopup(sender vcl.IObject, mousePos types.TPoint,
mnu.Popup2() mnu.Popup2()
} }
func (f *TMainForm) RefreshCurrentItem() {
item := f.Buckets.Selected()
if item == nil {
return // nothing to do
}
ndata := (*navData)(item.Data())
f.OnNavContextRefresh(item, ndata) // Refresh LHS pane/children
f.OnNavChange(f.Buckets, item) // Refresh RHS pane/data content
}
func (f *TMainForm) OnNavContextRefresh(item *vcl.TTreeNode, ndata *navData) { func (f *TMainForm) OnNavContextRefresh(item *vcl.TTreeNode, ndata *navData) {
isExpanded := item.Expanded() isExpanded := item.Expanded()
@ -566,131 +463,6 @@ func (f *TMainForm) OnNavContextRefresh(item *vcl.TTreeNode, ndata *navData) {
item.SetExpanded(isExpanded) item.SetExpanded(isExpanded)
} }
func (f *TMainForm) OnDataPrepareCanvas(sender vcl.IObject, aCol, aRow int32, aState types.TGridDrawState) {
if _, ok := f.deleteRows[aRow]; ok {
f.contentBox.Canvas().Brush().SetColor(CO_DELETE)
return
}
if _, ok := f.insertRows[aRow]; ok {
f.contentBox.Canvas().Brush().SetColor(CO_INSERT)
return
}
if er, ok := f.updateRows[aRow]; ok {
// This row is being edited
if int32slice_contains(er, aCol) {
f.contentBox.Canvas().Brush().SetColor(CO_EDIT_EXPLICIT)
} else {
f.contentBox.Canvas().Brush().SetColor(CO_EDIT_IMPLICIT)
}
return
}
}
// func (f *TMainForm) OnDataCellEditStarting(sender vcl.IObject, aCol, aRow int32, editor **vcl.TWinControl) {
func (f *TMainForm) OnDataCellEditStarting(sender vcl.IObject, aCol, aRow int32, value *string) {
f.isEditing = true
}
func (f *TMainForm) OnDataCellEdited(sender vcl.IObject) {
// The OnEditingDone event fires whenever the TStringGrid loses focus, even
// if editing was not currently taking place
// To detect real edits, set a flag in the OnSelectEditor event
if !f.isEditing {
return
}
f.isEditing = false
aRow := f.contentBox.Row()
aCol := f.contentBox.Col()
// If this is an insert row, no need to patch updateRows
if _, ok := f.insertRows[aRow]; ok {
return // nothing to do
}
if chk, ok := f.updateRows[aRow]; ok {
if int32slice_contains(chk, aCol) {
// nothing to do
} else {
chk = append(chk, aCol)
f.updateRows[aRow] = chk
}
} else {
f.updateRows[aRow] = []int32{aCol}
}
// If this row was marked for deletion, this new event takes priority
delete(f.deleteRows, aRow)
// Signal repaint
f.contentBox.InvalidateRow(aRow)
}
func (f *TMainForm) OnDataInsertClick(sender vcl.IObject) {
if !f.contentBox.Enabled() {
return // Not an active data view
}
rpos := f.contentBox.RowCount()
f.contentBox.SetRowCount(rpos + 1)
f.insertRows[rpos] = struct{}{}
// Scroll to bottom
f.contentBox.SetTopRow(rpos)
}
func (f *TMainForm) OnDataDeleteRowClick(sender vcl.IObject) {
if !f.contentBox.Enabled() {
return // Not an active data view
}
rpos := f.contentBox.Row()
f.deleteRows[rpos] = struct{}{}
// If this row was marked for edit, this takes priority
delete(f.updateRows, rpos)
// Repaint
f.contentBox.InvalidateRow(rpos)
}
func (f *TMainForm) OnDataCommitClick(sender vcl.IObject) {
if !f.contentBox.Enabled() {
return // Not an active data view
}
node := f.Buckets.Selected()
if node == nil {
vcl.ShowMessage("No database selected")
return
}
scrollPos := f.contentBox.TopRow()
ndata := (*navData)(node.Data())
editableLd, ok := ndata.ld.(editableLoadedDatabase)
if !ok {
vcl.ShowMessage("Unsupported action for this database")
return
}
err := editableLd.ApplyChanges(f, ndata)
if err != nil {
vcl.ShowMessage(err.Error())
}
// Refresh content
f.OnNavChange(f.Buckets, node) // Refresh RHS pane/data content
// Preserve scroll position
f.contentBox.SetTopRow(scrollPos)
}
func (f *TMainForm) OnNavContextClose(sender vcl.IObject) { func (f *TMainForm) OnNavContextClose(sender vcl.IObject) {
curItem := f.Buckets.Selected() curItem := f.Buckets.Selected()
if curItem == nil { if curItem == nil {
@ -708,46 +480,6 @@ func (f *TMainForm) OnNavContextClose(sender vcl.IObject) {
// n.b. This triggers OnNavChange, which will then re-render from noLoadedDatabase{} // n.b. This triggers OnNavChange, which will then re-render from noLoadedDatabase{}
} }
// func (f *TMainForm) OnQueryTextChanged(sender vcl.IObject) {
func (f *TMainForm) OnQueryTextChanged(sender vcl.IObject, key *types.Char, shift types.TShiftState) {
// FIXME changing the text colour calls the onchange handler recursively
// FIXME changing the text colour pushes into the undo stack
// Preserve
f.queryInput.Lines().BeginUpdate()
origPos := f.queryInput.SelStart()
origLen := f.queryInput.SelLength()
defer func() {
f.queryInput.SetSelStart(origPos)
f.queryInput.SetSelLength(origLen)
f.queryInput.Lines().EndUpdate()
}()
tx := strings.ToLower(f.queryInput.Text())
// Reset all existing colors
f.queryInput.SetSelStart(0)
f.queryInput.SetSelLength(int32(len(tx)))
f.queryInput.SelAttributes().SetColor(colors.ClBlack)
searchPos := 0
for {
matchPos := strings.Index(tx[searchPos:], "select")
if matchPos == -1 {
break
}
matchPos += searchPos // compensate for slicing
f.queryInput.SetSelStart(int32(matchPos))
f.queryInput.SetSelLength(6)
f.queryInput.SelAttributes().SetColor(colors.ClRed)
searchPos = matchPos + 6 // strlen(SELECT)
}
}
func (f *TMainForm) OnQueryExecute(sender vcl.IObject) { func (f *TMainForm) OnQueryExecute(sender vcl.IObject) {
// If query tab is not selected, switch to it, but do not exec // If query tab is not selected, switch to it, but do not exec
if f.Tabs.ActivePageIndex() != 2 { if f.Tabs.ActivePageIndex() != 2 {
@ -760,10 +492,6 @@ func (f *TMainForm) OnQueryExecute(sender vcl.IObject) {
queryString = f.queryInput.SelText() // Just the selected text queryString = f.queryInput.SelText() // Just the selected text
} }
if strings.TrimSpace(queryString) == "" {
return // prevent blank query
}
// Execute // Execute
node := f.Buckets.Selected() node := f.Buckets.Selected()
if node == nil { if node == nil {
@ -772,18 +500,7 @@ func (f *TMainForm) OnQueryExecute(sender vcl.IObject) {
} }
ndata := (*navData)(node.Data()) ndata := (*navData)(node.Data())
ndata.ld.ExecQuery(queryString, f.queryResult)
queryableLd, ok := ndata.ld.(queryableLoadedDatabase)
if !ok {
vcl.ShowMessage("Unsupported action for this database")
return
}
err := queryableLd.ExecQuery(queryString, f.queryResult)
if err != nil {
vcl.ShowMessage(err.Error())
return
}
} }
func (f *TMainForm) OnNavChange(sender vcl.IObject, node *vcl.TTreeNode) { func (f *TMainForm) OnNavChange(sender vcl.IObject, node *vcl.TTreeNode) {
@ -796,33 +513,7 @@ func (f *TMainForm) OnNavChange(sender vcl.IObject, node *vcl.TTreeNode) {
ld = ndata.ld ld = ndata.ld
} }
// Reset some controls that the render function is expected to populate ld.RenderForNav(f, ndata) // Handover to the database type's own renderer function
f.insertRows = make(map[int32]struct{})
f.updateRows = make(map[int32][]int32)
f.deleteRows = make(map[int32]struct{})
f.isEditing = false
f.propertiesBox.Clear()
vcl_stringgrid_clear(f.contentBox)
err := ld.RenderForNav(f, ndata) // Handover to the database type's own renderer function
if err != nil {
vcl.ShowMessage(err.Error())
// Ensure elements are disabled
f.contentBox.SetEnabled(false)
}
// Toggle the Edit functionality
_, ok := ld.(editableLoadedDatabase)
f.dataCommitBtn.SetEnabled(ok)
f.dataDelRowBtn.SetEnabled(ok)
f.dataInsertBtn.SetEnabled(ok)
// Toggle the Query functionality
_, ok = ld.(queryableLoadedDatabase)
f.queryInput.SetEnabled(ok)
f.queryResult.SetEnabled(ok)
f.queryExecBtn.SetEnabled(ok)
// We're in charge of common status bar text updates // We're in charge of common status bar text updates
f.StatusBar.SetSimpleText(ld.DisplayName() + " | " + ld.DriverName()) f.StatusBar.SetSimpleText(ld.DisplayName() + " | " + ld.DriverName())

View File

@ -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

View File

@ -1,115 +0,0 @@
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
}
}
}

View File

@ -1,69 +0,0 @@
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)
}

View File

@ -1,85 +0,0 @@
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
}

View File

@ -1,30 +0,0 @@
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)
}

View File

@ -5,6 +5,7 @@
// 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
@ -22,10 +23,7 @@ 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")
@ -39,13 +37,29 @@ 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 `--`
chEvents, pw, err := ExecEvents(cmd) pw, err := cmd.StdinPipe()
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{
listen: chEvents, stdout: pr,
stderr: pe,
w: pw, w: pw,
}, nil }, nil
} }
@ -76,7 +90,8 @@ var _ driver.Connector = &SCConnector{} // interface assertion
// //
type SCConn struct { type SCConn struct {
listen <-chan processEvent stdout io.Reader
stderr io.Reader
w io.WriteCloser w io.WriteCloser
} }
@ -90,10 +105,6 @@ 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,
@ -205,102 +216,24 @@ 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
// If something happens on stderr, or to the process, pr will read an error ret := []map[string]any{}
ret := []OrderedKV{} err = json.NewDecoder(s.conn.stdout).Decode(&ret)
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
@ -310,8 +243,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 _, cell := range ret[0] { for k, _ := range ret[0] {
columnNames = append(columnNames, cell.Key) columnNames = append(columnNames, k)
} }
} }
@ -345,7 +278,7 @@ var _ driver.Result = &SCResult{} // interface assertion
type SCRows struct { type SCRows struct {
idx int idx int
columns []string columns []string
data []OrderedKV data []map[string]any
} }
func (r *SCRows) Columns() []string { func (r *SCRows) Columns() []string {
@ -368,9 +301,12 @@ func (r *SCRows) Next(dest []driver.Value) error {
} }
for i := 0; i < len(dest); i++ { for i := 0; i < len(dest); i++ {
cell := r.data[r.idx][i] cell, ok := r.data[r.idx][r.columns[i]]
if !ok {
return fmt.Errorf("Result row %d is missing column #%d %q, unexpected", r.idx, i, r.columns[i])
}
dest[i] = cell.Value dest[i] = cell
} }
r.idx++ r.idx++

View File

@ -55,24 +55,3 @@ 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)
}
}

View File

@ -9,13 +9,3 @@ func box_interface[T any](input []T) []interface{} {
} }
return ret return ret
} }
func int32slice_contains(haystack []int32, needle int32) bool {
for _, v := range haystack {
if v == needle {
return true
}
}
return false
}

View File

@ -1,19 +1,14 @@
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 (
MY_SPACING = 6 MY_SPACING = 6
MY_HEIGHT = 90 MY_HEIGHT = 90
MY_WIDTH = 180 MY_WIDTH = 180
MAX_AUTO_COL_WIDTH = 240
) )
// vcl_row makes a TPanel row inside the target component. // vcl_row makes a TPanel row inside the target component.
@ -54,70 +49,3 @@ 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
// None of the colors.** constants seem to be quite right on a test
// Windows 11 machine - should be #f9f9f9
return 0x00f9f9f9
} else {
return colors.ClBtnFace // 0x00f0f0f0
}
}
func vcl_stringgrid_clear(d *vcl.TStringGrid) {
d.SetFixedCols(0) // No left-hand cols
d.SetRowCount(1) // The 0th row is the column headers
d.SetEnabled(false)
d.Columns().Clear()
d.Invalidate()
}
func vcl_stringgrid_columnwidths(d *vcl.TStringGrid) {
// Skip slow processing for very large result sets
if d.RowCount() > 1000 {
return
}
d.AutoAdjustColumns()
// AutoAdjustColumns will leave some columns massively too large by themselves
// Reign them back in
ct := d.Columns().Count()
for i := int32(0); i < ct; i++ {
if d.ColWidths(i) > MAX_AUTO_COL_WIDTH {
d.SetColWidths(i, MAX_AUTO_COL_WIDTH)
}
}
}
func vcl_confirm_dialog(sender vcl.IComponent, title string, message string) bool {
dlg := vcl.NewTaskDialog(sender)
dlg.SetCaption(APPNAME)
dlg.SetTitle(title)
dlg.SetText(message)
dlg.SetCommonButtons(types.NewSet())
yesBtn := dlg.Buttons().Add()
yesBtn.SetCaption("Confirm")
yesBtn.SetModalResult(types.MrYes)
noBtn := dlg.Buttons().Add()
noBtn.SetCaption("Cancel")
noBtn.SetModalResult(types.MrCancel)
ret := dlg.Execute()
if !ret {
return false // dialog closed
}
if dlg.ModalResult() != types.MrYes {
return false // other button clicked
}
return true // confirmed
}