48 Commits

Author SHA1 Message Date
97c58d7fc4 fltk: initial commit 2024-07-18 17:01:02 +12:00
6dd0635c9e doc/TODO: update 2024-07-14 15:35:56 +12:00
ce3d08740f sqlite: add context actions for compact, export, drop table 2024-07-14 15:34:17 +12:00
8f5e1054fb db: return error from contextAction.Callback (2) 2024-07-14 15:28:18 +12:00
1ac96eb133 move filter consts to each db file 2024-07-14 15:27:54 +12:00
53e9b6555e doc/TODO: more ideas 2024-07-13 18:03:47 +12:00
e1a9f187cb db: return error from RenderForNav, contextAction.Callback 2024-07-13 18:03:42 +12:00
ee3110162b doc/TODO: update 2024-07-06 12:44:37 +12:00
35a83eb483 gui: basic syntax highlighting implementation (disabled) 2024-07-06 12:41:57 +12:00
60add3be86 db: add common errunsupported 2024-07-06 12:02:58 +12:00
2f65ffdd70 db: lift execQuery error handling to parent 2024-07-06 11:59:55 +12:00
aad92d27e9 gui: use icons for toolbar 2024-07-06 11:54:48 +12:00
21151be8a3 gui/images: load more image assets 2024-07-06 11:54:36 +12:00
f78eec1872 bolt: support editing 2024-07-06 11:45:41 +12:00
8af27f8834 gui: change tracking for insert, edit, delete actions 2024-07-06 11:04:19 +12:00
0d3b90b879 gui: prep work for inserting rows 2024-07-05 20:07:19 +12:00
2b59efc410 gui: add refresh button on data tab 2024-07-05 19:46:04 +12:00
7fbf2ef1ed gui: common column handling, set widths automatically 2024-07-05 19:35:24 +12:00
d7e3363173 gui: convert data tables from TListView to TStringGrid 2024-07-05 19:21:08 +12:00
cecfc338d4 gui: bigger default window size 2024-07-05 18:43:02 +12:00
35f09fc072 doc/README: add v0.6.0 download links 2024-06-30 14:17:59 +12:00
2163b46907 doc/README: changelog for v0.6.0 2024-06-30 14:16:06 +12:00
81b6b08e7b doc/TODO: status update 2024-06-30 14:15:53 +12:00
f31724a110 gui: add extra space in help menu driver list 2024-06-30 14:14:50 +12:00
063a8ca837 debconf: add as database option 2024-06-30 14:14:41 +12:00
1cfc94a42b debconf: fix extra spaces, Name column ordering 2024-06-30 14:14:26 +12:00
053e07c319 debconf: implement dat file parser 2024-06-30 13:47:29 +12:00
0b91c379b8 doc/README: update partial changelog 2024-06-30 13:28:17 +12:00
7b4cc885f5 gui: use Consolas as monospace font on Windows 2024-06-30 13:16:27 +12:00
3b17ddd8a4 sqliteclidriver: always get columns in the right order 2024-06-30 13:12:11 +12:00
8d051a14e5 orderedkv: initial commit 2024-06-30 13:11:54 +12:00
4735c391bd doc/TODO: update status 2024-06-30 12:51:40 +12:00
0866e5edac sqliteclidriver: clear content from ret if sentinel was found 2024-06-30 12:51:05 +12:00
5c44dc5f54 sqliteclidriver: indicate driver in status bar 2024-06-30 12:48:22 +12:00
a7dd1ca340 makefile: compile cgo with -O2 2024-06-30 12:45:40 +12:00
abcf7dbfe5 sqliteclidriver: better bubble up stderr errors 2024-06-30 12:45:23 +12:00
d359f42b24 sqlite: support tables named using special characters 2024-06-30 12:45:03 +12:00
7cec5cee4c sqliteclidriver: use channel events, handle no results via sentinel 2024-06-30 12:33:47 +12:00
be91cd54c6 eventcmd: initial commit of channel-based process wrapper 2024-06-30 12:33:01 +12:00
b141aaaa6c lexer: separate tokens for top-level special characters 2024-06-30 11:26:00 +12:00
493ab846b9 gui: adjust styles for query frame 2024-06-30 11:10:19 +12:00
d3ebcb4666 gui: fix popup position for redis connection dialog 2024-06-30 11:05:25 +12:00
50cf207eae gui: fix properties tab background colour for windows build 2024-06-30 11:05:16 +12:00
e5cbbb6822 makefile/linux: don't upx, but xz harder, for faster startup 2024-06-30 10:45:19 +12:00
18674568dd makefile/windows: upx harder 2024-06-30 10:45:08 +12:00
748dd96267 makefile/windows: set windowsgui flag 2024-06-30 10:44:59 +12:00
c5578daa9f makefile: add cleanups before targets 2024-06-30 10:44:13 +12:00
3bc7f539ad doc/README: add v0.5.0 download links 2024-06-29 13:03:55 +12:00
40 changed files with 1462 additions and 240 deletions

View File

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

View File

@@ -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,20 @@ 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
[⬇️ 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
@@ -53,6 +68,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

32
TODO
View File

@@ -1,6 +1,12 @@
- Insert - Cast DB to wider interface to check feature support
- Update cell - Syntax highlighting in editor
- Delete row(s) - Mutation
- Get real primary key for mutation instead of string approximation
- Badger: Support insert/update/delete
- Pebble: Support insert/update/delete
- Debconf: Support insert/update/delete
- Redis: Support insert/update/delete
- SQLite: 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
@@ -9,24 +15,24 @@
- 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
- SSH tunnels - Windows registry
- Badger encryption key dialog - LDAP
- Pebble: connection options dialog - Connection dialog
- SQLite: DB compact action - SSH tunnels
- SQLite: DB export backup action - Badger encryption key dialog
- SQLite: drop table action - Pebble: connection options dialog
- SQLite: show views, triggers, indexes in nav
- SQLite CLI driver: - SQLite CLI driver:
- Basic error handling - Context support
- 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

BIN
assets/add.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 733 B

BIN
assets/delete.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 B

BIN
assets/lightning_go.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 B

BIN
assets/pencil.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

BIN
assets/pencil_add.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 589 B

BIN
assets/pencil_delete.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 B

BIN
assets/pencil_go.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 666 B

BIN
assets/resultset_next.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

BIN
assets/vendor_debian.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 831 B

View File

@@ -7,7 +7,6 @@ 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 {
@@ -34,7 +33,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) { func (ld *badgerLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error {
// Load properties // Load properties
@@ -42,25 +41,15 @@ func (ld *badgerLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
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.SetCaption("Key") colKey.Title().SetCaption("Key")
colKey.SetWidth(MY_WIDTH)
colKey.SetAlignment(types.TaLeftJustify)
colVal := f.contentBox.Columns().Add() colVal := f.contentBox.Columns().Add()
colVal.SetCaption("Value") colVal.Title().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
@@ -71,9 +60,10 @@ func (ld *badgerLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
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 {
dataEntry := f.contentBox.Items().Add() rpos := f.contentBox.RowCount()
dataEntry.SetCaption(formatUtf8(k)) f.contentBox.SetRowCount(rpos + 1)
dataEntry.SubItems().Add(formatUtf8(v)) f.contentBox.SetCells(0, rpos, formatUtf8(k))
f.contentBox.SetCells(1, rpos, formatUtf8(v))
return nil return nil
}) })
@@ -84,12 +74,17 @@ func (ld *badgerLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
return nil return nil
}) })
if err != nil { if err != nil {
vcl.ShowMessage(fmt.Sprintf("Failed to load data: %s", err.Error())) return err
return
} }
// Valid // Valid
vcl_stringgrid_columnwidths(f.contentBox)
f.contentBox.SetEnabled(true) f.contentBox.SetEnabled(true)
return nil
}
func (n *badgerLoadedDatabase) ApplyChanges(f *TMainForm, ndata *navData) error {
return ErrNotSupported
} }
func (ld *badgerLoadedDatabase) NavChildren(ndata *navData) ([]string, error) { func (ld *badgerLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
@@ -107,8 +102,8 @@ 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) { func (ld *badgerLoadedDatabase) ExecQuery(query string, resultArea *vcl.TStringGrid) error {
vcl.ShowMessage("Badger doesn't support querying") return ErrNotSupported
} }
func (ld *badgerLoadedDatabase) Close() { func (ld *badgerLoadedDatabase) Close() {

View File

@@ -1,6 +1,7 @@
package main package main
import ( import (
"errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -9,11 +10,14 @@ 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
@@ -39,7 +43,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) { func (ld *boltLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error {
// Load properties // Load properties
@@ -48,19 +52,11 @@ func (ld *boltLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
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.SetCaption("Key") colKey.Title().SetCaption("Key")
colKey.SetWidth(MY_WIDTH)
colKey.SetAlignment(types.TaLeftJustify)
colVal := f.contentBox.Columns().Add() colVal := f.contentBox.Columns().Add()
colVal.SetCaption("Value") colVal.Title().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)
@@ -70,23 +66,74 @@ func (ld *boltLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
} }
// 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() {
dataEntry := f.contentBox.Items().Add() rpos := f.contentBox.RowCount()
dataEntry.SetCaption(formatUtf8(k)) f.contentBox.SetRowCount(rpos + 1)
dataEntry.SubItems().Add(formatUtf8(v)) f.contentBox.SetCells(0, rpos, formatUtf8(k))
f.contentBox.SetCells(1, rpos, formatUtf8(v))
} }
return nil return nil
}) })
if err != nil { if err != nil {
vcl.ShowMessage(fmt.Sprintf("Failed to load data for bucket %q: %s", bucketDisplayName, err.Error())) return err
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) {
@@ -100,13 +147,14 @@ 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(ndata *navData) { func (ld *boltLoadedDatabase) AddChildBucket(sender vcl.IComponent, ndata *navData) error {
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 // cancel return nil // cancel
} }
err := ld.db.Update(func(tx *bbolt.Tx) error { err := ld.db.Update(func(tx *bbolt.Tx) error {
@@ -121,11 +169,12 @@ func (ld *boltLoadedDatabase) AddChildBucket(ndata *navData) {
return err return err
}) })
if err != nil { if err != nil {
vcl.ShowMessageFmt("Error adding bucket: %v", err) return fmt.Errorf("Error adding bucket: %w", err)
} }
return nil
} }
func (ld *boltLoadedDatabase) DeleteBucket(ndata *navData) { func (ld *boltLoadedDatabase) DeleteBucket(sender vcl.IComponent, ndata *navData) error {
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 {
@@ -138,12 +187,13 @@ func (ld *boltLoadedDatabase) DeleteBucket(ndata *navData) {
} }
}) })
if err != nil { if err != nil {
vcl.ShowMessageFmt("Error deleting bucket %q: %v", strings.Join(ndata.bucketPath, `/`), err) return fmt.Errorf("Error deleting bucket %q: %w", strings.Join(ndata.bucketPath, `/`), err)
} }
return nil
} }
func (ld *boltLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) { func (ld *boltLoadedDatabase) ExecQuery(query string, resultArea *vcl.TStringGrid) error {
vcl.ShowMessage("Bolt doesn't support querying") return ErrNotSupported
} }
func (ld *boltLoadedDatabase) Close() { func (ld *boltLoadedDatabase) Close() {

140
db_debconf.go Normal file
View File

@@ -0,0 +1,140 @@
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 (n *debconfLoadedDatabase) ApplyChanges(f *TMainForm, ndata *navData) error {
return ErrNotSupported
}
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.TStringGrid) error {
return ErrNotSupported
}
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,13 +18,17 @@ func (n *noLoadedDatabase) RootElement() *vcl.TTreeNode {
return nil return nil
} }
func (n *noLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) { func (n *noLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error {
f.propertiesBox.SetText("Open a database to get started...") f.propertiesBox.SetText("Open a database to get started...")
f.contentBox.SetEnabled(false) return nil
f.contentBox.Clear()
} }
func (n *noLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) { func (n *noLoadedDatabase) ApplyChanges(f *TMainForm, ndata *navData) error {
return ErrNotSupported
}
func (n *noLoadedDatabase) ExecQuery(query string, resultArea *vcl.TStringGrid) error {
return ErrNotSupported
} }
func (n *noLoadedDatabase) NavChildren(ndata *navData) ([]string, error) { func (n *noLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {

View File

@@ -9,7 +9,6 @@ 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 {
@@ -36,7 +35,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) { func (ld *pebbleLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error {
ctx := context.Background() ctx := context.Background()
@@ -46,19 +45,12 @@ func (ld *pebbleLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
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.SetCaption("Key") colKey.Title().SetCaption("Key")
colKey.SetWidth(MY_WIDTH)
colKey.SetAlignment(types.TaLeftJustify)
colVal := f.contentBox.Columns().Add() colVal := f.contentBox.Columns().Add()
colVal.SetCaption("Value") colVal.Title().SetCaption("Value")
itr := ld.db.NewIterWithContext(ctx, nil) itr := ld.db.NewIterWithContext(ctx, nil)
defer itr.Close() defer itr.Close()
@@ -67,17 +59,23 @@ func (ld *pebbleLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
k := itr.Key() k := itr.Key()
v, err := itr.ValueAndErr() v, err := itr.ValueAndErr()
if err != nil { if err != nil {
vcl.ShowMessage(fmt.Sprintf("Failed to load data for key %q: %s", formatAny(k), err.Error())) return fmt.Errorf("Failed to load data for key %q: %w", formatAny(k), err)
return
} }
dataEntry := f.contentBox.Items().Add() rpos := f.contentBox.RowCount()
dataEntry.SetCaption(formatUtf8(k)) f.contentBox.SetRowCount(rpos + 1)
dataEntry.SubItems().Add(formatUtf8(v)) f.contentBox.SetCells(0, rpos, formatUtf8(k))
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 (n *pebbleLoadedDatabase) ApplyChanges(f *TMainForm, ndata *navData) error {
return ErrNotSupported
} }
func (ld *pebbleLoadedDatabase) NavChildren(ndata *navData) ([]string, error) { func (ld *pebbleLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
@@ -95,8 +93,8 @@ 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) { func (ld *pebbleLoadedDatabase) ExecQuery(query string, resultArea *vcl.TStringGrid) error {
vcl.ShowMessage("pebble doesn't support querying") return ErrNotSupported
} }
func (ld *pebbleLoadedDatabase) Close() { func (ld *pebbleLoadedDatabase) Close() {

View File

@@ -10,7 +10,6 @@ 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 {
@@ -41,83 +40,71 @@ 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) { func (ld *redisLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error {
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 {
vcl.ShowMessage(fmt.Sprintf("Retreiving database info: %v", err)) return fmt.Errorf("Retreiving database info: %w", err)
return
} }
f.propertiesBox.SetText(infostr) f.propertiesBox.SetText(infostr)
// Disable data tab // Leave data tab disabled (default behaviour)
f.contentBox.SetEnabled(false) return nil
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 {
vcl.ShowMessage(fmt.Sprintf("Switching to database %q: %v", ndata.bucketPath[0], err)) return fmt.Errorf("Switching to database %q: %w", 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 {
vcl.ShowMessage(fmt.Sprintf("Listing keys in database %q: %v", ndata.bucketPath[0], err)) return fmt.Errorf("Listing keys in database %q: %w", 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.SetCaption("Key") colKey.Title().SetCaption("Key")
colKey.SetWidth(MY_WIDTH)
colKey.SetAlignment(types.TaLeftJustify)
colType := f.contentBox.Columns().Add() colType := f.contentBox.Columns().Add()
colType.SetCaption("Type") colType.Title().SetCaption("Type")
colVal := f.contentBox.Columns().Add() colVal := f.contentBox.Columns().Add()
colVal.SetCaption("Value") colVal.Title().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 {
vcl.ShowMessage(fmt.Sprintf("Loading %q/%q: %v", ndata.bucketPath[0], key, err)) return fmt.Errorf("Loading %q/%q: %w", ndata.bucketPath[0], key, err)
return
} }
dataEntry := f.contentBox.Items().Add() rpos := f.contentBox.RowCount()
dataEntry.SetCaption(key) // formatUtf8 f.contentBox.SetRowCount(rpos + 1)
dataEntry.SubItems().Add(typeName) f.contentBox.SetCells(0, rpos, formatUtf8([]byte(key)))
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 {
vcl.ShowMessage(fmt.Sprintf("Loading %q/%q: %v", ndata.bucketPath[0], key, err)) return fmt.Errorf("Loading %q/%q: %w", ndata.bucketPath[0], key, err)
return
} }
dataEntry.SubItems().Add(val) f.contentBox.SetCells(2, rpos, 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 {
vcl.ShowMessage(fmt.Sprintf("Loading %q/%q: %v", ndata.bucketPath[0], key, err)) return fmt.Errorf("Loading %q/%q: %w", ndata.bucketPath[0], key, err)
return
} }
// It's a map[string]string // It's a map[string]string
dataEntry.SubItems().Add(formatAny(val)) f.contentBox.SetCells(2, rpos, formatAny(val))
case "lists": case "lists":
fallthrough fallthrough
@@ -129,21 +116,26 @@ func (ld *redisLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
fallthrough fallthrough
default: default:
dataEntry.SubItems().Add("<<<other object type>>>") f.contentBox.SetCells(2, rpos, "<<<other object type>>>")
} }
} }
// Valid // Valid
vcl_stringgrid_columnwidths(f.contentBox)
f.contentBox.SetEnabled(true) f.contentBox.SetEnabled(true)
return nil
} else { } else {
vcl.ShowMessage(fmt.Sprintf("Unexpected nav position %q", ndata.bucketPath)) return fmt.Errorf("Unexpected nav position %q", ndata.bucketPath)
return
} }
} }
func (n *redisLoadedDatabase) ApplyChanges(f *TMainForm, ndata *navData) error {
return ErrNotSupported
}
func (ld *redisLoadedDatabase) NavChildren(ndata *navData) ([]string, error) { func (ld *redisLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
// ctx := context.Background() // ctx := context.Background()
@@ -170,48 +162,50 @@ 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.TListView) { func (ld *redisLoadedDatabase) ExecQuery(query string, resultArea *vcl.TStringGrid) error {
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 {
vcl.ShowMessage(fmt.Sprintf("Parsing the query: %v", err)) return fmt.Errorf("Parsing the query: %w", 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 {
vcl.ShowMessage(fmt.Sprintf("The redis query returned an error: %v", err)) return fmt.Errorf("The redis query returned an error: %w", err)
return
} }
resultArea.SetEnabled(false) vcl_stringgrid_clear(resultArea)
resultArea.Clear()
resultArea.Columns().Clear()
colVal := resultArea.Columns().Add() colVal := resultArea.Columns().Add()
colVal.SetCaption("Result") colVal.Title().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()
cell.SetCaption(single) // formatUtf8 rpos := resultArea.RowCount()
resultArea.SetRowCount(rpos + 1)
resultArea.SetCells(0, rpos, formatUtf8([]byte(single)))
} }
default: default:
// Single value // Single value
dataEntry := resultArea.Items().Add() rpos := resultArea.RowCount()
dataEntry.SetCaption(formatAny(ret)) // formatUtf8 resultArea.SetRowCount(rpos + 1)
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

@@ -2,6 +2,7 @@ package main
import ( import (
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"unsafe" "unsafe"
@@ -9,11 +10,11 @@ import (
_ "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 {
@@ -37,19 +38,17 @@ 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) { func (ld *sqliteLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error {
if len(ndata.bucketPath) == 0 { if len(ndata.bucketPath) == 0 {
// Top-level // Top-level
f.propertiesBox.SetText("Please select...") f.propertiesBox.SetText("Please select...")
f.contentBox.SetEnabled(false) return nil
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...")
f.contentBox.SetEnabled(false) return nil
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
@@ -66,17 +65,13 @@ func (ld *sqliteLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
// 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 {
vcl.ShowMessageFmt("Failed to load columns for table %q: %s", tableName, err.Error()) return fmt.Errorf("Failed to load columns for table %q: %w", tableName)
return
} }
populateColumns(columnNames, f.contentBox) populateColumns(columnNames, f.contentBox)
@@ -85,24 +80,30 @@ 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()) return fmt.Errorf("Failed to load data for table %q: %w", tableName, err)
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("?")
} }
} }
func (n *sqliteLoadedDatabase) ApplyChanges(f *TMainForm, ndata *navData) error {
return ErrNotSupported
}
func (ld *sqliteLoadedDatabase) sqliteGetColumnNamesForTable(tableName string) ([]string, error) { func (ld *sqliteLoadedDatabase) sqliteGetColumnNamesForTable(tableName string) ([]string, error) {
colr, err := ld.db.Query(`SELECT name FROM pragma_table_info( ? )`, tableName) colr, err := ld.db.Query(`SELECT name FROM pragma_table_info( ? )`, tableName)
if err != nil { if err != nil {
@@ -129,17 +130,14 @@ func (ld *sqliteLoadedDatabase) sqliteGetColumnNamesForTable(tableName string) (
return ret, nil return ret, nil
} }
func populateColumns(names []string, dest *vcl.TListView) { func populateColumns(names []string, dest *vcl.TStringGrid) {
dest.Columns().Clear()
for _, columnName := range names { for _, columnName := range names {
col := dest.Columns().Add() col := dest.Columns().Add()
col.SetCaption(columnName) col.Title().SetCaption(columnName)
col.SetWidth(MY_WIDTH)
col.SetAlignment(types.TaLeftJustify)
} }
} }
func populateRows(rr *sql.Rows, dest *vcl.TListView) { func populateRows(rr *sql.Rows, dest *vcl.TStringGrid) {
numColumns := int(dest.Columns().Count()) numColumns := int(dest.Columns().Count())
@@ -156,10 +154,10 @@ func populateRows(rr *sql.Rows, dest *vcl.TListView) {
return return
} }
dataEntry := dest.Items().Add() rpos := dest.RowCount()
dataEntry.SetCaption(formatAny(fields[0])) dest.SetRowCount(rpos + 1)
for i := 1; i < len(fields); i += 1 { for i := 0; i < len(fields); i += 1 {
dataEntry.SubItems().Add(formatAny(fields[i])) dest.SetCells(int32(i), rpos, formatAny(fields[i]))
} }
} }
@@ -169,29 +167,28 @@ func populateRows(rr *sql.Rows, dest *vcl.TListView) {
} }
} }
func (ld *sqliteLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) { func (ld *sqliteLoadedDatabase) ExecQuery(query string, resultArea *vcl.TStringGrid) error {
rr, err := ld.db.Query(query) rr, err := ld.db.Query(query)
if err != nil { if err != nil {
vcl.ShowMessage(err.Error()) return err
return
} }
defer rr.Close() defer rr.Close()
resultArea.SetEnabled(false) vcl_stringgrid_clear(resultArea)
resultArea.Clear()
columns, err := rr.Columns() columns, err := rr.Columns()
if err != nil { if err != nil {
vcl.ShowMessage(err.Error()) return err
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 (ld *sqliteLoadedDatabase) NavChildren(ndata *navData) ([]string, error) { func (ld *sqliteLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
@@ -232,8 +229,52 @@ 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) ([]contextAction, error) { func (ld *sqliteLoadedDatabase) NavContext(ndata *navData) (ret []contextAction, err 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,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
} }

View File

@@ -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
View 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
View 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))
}
}

47
fltktest/main.go Normal file
View File

@@ -0,0 +1,47 @@
package main
import (
"github.com/pwiecz/go-fltk"
)
const WNDCOLOR = 0xf0f0f000
const FONTSIZE = 12
func main() {
fltk.SetScheme("gtk+")
// ourFont := fltk.FREE_FONT + 1
// fltk.SetFont(ourFont, "Papyrus")
fltk.SetColor(fltk.BACKGROUND_COLOR, 0xf0, 0xf0, 0xf0)
fltk.SetColor(fltk.BACKGROUND2_COLOR, 0xff, 0x00, 0x00)
win := fltk.NewWindow(400, 300)
win.SetLabel("Main Window")
// win.SetLabelSize(FONTSIZE)
// win.SetColor(WNDCOLOR)
mnu := fltk.NewMenuBar(0, 0, win.W(), 25)
mnu.Add("File/Quit", func() {})
mnu.SetLabelSize(FONTSIZE)
mnu.SetBox(fltk.FLAT_BOX)
win.Add(mnu)
btn := fltk.NewButton(160, 200, 80, 30, "Click")
// btn.SetLabelFont(ourFont)
btn.SetLabelSize(FONTSIZE)
btn.SetCallback(func() {
btn.SetLabel("Clicked")
})
// win.End()
// win.Resizable()
win.SetResizeHandler(func() {
mnu.Resize(0, 0, win.W(), 25)
})
win.Show()
fltk.Run()
}

3
go.mod
View File

@@ -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
@@ -45,6 +45,7 @@ require (
github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a // indirect github.com/prometheus/client_model v0.2.1-0.20210607210712-147c58e9608a // indirect
github.com/prometheus/common v0.32.1 // indirect github.com/prometheus/common v0.32.1 // indirect
github.com/prometheus/procfs v0.7.3 // indirect github.com/prometheus/procfs v0.7.3 // indirect
github.com/pwiecz/go-fltk v0.0.0-20240525043121-5313f8a5a643 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect
go.opencensus.io v0.22.5 // indirect go.opencensus.io v0.22.5 // indirect

6
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.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=
@@ -239,6 +239,8 @@ github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4O
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU= github.com/prometheus/procfs v0.7.3 h1:4jVXhlkAyzOScmCkXBTOLRLTz8EeU+eyjrwB/EPq0VU=
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/pwiecz/go-fltk v0.0.0-20240525043121-5313f8a5a643 h1:t1fpLVVcboeJvXMiwMCpF1MBiQGg7VyTBqjLEEe+qXM=
github.com/pwiecz/go-fltk v0.0.0-20240525043121-5313f8a5a643/go.mod h1:uMK5daOr9p+ba2BPs5QadbfaqqrHR5TGj13yWGsAsmw=
github.com/redis/go-redis/v9 v9.5.3 h1:fOAp1/uJG+ZtcITgZOfYFmTKPE7n4Vclj1wZFgRciUU= github.com/redis/go-redis/v9 v9.5.3 h1:fOAp1/uJG+ZtcITgZOfYFmTKPE7n4Vclj1wZFgRciUU=
github.com/redis/go-redis/v9 v9.5.3/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= github.com/redis/go-redis/v9 v9.5.3/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M=
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=

View File

@@ -10,19 +10,28 @@ import (
var assetsFs embed.FS var assetsFs embed.FS
const ( const (
imgArrowRefresh int32 = iota imgAdd 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
@@ -47,6 +56,9 @@ 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)
@@ -54,17 +66,26 @@ 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,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)
} }

View File

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

View File

@@ -7,10 +7,11 @@ import (
) )
var ErrNavNotExist error = errors.New("The selected item no longer exists") var ErrNavNotExist error = errors.New("The selected item no longer exists")
var ErrNotSupported error = errors.New("Unsupported action for this database type")
type contextAction struct { type contextAction struct {
Name string Name string
Callback func(ndata *navData) Callback func(sender vcl.IComponent, ndata *navData) error
} }
// loadedDatabase is a DB-agnostic interface for each loaded database. // loadedDatabase is a DB-agnostic interface for each loaded database.
@@ -18,8 +19,9 @@ type loadedDatabase interface {
DisplayName() string DisplayName() string
DriverName() string DriverName() string
RootElement() *vcl.TTreeNode RootElement() *vcl.TTreeNode
RenderForNav(f *TMainForm, ndata *navData) RenderForNav(f *TMainForm, ndata *navData) error
ExecQuery(query string, resultArea *vcl.TListView) ApplyChanges(f *TMainForm, ndata *navData) error
ExecQuery(query string, resultArea *vcl.TStringGrid) error
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)

341
main.go
View File

@@ -3,6 +3,7 @@ package main
import ( import (
"errors" "errors"
"fmt" "fmt"
"runtime"
"runtime/debug" "runtime/debug"
"strings" "strings"
"unsafe" "unsafe"
@@ -16,6 +17,11 @@ 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 {
@@ -30,9 +36,13 @@ type TMainForm struct {
Buckets *vcl.TTreeView Buckets *vcl.TTreeView
Tabs *vcl.TPageControl Tabs *vcl.TPageControl
propertiesBox *vcl.TMemo propertiesBox *vcl.TMemo
contentBox *vcl.TListView contentBox *vcl.TStringGrid
queryInput *vcl.TMemo isEditing bool
queryResult *vcl.TListView 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
queryInput *vcl.TRichEdit
queryResult *vcl.TStringGrid
none *noLoadedDatabase none *noLoadedDatabase
dbs []loadedDatabase dbs []loadedDatabase
@@ -51,6 +61,8 @@ 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)
@@ -79,6 +91,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)
@@ -126,7 +147,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(imgLightning) mnuQueryExecute.SetImageIndex(imgResultsetNext)
mnuQuery.Add(mnuQueryExecute) mnuQuery.Add(mnuQueryExecute)
mnuHelp := vcl.NewMenuItem(f) mnuHelp := vcl.NewMenuItem(f)
@@ -187,8 +208,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)
@@ -198,14 +219,56 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
dataTab.SetCaption("Data") dataTab.SetCaption("Data")
dataTab.SetImageIndex(imgTable) dataTab.SetImageIndex(imgTable)
f.contentBox = vcl.NewListView(dataTab) dataButtonBar := vcl.NewToolBar(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() })
dataInsertBtn := vcl.NewToolButton(dataButtonBar)
dataInsertBtn.SetParent(dataButtonBar)
dataInsertBtn.SetImageIndex(imgAdd)
dataInsertBtn.SetHint("Insert")
dataInsertBtn.SetShowHint(true)
dataInsertBtn.SetOnClick(f.OnDataInsertClick)
dataDelRowBtn := vcl.NewToolButton(dataButtonBar)
dataDelRowBtn.SetParent(dataButtonBar)
dataDelRowBtn.SetImageIndex(imgDelete)
dataDelRowBtn.SetHint("Delete Row")
dataDelRowBtn.SetShowHint(true)
dataDelRowBtn.SetOnClick(f.OnDataDeleteRowClick)
dataCommitBtn := vcl.NewToolButton(dataButtonBar)
dataCommitBtn.SetParent(dataButtonBar)
dataCommitBtn.SetImageIndex(imgPencilGo)
dataCommitBtn.SetHint("Commit")
dataCommitBtn.SetShowHint(true)
dataCommitBtn.SetOnClick(f.OnDataCommitClick)
f.contentBox = vcl.NewStringGrid(dataTab)
f.contentBox.SetParent(dataTab) f.contentBox.SetParent(dataTab)
f.contentBox.BorderSpacing().SetAround(MY_SPACING) f.contentBox.BorderSpacing().SetLeft(MY_SPACING)
f.contentBox.SetAlign(types.AlClient) // fill remaining space f.contentBox.BorderSpacing().SetRight(MY_SPACING)
f.contentBox.SetViewStyle(types.VsReport) // "Report style" i.e. has columns f.contentBox.BorderSpacing().SetBottom(MY_SPACING)
f.contentBox.SetAutoWidthLastColumn(true) f.contentBox.SetAlign(types.AlClient) // fill remaining space
f.contentBox.SetReadOnly(true) f.contentBox.SetOptions(f.contentBox.Options().Include(types.GoThumbTracking, types.GoColSizing, types.GoDblClickAutoSize, types.GoEditing))
f.contentBox.Columns().Clear() f.contentBox.SetOnPrepareCanvas(f.OnDataPrepareCanvas)
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)
@@ -217,42 +280,50 @@ 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)
queryExecBtn := vcl.NewToolButton(queryButtonBar) queryExecBtn := vcl.NewToolButton(queryButtonBar)
queryExecBtn.SetParent(queryButtonBar) queryExecBtn.SetParent(queryButtonBar)
queryExecBtn.SetCaption("Execute") queryExecBtn.SetHint("Execute")
// queryExecBtn.SetImageIndex(imgLightning) queryExecBtn.SetShowHint(true)
queryExecBtn.SetImageIndex(imgResultsetNext)
queryExecBtn.SetOnClick(f.OnQueryExecute) queryExecBtn.SetOnClick(f.OnQueryExecute)
f.queryInput = vcl.NewMemo(queryTab) f.queryInput = vcl.NewRichEdit(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)
f.queryInput.Font().SetName("monospace") if runtime.GOOS == "windows" {
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(0) //f.queryInput.BorderSpacing().SetTop(1)
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.NewListView(queryTab) f.queryResult = vcl.NewStringGrid(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
@@ -261,7 +332,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("Bolt database|*.db|All files|*.*") dlg.SetFilter(boltFilter)
ret := dlg.Execute() // Fake blocking ret := dlg.Execute() // Fake blocking
if ret { if ret {
f.boltAddDatabaseFromFile(dlg.FileName(), false) f.boltAddDatabaseFromFile(dlg.FileName(), false)
@@ -271,7 +342,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("Bolt database|*.db|All files|*.*") dlg.SetFilter(boltFilter)
ret := dlg.Execute() // Fake blocking ret := dlg.Execute() // Fake blocking
if ret { if ret {
f.boltAddDatabaseFromFile(dlg.FileName(), false) f.boltAddDatabaseFromFile(dlg.FileName(), false)
@@ -281,7 +352,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("Bolt database|*.db|All files|*.*") dlg.SetFilter(boltFilter)
ret := dlg.Execute() // Fake blocking ret := dlg.Execute() // Fake blocking
if ret { if ret {
f.boltAddDatabaseFromFile(dlg.FileName(), true) f.boltAddDatabaseFromFile(dlg.FileName(), true)
@@ -293,7 +364,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("SQLite database|*.db;*.db3;*.sqlite;*.sqlite3|All files|*.*") dlg.SetFilter(sqliteFilter)
ret := dlg.Execute() // Fake blocking ret := dlg.Execute() // Fake blocking
if ret { if ret {
f.sqliteAddDatabaseFromFile(dlg.FileName(), cliDriver) f.sqliteAddDatabaseFromFile(dlg.FileName(), cliDriver)
@@ -322,6 +393,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 +452,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
@@ -423,7 +507,10 @@ 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) {
cb(ndata) err = cb(f, ndata)
if err != nil {
vcl.ShowMessage(err.Error())
}
f.OnNavContextRefresh(curItem, ndata) f.OnNavContextRefresh(curItem, ndata)
}) })
mnu.Items().Add(mnuAction) mnu.Items().Add(mnuAction)
@@ -447,6 +534,18 @@ 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()
@@ -463,6 +562,124 @@ 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())
err := ndata.ld.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 {
@@ -480,6 +697,46 @@ 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 {
@@ -500,7 +757,11 @@ func (f *TMainForm) OnQueryExecute(sender vcl.IObject) {
} }
ndata := (*navData)(node.Data()) ndata := (*navData)(node.Data())
ndata.ld.ExecQuery(queryString, f.queryResult) err := ndata.ld.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) {
@@ -513,7 +774,21 @@ func (f *TMainForm) OnNavChange(sender vcl.IObject, node *vcl.TTreeNode) {
ld = ndata.ld ld = ndata.ld
} }
ld.RenderForNav(f, ndata) // Handover to the database type's own renderer function // Reset some controls that the render function is expected to populate
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)
}
// 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

115
sqliteclidriver/eventcmd.go Normal file
View 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
}
}
}

View 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)
}

View 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
}

View 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)
}

View File

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

View File

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

View File

@@ -8,4 +8,14 @@ func box_interface[T any](input []T) []interface{} {
ret = append(ret, v) ret = append(ret, v)
} }
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,14 +1,19 @@
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.
@@ -49,3 +54,66 @@ 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
}
}
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
}