Compare commits

..

56 Commits

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

View File

@ -1,32 +1,34 @@
SHELL:=/bin/bash SHELL:=/bin/bash
SOURCES=$(find . -name '*.go' -type f) SOURCES=$(find . -name '*.go' -type f)
.DEFAULT_GOAL := dist
liblcl-2.2.3.zip: liblcl-2.2.3.zip:
rm -f liblcl-2.2.3.zip
wget 'https://github.com/ying32/govcl/releases/download/v2.2.3/liblcl-2.2.3.zip' wget 'https://github.com/ying32/govcl/releases/download/v2.2.3/liblcl-2.2.3.zip'
liblcl.so: liblcl-2.2.3.zip liblcl.so: liblcl-2.2.3.zip
echo "154b4e4a1d5137a2ffe51cb4d0bf152dd997c12616ae30862775c0e4f0928e88 liblcl-2.2.3.zip" | sha256sum -c echo "154b4e4a1d5137a2ffe51cb4d0bf152dd997c12616ae30862775c0e4f0928e88 liblcl-2.2.3.zip" | sha256sum -c
rm -f liblcl.so
unzip -j liblcl-2.2.3.zip linux64-gtk2/liblcl.so -d . unzip -j liblcl-2.2.3.zip linux64-gtk2/liblcl.so -d .
touch liblcl.so touch liblcl.so
liblcl.dll: liblcl-2.2.3.zip liblcl.dll: liblcl-2.2.3.zip
echo "154b4e4a1d5137a2ffe51cb4d0bf152dd997c12616ae30862775c0e4f0928e88 liblcl-2.2.3.zip" | sha256sum -c echo "154b4e4a1d5137a2ffe51cb4d0bf152dd997c12616ae30862775c0e4f0928e88 liblcl-2.2.3.zip" | sha256sum -c
rm -f liblcl.dll
unzip -j liblcl-2.2.3.zip win64/liblcl.dll -d . unzip -j liblcl-2.2.3.zip win64/liblcl.dll -d .
touch liblcl.dll touch liblcl.dll
yvbolt: $(SOURCES) yvbolt: $(SOURCES)
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

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

33
TODO
View File

@ -1,6 +1,10 @@
- Insert - Syntax highlighting in editor
- Update cell - Mutation
- Delete row(s) - 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
- 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,26 +13,29 @@
- 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
- Faster virtual rendering - Faster virtual rendering
- https://github.com/ying32/govcl/blob/master/samples/listviewvirtualdata/main.go - https://github.com/ying32/govcl/blob/master/samples/listviewvirtualdata/main.go
- Query history
- Test suite
- `CREATE TABLE foo (id integer primary key, aaa text not null, bbb text not null);`

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,13 @@ 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 (ld *badgerLoadedDatabase) NavChildren(ndata *navData) ([]string, error) { func (ld *badgerLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
@ -107,10 +98,6 @@ func (ld *badgerLoadedDatabase) NavContext(ndata *navData) ([]contextAction, err
return nil, nil // No special actions are supported return nil, nil // No special actions are supported
} }
func (ld *badgerLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
vcl.ShowMessage("Badger doesn't support querying")
}
func (ld *badgerLoadedDatabase) Close() { func (ld *badgerLoadedDatabase) Close() {
_ = ld.db.Close() _ = ld.db.Close()
ld.arena = nil ld.arena = nil

View File

@ -1,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,9 @@ 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) {
vcl.ShowMessage("Bolt doesn't support querying")
} }
func (ld *boltLoadedDatabase) Close() { func (ld *boltLoadedDatabase) Close() {

132
db_debconf.go Normal file
View File

@ -0,0 +1,132 @@
package main
import (
"fmt"
"os"
"path/filepath"
"unsafe"
"yvbolt/debconf"
"github.com/ying32/govcl/vcl"
)
type debconfLoadedDatabase struct {
displayName string
db *debconf.Database
nav *vcl.TTreeNode
arena []*navData // keepalive
}
func (ld *debconfLoadedDatabase) DisplayName() string {
return ld.displayName
}
func (ld *debconfLoadedDatabase) DriverName() string {
return "debconf"
}
func (ld *debconfLoadedDatabase) RootElement() *vcl.TTreeNode {
return ld.nav
}
func (ld *debconfLoadedDatabase) Keepalive(ndata *navData) {
ld.arena = append(ld.arena, ndata)
}
func (ld *debconfLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) error {
// Load properties
content := fmt.Sprintf("Entries: %d\nUnique attributes: %d\n", len(ld.db.Entries), len(ld.db.AllColumnNames))
f.propertiesBox.SetText(content)
// Load data
indexes := make(map[string]int)
for i, cname := range ld.db.AllColumnNames {
indexes[cname] = i
col := f.contentBox.Columns().Add()
col.Title().SetCaption(cname)
}
for _, entry := range ld.db.Entries {
rpos := f.contentBox.RowCount()
f.contentBox.SetRowCount(rpos + 1)
f.contentBox.SetCells(0, rpos, entry.Name)
for _, proppair := range entry.Properties {
f.contentBox.SetCells(int32(indexes[proppair[0]]), rpos, proppair[1])
}
}
// Valid
vcl_stringgrid_columnwidths(f.contentBox)
f.contentBox.SetEnabled(true)
return nil
}
func (ld *debconfLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
// In the debconf implementation, there is only one child: "Data"
if len(ndata.bucketPath) == 0 {
return []string{"Data"}, nil
} else {
// No children deeper than that
return []string{}, nil
}
}
func (ld *debconfLoadedDatabase) NavContext(ndata *navData) ([]contextAction, error) {
return nil, nil // No special actions are supported
}
func (ld *debconfLoadedDatabase) Close() {
ld.arena = nil
}
var _ loadedDatabase = &debconfLoadedDatabase{} // interface assertion
//
func (f *TMainForm) debconfAddDatabaseFrom(path string) {
// TODO load in background thread to stop blocking the UI
fh, err := os.OpenFile(path, os.O_RDONLY, 0400)
if err != nil {
vcl.ShowMessage(fmt.Sprintf("Failed to load database: %s", err.Error()))
return
}
defer fh.Close()
db, err := debconf.Parse(fh)
if err != nil {
vcl.ShowMessage(fmt.Sprintf("Failed to load database: %s", err.Error()))
return
}
ld := &debconfLoadedDatabase{
db: db,
displayName: filepath.Base(path),
}
ld.nav = f.Buckets.Items().Add(nil, ld.displayName)
ld.nav.SetHasChildren(true) // dynamically populate in OnNavExpanding
ld.nav.SetImageIndex(imgDatabase)
ld.nav.SetSelectedIndex(imgDatabase)
navData := &navData{
ld: ld,
childrenLoaded: false, // will be loaded dynamically
bucketPath: []string{}, // empty = root
}
ld.nav.SetData(unsafe.Pointer(navData))
f.dbs = append(f.dbs, ld)
f.Buckets.SetSelected(ld.nav) // Select new element
ld.Keepalive(navData)
}

View File

@ -18,13 +18,9 @@ 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) 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,19 @@ 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 (ld *pebbleLoadedDatabase) NavChildren(ndata *navData) ([]string, error) { func (ld *pebbleLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
@ -95,10 +89,6 @@ func (ld *pebbleLoadedDatabase) NavContext(ndata *navData) ([]contextAction, err
return nil, nil // No special actions are supported return nil, nil // No special actions are supported
} }
func (ld *pebbleLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
vcl.ShowMessage("pebble doesn't support querying")
}
func (ld *pebbleLoadedDatabase) Close() { func (ld *pebbleLoadedDatabase) Close() {
_ = ld.db.Close() _ = ld.db.Close()
ld.arena = nil ld.arena = nil

View File

@ -10,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,17 +116,18 @@ 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
} }
} }
@ -170,48 +158,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

@ -1,19 +1,22 @@
package main package main
import ( import (
"context"
"database/sql" "database/sql"
"errors"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings"
"unsafe" "unsafe"
_ "yvbolt/sqliteclidriver" _ "yvbolt/sqliteclidriver"
"github.com/ying32/govcl/vcl" "github.com/ying32/govcl/vcl"
"github.com/ying32/govcl/vcl/types"
) )
const ( const (
sqliteTablesCaption = "Tables" sqliteTablesCaption = "Tables"
sqliteFilter = "SQLite database|*.db;*.db3;*.sqlite;*.sqlite3|All files|*.*"
) )
type sqliteLoadedDatabase struct { type sqliteLoadedDatabase struct {
@ -37,19 +40,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 +67,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,19 +82,21 @@ 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("?")
} }
@ -129,17 +128,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 +152,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 +165,149 @@ 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 (n *sqliteLoadedDatabase) ApplyChanges(f *TMainForm, ndata *navData) (retErr error) {
if len(ndata.bucketPath) != 2 {
return errors.New("invalid selection")
}
tableName := ndata.bucketPath[1]
// We have rendered row IDs, need to convert back to an SQLite primary key
// TODO stash the real key inside f.contentBox.Objects()
// FIXME breaks if you try and edit the primary key(!)
ctx := context.Background()
tx, err := n.db.BeginTx(ctx, nil)
if err != nil {
return err
}
var commitOK bool = false
defer func() {
if !commitOK {
err := tx.Rollback()
if err != nil {
retErr = err
}
}
}()
// Data grid properties
var columnNames []string
for i := int32(0); i < f.contentBox.ColCount(); i++ {
columnNames = append(columnNames, f.contentBox.Columns().Items(i).Title().Caption())
}
// Query sqlite table metadata to determine which of these is the PRIMARY KEY
var primaryColumnName string
err = tx.QueryRowContext(ctx, `SELECT l.name FROM pragma_table_info(?) as l WHERE l.pk = 1;`, tableName).Scan(&primaryColumnName)
if err != nil {
return fmt.Errorf("Finding primary key for update: %w", err)
}
// Convert it to an index
var primaryColumnIdx int = -1
for i := 0; i < len(columnNames); i++ {
if columnNames[i] == primaryColumnName {
primaryColumnIdx = i
break
}
}
if primaryColumnIdx == -1 {
return fmt.Errorf("Primary key %q missing from available columns", primaryColumnName)
}
// SQLite can only LIMIT 1 on update/delete if it was compiled with
// SQLITE_ENABLE_UPDATE_DELETE_LIMIT, which isn't the case for the mattn
// cgo library
// Skip that, and just rely on primary key uniqueness
// Edit
for rowid, editcells := range f.updateRows {
stmt := `UPDATE "` + tableName + `" SET `
params := []interface{}{} // FIXME reinstate types for the driver (although SQLite doesn't mind)
for ct, cell := range editcells {
if ct > 0 {
stmt += `, `
}
stmt += `"` + columnNames[cell] + `" = ?`
params = append(params, f.contentBox.Cells(cell, rowid))
}
stmt += ` WHERE "` + primaryColumnName + `" = ?`
pkVal := f.contentBox.Cells(int32(primaryColumnIdx), rowid)
params = append(params, pkVal)
_, err = tx.ExecContext(ctx, stmt, params...)
if err != nil {
return fmt.Errorf("Updating row %q: %w", pkVal, err)
}
}
// Delete by key (affects rowids after re-render)
for rowid, _ := range f.deleteRows {
pkVal := f.contentBox.Cells(int32(primaryColumnIdx), rowid)
stmt := `DELETE FROM "` + tableName + `" WHERE "` + primaryColumnName + `" = ?`
_, err = tx.ExecContext(ctx, stmt, pkVal)
if err != nil {
return fmt.Errorf("Deleting row %q: %w", pkVal, err)
}
}
// Insert all new entries
for rowid, _ := range f.insertRows {
stmt := `INSERT INTO "` + tableName + `" (` + strings.Join(columnNames, `, `) + `) VALUES (`
params := []interface{}{} // FIXME reinstate types for the driver (although SQLite doesn't mind)
for colid := 0; colid < len(columnNames); colid++ {
if colid > 0 {
stmt += `, `
}
stmt += "?"
params = append(params, f.contentBox.Cells(int32(colid), rowid))
}
stmt += `)`
_, err = tx.ExecContext(ctx, stmt, params...)
if err != nil {
return fmt.Errorf("Inserting row: %w", err)
}
}
err = tx.Commit()
if err != nil {
return err
}
commitOK = true // no need for rollback
return nil
} }
func (ld *sqliteLoadedDatabase) NavChildren(ndata *navData) ([]string, error) { func (ld *sqliteLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
@ -232,8 +348,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))
}
}

2
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

4
go.sum
View File

@ -160,8 +160,8 @@ github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.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=

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

@ -10,7 +10,7 @@ var ErrNavNotExist error = errors.New("The selected item no longer exists")
type contextAction struct { type contextAction struct {
Name string Name string
Callback func(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,14 +18,21 @@ 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)
NavChildren(ndata *navData) ([]string, error) NavChildren(ndata *navData) ([]string, error)
NavContext(ndata *navData) ([]contextAction, error) NavContext(ndata *navData) ([]contextAction, error)
Keepalive(ndata *navData) Keepalive(ndata *navData)
Close() Close()
} }
type queryableLoadedDatabase interface {
ExecQuery(query string, resultArea *vcl.TStringGrid) error
}
type editableLoadedDatabase interface {
ApplyChanges(f *TMainForm, ndata *navData) error
}
// navData is the .Data() pointer for each TTreeNode in the left-hand tree. // navData is the .Data() pointer for each TTreeNode in the left-hand tree.
type navData struct { type navData struct {
ld loadedDatabase ld loadedDatabase

381
main.go
View File

@ -3,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,17 @@ 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 dataInsertBtn *vcl.TToolButton
queryResult *vcl.TListView dataDelRowBtn *vcl.TToolButton
dataCommitBtn *vcl.TToolButton
isEditing bool
insertRows map[int32]struct{} // Rows in the StringGrid that are to-be-inserted
deleteRows map[int32]struct{}
updateRows map[int32][]int32 // Row->cells that are to-be-updated
queryExecBtn *vcl.TToolButton
queryInput *vcl.TRichEdit
queryResult *vcl.TStringGrid
none *noLoadedDatabase none *noLoadedDatabase
dbs []loadedDatabase dbs []loadedDatabase
@ -51,6 +65,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 +95,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 +151,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 +212,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 +223,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() })
f.dataInsertBtn = vcl.NewToolButton(dataButtonBar)
f.dataInsertBtn.SetParent(dataButtonBar)
f.dataInsertBtn.SetImageIndex(imgAdd)
f.dataInsertBtn.SetHint("Insert")
f.dataInsertBtn.SetShowHint(true)
f.dataInsertBtn.SetOnClick(f.OnDataInsertClick)
f.dataDelRowBtn = vcl.NewToolButton(dataButtonBar)
f.dataDelRowBtn.SetParent(dataButtonBar)
f.dataDelRowBtn.SetImageIndex(imgDelete)
f.dataDelRowBtn.SetHint("Delete Row")
f.dataDelRowBtn.SetShowHint(true)
f.dataDelRowBtn.SetOnClick(f.OnDataDeleteRowClick)
f.dataCommitBtn = vcl.NewToolButton(dataButtonBar)
f.dataCommitBtn.SetParent(dataButtonBar)
f.dataCommitBtn.SetImageIndex(imgPencilGo)
f.dataCommitBtn.SetHint("Commit")
f.dataCommitBtn.SetShowHint(true)
f.dataCommitBtn.SetOnClick(f.OnDataCommitClick)
f.contentBox = vcl.NewStringGrid(dataTab)
f.contentBox.SetParent(dataTab) f.contentBox.SetParent(dataTab)
f.contentBox.BorderSpacing().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 +284,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) f.queryExecBtn = vcl.NewToolButton(queryButtonBar)
queryExecBtn.SetParent(queryButtonBar) f.queryExecBtn.SetParent(queryButtonBar)
queryExecBtn.SetCaption("Execute") f.queryExecBtn.SetHint("Execute")
// queryExecBtn.SetImageIndex(imgLightning) f.queryExecBtn.SetShowHint(true)
queryExecBtn.SetOnClick(f.OnQueryExecute) f.queryExecBtn.SetImageIndex(imgResultsetNext)
f.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 +336,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 +346,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 +356,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 +368,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 +397,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 +456,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 +511,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 +538,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 +566,131 @@ func (f *TMainForm) OnNavContextRefresh(item *vcl.TTreeNode, ndata *navData) {
item.SetExpanded(isExpanded) item.SetExpanded(isExpanded)
} }
func (f *TMainForm) OnDataPrepareCanvas(sender vcl.IObject, aCol, aRow int32, aState types.TGridDrawState) {
if _, ok := f.deleteRows[aRow]; ok {
f.contentBox.Canvas().Brush().SetColor(CO_DELETE)
return
}
if _, ok := f.insertRows[aRow]; ok {
f.contentBox.Canvas().Brush().SetColor(CO_INSERT)
return
}
if er, ok := f.updateRows[aRow]; ok {
// This row is being edited
if int32slice_contains(er, aCol) {
f.contentBox.Canvas().Brush().SetColor(CO_EDIT_EXPLICIT)
} else {
f.contentBox.Canvas().Brush().SetColor(CO_EDIT_IMPLICIT)
}
return
}
}
// func (f *TMainForm) OnDataCellEditStarting(sender vcl.IObject, aCol, aRow int32, editor **vcl.TWinControl) {
func (f *TMainForm) OnDataCellEditStarting(sender vcl.IObject, aCol, aRow int32, value *string) {
f.isEditing = true
}
func (f *TMainForm) OnDataCellEdited(sender vcl.IObject) {
// The OnEditingDone event fires whenever the TStringGrid loses focus, even
// if editing was not currently taking place
// To detect real edits, set a flag in the OnSelectEditor event
if !f.isEditing {
return
}
f.isEditing = false
aRow := f.contentBox.Row()
aCol := f.contentBox.Col()
// If this is an insert row, no need to patch updateRows
if _, ok := f.insertRows[aRow]; ok {
return // nothing to do
}
if chk, ok := f.updateRows[aRow]; ok {
if int32slice_contains(chk, aCol) {
// nothing to do
} else {
chk = append(chk, aCol)
f.updateRows[aRow] = chk
}
} else {
f.updateRows[aRow] = []int32{aCol}
}
// If this row was marked for deletion, this new event takes priority
delete(f.deleteRows, aRow)
// Signal repaint
f.contentBox.InvalidateRow(aRow)
}
func (f *TMainForm) OnDataInsertClick(sender vcl.IObject) {
if !f.contentBox.Enabled() {
return // Not an active data view
}
rpos := f.contentBox.RowCount()
f.contentBox.SetRowCount(rpos + 1)
f.insertRows[rpos] = struct{}{}
// Scroll to bottom
f.contentBox.SetTopRow(rpos)
}
func (f *TMainForm) OnDataDeleteRowClick(sender vcl.IObject) {
if !f.contentBox.Enabled() {
return // Not an active data view
}
rpos := f.contentBox.Row()
f.deleteRows[rpos] = struct{}{}
// If this row was marked for edit, this takes priority
delete(f.updateRows, rpos)
// Repaint
f.contentBox.InvalidateRow(rpos)
}
func (f *TMainForm) OnDataCommitClick(sender vcl.IObject) {
if !f.contentBox.Enabled() {
return // Not an active data view
}
node := f.Buckets.Selected()
if node == nil {
vcl.ShowMessage("No database selected")
return
}
scrollPos := f.contentBox.TopRow()
ndata := (*navData)(node.Data())
editableLd, ok := ndata.ld.(editableLoadedDatabase)
if !ok {
vcl.ShowMessage("Unsupported action for this database")
return
}
err := editableLd.ApplyChanges(f, ndata)
if err != nil {
vcl.ShowMessage(err.Error())
}
// Refresh content
f.OnNavChange(f.Buckets, node) // Refresh RHS pane/data content
// Preserve scroll position
f.contentBox.SetTopRow(scrollPos)
}
func (f *TMainForm) OnNavContextClose(sender vcl.IObject) { func (f *TMainForm) OnNavContextClose(sender vcl.IObject) {
curItem := f.Buckets.Selected() curItem := f.Buckets.Selected()
if curItem == nil { if curItem == nil {
@ -480,6 +708,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 {
@ -492,6 +760,10 @@ func (f *TMainForm) OnQueryExecute(sender vcl.IObject) {
queryString = f.queryInput.SelText() // Just the selected text queryString = f.queryInput.SelText() // Just the selected text
} }
if strings.TrimSpace(queryString) == "" {
return // prevent blank query
}
// Execute // Execute
node := f.Buckets.Selected() node := f.Buckets.Selected()
if node == nil { if node == nil {
@ -500,7 +772,18 @@ func (f *TMainForm) OnQueryExecute(sender vcl.IObject) {
} }
ndata := (*navData)(node.Data()) ndata := (*navData)(node.Data())
ndata.ld.ExecQuery(queryString, f.queryResult)
queryableLd, ok := ndata.ld.(queryableLoadedDatabase)
if !ok {
vcl.ShowMessage("Unsupported action for this database")
return
}
err := queryableLd.ExecQuery(queryString, f.queryResult)
if err != nil {
vcl.ShowMessage(err.Error())
return
}
} }
func (f *TMainForm) OnNavChange(sender vcl.IObject, node *vcl.TTreeNode) { func (f *TMainForm) OnNavChange(sender vcl.IObject, node *vcl.TTreeNode) {
@ -513,7 +796,33 @@ 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)
}
// Toggle the Edit functionality
_, ok := ld.(editableLoadedDatabase)
f.dataCommitBtn.SetEnabled(ok)
f.dataDelRowBtn.SetEnabled(ok)
f.dataInsertBtn.SetEnabled(ok)
// Toggle the Query functionality
_, ok = ld.(queryableLoadedDatabase)
f.queryInput.SetEnabled(ok)
f.queryResult.SetEnabled(ok)
f.queryExecBtn.SetEnabled(ok)
// We're in charge of common status bar text updates // We're in charge of common status bar text updates
f.StatusBar.SetSimpleText(ld.DisplayName() + " | " + ld.DriverName()) f.StatusBar.SetSimpleText(ld.DisplayName() + " | " + ld.DriverName())

View File

@ -26,9 +26,9 @@ type TRedisConnectionDialog struct {
func (f *TRedisConnectionDialog) OnFormCreate(sender vcl.IObject) { func (f *TRedisConnectionDialog) OnFormCreate(sender vcl.IObject) {
f.SetCaption("Connect to Redis...") f.SetCaption("Connect to Redis...")
f.ScreenCenter()
f.SetWidth(320) f.SetWidth(320)
f.SetHeight(160) f.SetHeight(160)
f.SetPosition(types.PoOwnerFormCenter)
// row 1 // row 1

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,70 @@ func vcl_menuseparator(parent *vcl.TMenuItem) *vcl.TMenuItem {
return s return s
} }
func vcl_default_tab_background() types.TColor {
if runtime.GOOS == "windows" {
// Assuming that uxtheme is loaded
// @ref https://stackoverflow.com/a/20332712
// return colors.ClBtnHighlight
// None of the colors.** constants seem to be quite right on a test
// Windows 11 machine - should be #f9f9f9
return 0x00f9f9f9
} else {
return colors.ClBtnFace // 0x00f0f0f0
}
}
func vcl_stringgrid_clear(d *vcl.TStringGrid) {
d.SetFixedCols(0) // No left-hand cols
d.SetRowCount(1) // The 0th row is the column headers
d.SetEnabled(false)
d.Columns().Clear()
d.Invalidate()
}
func vcl_stringgrid_columnwidths(d *vcl.TStringGrid) {
// Skip slow processing for very large result sets
if d.RowCount() > 1000 {
return
}
d.AutoAdjustColumns()
// AutoAdjustColumns will leave some columns massively too large by themselves
// Reign them back in
ct := d.Columns().Count()
for i := int32(0); i < ct; i++ {
if d.ColWidths(i) > MAX_AUTO_COL_WIDTH {
d.SetColWidths(i, MAX_AUTO_COL_WIDTH)
}
}
}
func vcl_confirm_dialog(sender vcl.IComponent, title string, message string) bool {
dlg := vcl.NewTaskDialog(sender)
dlg.SetCaption(APPNAME)
dlg.SetTitle(title)
dlg.SetText(message)
dlg.SetCommonButtons(types.NewSet())
yesBtn := dlg.Buttons().Add()
yesBtn.SetCaption("Confirm")
yesBtn.SetModalResult(types.MrYes)
noBtn := dlg.Buttons().Add()
noBtn.SetCaption("Cancel")
noBtn.SetModalResult(types.MrCancel)
ret := dlg.Execute()
if !ret {
return false // dialog closed
}
if dlg.ModalResult() != types.MrYes {
return false // other button clicked
}
return true // confirmed
}