Compare commits
56 Commits
Author | SHA1 | Date | |
---|---|---|---|
c541e8b941 | |||
877f291a1f | |||
6145320858 | |||
8296a2fec9 | |||
223d13be58 | |||
eca27dcd4f | |||
0f2a3e021a | |||
90259fb2b9 | |||
7573cf0453 | |||
6dd0635c9e | |||
ce3d08740f | |||
8f5e1054fb | |||
1ac96eb133 | |||
53e9b6555e | |||
e1a9f187cb | |||
ee3110162b | |||
35a83eb483 | |||
60add3be86 | |||
2f65ffdd70 | |||
aad92d27e9 | |||
21151be8a3 | |||
f78eec1872 | |||
8af27f8834 | |||
0d3b90b879 | |||
2b59efc410 | |||
7fbf2ef1ed | |||
d7e3363173 | |||
cecfc338d4 | |||
35f09fc072 | |||
2163b46907 | |||
81b6b08e7b | |||
f31724a110 | |||
063a8ca837 | |||
1cfc94a42b | |||
053e07c319 | |||
0b91c379b8 | |||
7b4cc885f5 | |||
3b17ddd8a4 | |||
8d051a14e5 | |||
4735c391bd | |||
0866e5edac | |||
5c44dc5f54 | |||
a7dd1ca340 | |||
abcf7dbfe5 | |||
d359f42b24 | |||
7cec5cee4c | |||
be91cd54c6 | |||
b141aaaa6c | |||
493ab846b9 | |||
d3ebcb4666 | |||
50cf207eae | |||
e5cbbb6822 | |||
18674568dd | |||
748dd96267 | |||
c5578daa9f | |||
3bc7f539ad |
14
Makefile
@ -1,32 +1,34 @@
|
||||
SHELL:=/bin/bash
|
||||
|
||||
SOURCES=$(find . -name '*.go' -type f)
|
||||
.DEFAULT_GOAL := dist
|
||||
|
||||
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'
|
||||
|
||||
liblcl.so: liblcl-2.2.3.zip
|
||||
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 .
|
||||
touch liblcl.so
|
||||
|
||||
liblcl.dll: liblcl-2.2.3.zip
|
||||
echo "154b4e4a1d5137a2ffe51cb4d0bf152dd997c12616ae30862775c0e4f0928e88 liblcl-2.2.3.zip" | sha256sum -c
|
||||
rm -f liblcl.dll
|
||||
unzip -j liblcl-2.2.3.zip win64/liblcl.dll -d .
|
||||
touch liblcl.dll
|
||||
|
||||
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
|
||||
upx --best yvbolt
|
||||
|
||||
yvbolt.exe: $(SOURCES)
|
||||
GOOS=windows GOARCH=amd64 CGO_ENABLED=1 CC=x86_64-w64-mingw32-gcc-win32 go build -trimpath -ldflags '-s -w'
|
||||
upx --best yvbolt.exe
|
||||
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 --lzma yvbolt.exe
|
||||
|
||||
yvbolt.linux64.tar.xz: yvbolt liblcl.so
|
||||
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
|
||||
rm -f yvbolt.win64.zip
|
||||
|
47
README.md
@ -1,13 +1,11 @@
|
||||
# yvbolt
|
||||
|
||||
A graphical browser for multiple databases using [GoVCL](https://z-kit.cc/en/).
|
||||
|
||||
This is an experimental application and you should generally prefer to use [qbolt](https://code.ivysaur.me/qbolt).
|
||||
A graphical interface for multiple databases using [GoVCL](https://z-kit.cc/en/).
|
||||
|
||||
## Features
|
||||
|
||||
- Native desktop application, running on Linux, Windows, and macOS
|
||||
- Connect to multiple databases
|
||||
- Connect to multiple databases at once
|
||||
- Browse table/bucket content
|
||||
- Use context menu to perform special table/bucket actions
|
||||
- Run custom SQL queries
|
||||
@ -17,11 +15,16 @@ This is an experimental application and you should generally prefer to use [qbol
|
||||
- Badger v4
|
||||
- Bolt
|
||||
- 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
|
||||
- Redis
|
||||
- SQLite
|
||||
- Drivers: mattn (CGo), modernc.org (no cgo), experimental command-line driver
|
||||
- Supports editing
|
||||
- Integrated vacuum and export commands
|
||||
|
||||
## 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.
|
||||
|
||||
## Usage
|
||||
## Compiling
|
||||
|
||||
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)
|
||||
@ -40,6 +43,34 @@ This project includes trademarked logo images for each supported database type.
|
||||
|
||||
## 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
|
||||
|
||||
- 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
|
||||
- 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
|
||||
|
||||
- Redis: Add as supported database
|
||||
|
33
TODO
@ -1,6 +1,10 @@
|
||||
- Insert
|
||||
- Update cell
|
||||
- Delete row(s)
|
||||
- Syntax highlighting in editor
|
||||
- Mutation
|
||||
- Get real primary key for mutation instead of string approximation
|
||||
- Badger: Support insert/update/delete
|
||||
- Pebble: Support insert/update/delete
|
||||
- Debconf: Support insert/update/delete
|
||||
- Redis: Support insert/update/delete
|
||||
- Binary data viewer
|
||||
- Detect jpg/png and show as image
|
||||
- More DB types
|
||||
@ -9,26 +13,29 @@
|
||||
- CLI using psql
|
||||
- MSSQL (recursive navigation for instances)
|
||||
- Other K/V stores from https://github.com/smallnest/kvbench
|
||||
- SSH tunnels
|
||||
- Badger encryption key dialog
|
||||
- Pebble: connection options dialog
|
||||
- SQLite: DB compact action
|
||||
- SQLite: DB export backup action
|
||||
- SQLite: drop table action
|
||||
- SQLite: show views, triggers, indexes in nav
|
||||
- Windows registry
|
||||
- LDAP
|
||||
- Connection dialog
|
||||
- SSH tunnels
|
||||
- Badger encryption key dialog
|
||||
- Pebble: connection options dialog
|
||||
- SQLite CLI driver:
|
||||
- Basic error handling
|
||||
- Better lexing
|
||||
- Context support
|
||||
- Attach to SSH tunnel
|
||||
- Configure binary path
|
||||
- https://ricardoanderegg.com/posts/sqlite-remote-explorer-gui/
|
||||
- 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
|
||||
- Makefile to cross-compile release binaries in docker
|
||||
- Build own liblcl binaries in docker
|
||||
- Win32 icon resource
|
||||
- https://github.com/ying32/govcl/tree/master/Tools/winRes
|
||||
- Performance
|
||||
- Warning if data table is filtered to 1000 rows, or add pagination
|
||||
- Context/interrupt slow queries
|
||||
- Faster virtual rendering
|
||||
- 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
After Width: | Height: | Size: 733 B |
BIN
assets/delete.png
Normal file
After Width: | Height: | Size: 715 B |
BIN
assets/lightning_go.png
Normal file
After Width: | Height: | Size: 739 B |
BIN
assets/pencil.png
Normal file
After Width: | Height: | Size: 450 B |
BIN
assets/pencil_add.png
Normal file
After Width: | Height: | Size: 589 B |
BIN
assets/pencil_delete.png
Normal file
After Width: | Height: | Size: 603 B |
BIN
assets/pencil_go.png
Normal file
After Width: | Height: | Size: 666 B |
BIN
assets/resultset_next.png
Normal file
After Width: | Height: | Size: 395 B |
BIN
assets/vendor_debian.png
Normal file
After Width: | Height: | Size: 831 B |
33
db_badger.go
@ -7,7 +7,6 @@ import (
|
||||
|
||||
"github.com/dgraph-io/badger/v4"
|
||||
"github.com/ying32/govcl/vcl"
|
||||
"github.com/ying32/govcl/vcl/types"
|
||||
)
|
||||
|
||||
type badgerLoadedDatabase struct {
|
||||
@ -34,7 +33,7 @@ func (ld *badgerLoadedDatabase) Keepalive(ndata *navData) {
|
||||
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
|
||||
|
||||
@ -42,25 +41,15 @@ func (ld *badgerLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
|
||||
f.propertiesBox.SetText(content)
|
||||
|
||||
// Load data
|
||||
|
||||
f.contentBox.SetEnabled(false)
|
||||
f.contentBox.Clear()
|
||||
|
||||
// Badger always uses Key + Value as the columns
|
||||
|
||||
f.contentBox.Columns().Clear()
|
||||
colKey := f.contentBox.Columns().Add()
|
||||
colKey.SetCaption("Key")
|
||||
colKey.SetWidth(MY_WIDTH)
|
||||
colKey.SetAlignment(types.TaLeftJustify)
|
||||
colKey.Title().SetCaption("Key")
|
||||
colVal := f.contentBox.Columns().Add()
|
||||
colVal.SetCaption("Value")
|
||||
colVal.Title().SetCaption("Value")
|
||||
|
||||
err := ld.db.View(func(txn *badger.Txn) error {
|
||||
|
||||
// Valid
|
||||
f.contentBox.Clear()
|
||||
|
||||
// Create iterator
|
||||
opts := badger.DefaultIteratorOptions
|
||||
opts.PrefetchSize = 64
|
||||
@ -71,9 +60,10 @@ func (ld *badgerLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
|
||||
item := it.Item()
|
||||
k := item.Key()
|
||||
err := item.Value(func(v []byte) error {
|
||||
dataEntry := f.contentBox.Items().Add()
|
||||
dataEntry.SetCaption(formatUtf8(k))
|
||||
dataEntry.SubItems().Add(formatUtf8(v))
|
||||
rpos := f.contentBox.RowCount()
|
||||
f.contentBox.SetRowCount(rpos + 1)
|
||||
f.contentBox.SetCells(0, rpos, formatUtf8(k))
|
||||
f.contentBox.SetCells(1, rpos, formatUtf8(v))
|
||||
|
||||
return nil
|
||||
})
|
||||
@ -84,12 +74,13 @@ func (ld *badgerLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
vcl.ShowMessage(fmt.Sprintf("Failed to load data: %s", err.Error()))
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
// Valid
|
||||
vcl_stringgrid_columnwidths(f.contentBox)
|
||||
f.contentBox.SetEnabled(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (ld *badgerLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
|
||||
vcl.ShowMessage("Badger doesn't support querying")
|
||||
}
|
||||
|
||||
func (ld *badgerLoadedDatabase) Close() {
|
||||
_ = ld.db.Close()
|
||||
ld.arena = nil
|
||||
|
102
db_bolt.go
@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@ -9,11 +10,14 @@ import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/ying32/govcl/vcl"
|
||||
"github.com/ying32/govcl/vcl/types"
|
||||
"go.etcd.io/bbolt"
|
||||
"go.etcd.io/bbolt/version"
|
||||
)
|
||||
|
||||
const (
|
||||
boltFilter = "Bolt database|*.db|All files|*.*"
|
||||
)
|
||||
|
||||
type boltLoadedDatabase struct {
|
||||
displayName string
|
||||
path string
|
||||
@ -39,7 +43,7 @@ func (ld *boltLoadedDatabase) Keepalive(ndata *navData) {
|
||||
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
|
||||
|
||||
@ -48,19 +52,11 @@ func (ld *boltLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
|
||||
f.propertiesBox.SetText(content)
|
||||
|
||||
// Load data
|
||||
|
||||
f.contentBox.SetEnabled(false)
|
||||
f.contentBox.Clear()
|
||||
|
||||
// Bolt always uses Key + Value as the columns
|
||||
|
||||
f.contentBox.Columns().Clear()
|
||||
colKey := f.contentBox.Columns().Add()
|
||||
colKey.SetCaption("Key")
|
||||
colKey.SetWidth(MY_WIDTH)
|
||||
colKey.SetAlignment(types.TaLeftJustify)
|
||||
colKey.Title().SetCaption("Key")
|
||||
colVal := f.contentBox.Columns().Add()
|
||||
colVal.SetCaption("Value")
|
||||
colVal.Title().SetCaption("Value")
|
||||
|
||||
err := ld.db.View(func(tx *bbolt.Tx) error {
|
||||
b := boltTargetBucket(tx, ndata.bucketPath)
|
||||
@ -70,23 +66,74 @@ func (ld *boltLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
|
||||
}
|
||||
|
||||
// Valid
|
||||
f.contentBox.Clear()
|
||||
|
||||
c := b.Cursor()
|
||||
for k, v := c.First(); k != nil; k, v = c.Next() {
|
||||
dataEntry := f.contentBox.Items().Add()
|
||||
dataEntry.SetCaption(formatUtf8(k))
|
||||
dataEntry.SubItems().Add(formatUtf8(v))
|
||||
rpos := f.contentBox.RowCount()
|
||||
f.contentBox.SetRowCount(rpos + 1)
|
||||
f.contentBox.SetCells(0, rpos, formatUtf8(k))
|
||||
f.contentBox.SetCells(1, rpos, formatUtf8(v))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
vcl.ShowMessage(fmt.Sprintf("Failed to load data for bucket %q: %s", bucketDisplayName, err.Error()))
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
// Valid
|
||||
vcl_stringgrid_columnwidths(f.contentBox)
|
||||
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) {
|
||||
@ -100,13 +147,14 @@ func (ld *boltLoadedDatabase) NavContext(ndata *navData) (ret []contextAction, e
|
||||
if len(ndata.bucketPath) > 0 {
|
||||
ret = append(ret, contextAction{"Delete bucket", ld.DeleteBucket})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (ld *boltLoadedDatabase) AddChildBucket(ndata *navData) {
|
||||
func (ld *boltLoadedDatabase) AddChildBucket(sender vcl.IComponent, ndata *navData) error {
|
||||
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 {
|
||||
@ -121,11 +169,12 @@ func (ld *boltLoadedDatabase) AddChildBucket(ndata *navData) {
|
||||
return err
|
||||
})
|
||||
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 {
|
||||
// Find parent of this bucket.
|
||||
if len(ndata.bucketPath) >= 2 {
|
||||
@ -138,12 +187,9 @@ func (ld *boltLoadedDatabase) DeleteBucket(ndata *navData) {
|
||||
}
|
||||
})
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
func (ld *boltLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
|
||||
vcl.ShowMessage("Bolt doesn't support querying")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ld *boltLoadedDatabase) Close() {
|
||||
|
132
db_debconf.go
Normal 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)
|
||||
}
|
@ -18,13 +18,9 @@ func (n *noLoadedDatabase) RootElement() *vcl.TTreeNode {
|
||||
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.contentBox.SetEnabled(false)
|
||||
f.contentBox.Clear()
|
||||
}
|
||||
|
||||
func (n *noLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *noLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
|
||||
|
30
db_pebble.go
@ -9,7 +9,6 @@ import (
|
||||
"github.com/cockroachdb/pebble"
|
||||
"github.com/cockroachdb/pebble/vfs"
|
||||
"github.com/ying32/govcl/vcl"
|
||||
"github.com/ying32/govcl/vcl/types"
|
||||
)
|
||||
|
||||
type pebbleLoadedDatabase struct {
|
||||
@ -36,7 +35,7 @@ func (ld *pebbleLoadedDatabase) Keepalive(ndata *navData) {
|
||||
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()
|
||||
|
||||
@ -46,19 +45,12 @@ func (ld *pebbleLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
|
||||
f.propertiesBox.SetText(content)
|
||||
|
||||
// Load data
|
||||
|
||||
f.contentBox.SetEnabled(false)
|
||||
f.contentBox.Clear()
|
||||
|
||||
// pebble always uses Key + Value as the columns
|
||||
|
||||
f.contentBox.Columns().Clear()
|
||||
colKey := f.contentBox.Columns().Add()
|
||||
colKey.SetCaption("Key")
|
||||
colKey.SetWidth(MY_WIDTH)
|
||||
colKey.SetAlignment(types.TaLeftJustify)
|
||||
colKey.Title().SetCaption("Key")
|
||||
colVal := f.contentBox.Columns().Add()
|
||||
colVal.SetCaption("Value")
|
||||
colVal.Title().SetCaption("Value")
|
||||
|
||||
itr := ld.db.NewIterWithContext(ctx, nil)
|
||||
defer itr.Close()
|
||||
@ -67,17 +59,19 @@ func (ld *pebbleLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
|
||||
k := itr.Key()
|
||||
v, err := itr.ValueAndErr()
|
||||
if err != nil {
|
||||
vcl.ShowMessage(fmt.Sprintf("Failed to load data for key %q: %s", formatAny(k), err.Error()))
|
||||
return
|
||||
return fmt.Errorf("Failed to load data for key %q: %w", formatAny(k), err)
|
||||
}
|
||||
|
||||
dataEntry := f.contentBox.Items().Add()
|
||||
dataEntry.SetCaption(formatUtf8(k))
|
||||
dataEntry.SubItems().Add(formatUtf8(v))
|
||||
rpos := f.contentBox.RowCount()
|
||||
f.contentBox.SetRowCount(rpos + 1)
|
||||
f.contentBox.SetCells(0, rpos, formatUtf8(k))
|
||||
f.contentBox.SetCells(0, rpos, formatUtf8(v))
|
||||
}
|
||||
|
||||
// Valid
|
||||
vcl_stringgrid_columnwidths(f.contentBox)
|
||||
f.contentBox.SetEnabled(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func (ld *pebbleLoadedDatabase) ExecQuery(query string, resultArea *vcl.TListView) {
|
||||
vcl.ShowMessage("pebble doesn't support querying")
|
||||
}
|
||||
|
||||
func (ld *pebbleLoadedDatabase) Close() {
|
||||
_ = ld.db.Close()
|
||||
ld.arena = nil
|
||||
|
84
db_redis.go
@ -10,7 +10,6 @@ import (
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/ying32/govcl/vcl"
|
||||
"github.com/ying32/govcl/vcl/types"
|
||||
)
|
||||
|
||||
type redisLoadedDatabase struct {
|
||||
@ -41,83 +40,71 @@ func (ld *redisLoadedDatabase) Keepalive(ndata *navData) {
|
||||
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()
|
||||
|
||||
if len(ndata.bucketPath) == 0 {
|
||||
// Top-level: Show info() on main Properties tab
|
||||
infostr, err := ld.db.Info(ctx).Result()
|
||||
if err != nil {
|
||||
vcl.ShowMessage(fmt.Sprintf("Retreiving database info: %v", err))
|
||||
return
|
||||
return fmt.Errorf("Retreiving database info: %w", err)
|
||||
}
|
||||
|
||||
f.propertiesBox.SetText(infostr)
|
||||
|
||||
// Disable data tab
|
||||
f.contentBox.SetEnabled(false)
|
||||
f.contentBox.Clear()
|
||||
// Leave data tab disabled (default behaviour)
|
||||
return nil
|
||||
|
||||
} else if len(ndata.bucketPath) == 1 {
|
||||
// One selected database
|
||||
// Figure out its content
|
||||
err := ld.db.Do(ctx, "SELECT", ndata.bucketPath[0]).Err()
|
||||
if err != nil {
|
||||
vcl.ShowMessage(fmt.Sprintf("Switching to database %q: %v", ndata.bucketPath[0], err))
|
||||
return
|
||||
return fmt.Errorf("Switching to database %q: %w", ndata.bucketPath[0], err)
|
||||
}
|
||||
|
||||
allKeys, err := ld.db.Keys(ctx, "*").Result()
|
||||
if err != nil {
|
||||
vcl.ShowMessage(fmt.Sprintf("Listing keys in database %q: %v", ndata.bucketPath[0], err))
|
||||
return
|
||||
return fmt.Errorf("Listing keys in database %q: %w", ndata.bucketPath[0], err)
|
||||
}
|
||||
|
||||
f.propertiesBox.SetText(fmt.Sprintf("Database %s\nTotal keys: %d\n", ndata.bucketPath[0], len(allKeys)))
|
||||
|
||||
// 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.SetCaption("Key")
|
||||
colKey.SetWidth(MY_WIDTH)
|
||||
colKey.SetAlignment(types.TaLeftJustify)
|
||||
colKey.Title().SetCaption("Key")
|
||||
colType := f.contentBox.Columns().Add()
|
||||
colType.SetCaption("Type")
|
||||
colType.Title().SetCaption("Type")
|
||||
colVal := f.contentBox.Columns().Add()
|
||||
colVal.SetCaption("Value")
|
||||
colVal.Title().SetCaption("Value")
|
||||
|
||||
for _, key := range allKeys {
|
||||
typeName, err := ld.db.Type(ctx, key).Result()
|
||||
if err != nil {
|
||||
vcl.ShowMessage(fmt.Sprintf("Loading %q/%q: %v", ndata.bucketPath[0], key, err))
|
||||
return
|
||||
return fmt.Errorf("Loading %q/%q: %w", ndata.bucketPath[0], key, err)
|
||||
}
|
||||
|
||||
dataEntry := f.contentBox.Items().Add()
|
||||
dataEntry.SetCaption(key) // formatUtf8
|
||||
dataEntry.SubItems().Add(typeName)
|
||||
rpos := f.contentBox.RowCount()
|
||||
f.contentBox.SetRowCount(rpos + 1)
|
||||
f.contentBox.SetCells(0, rpos, formatUtf8([]byte(key)))
|
||||
f.contentBox.SetCells(1, rpos, typeName)
|
||||
|
||||
switch typeName {
|
||||
case "string":
|
||||
val, err := ld.db.Get(ctx, key).Result()
|
||||
if err != nil {
|
||||
vcl.ShowMessage(fmt.Sprintf("Loading %q/%q: %v", ndata.bucketPath[0], key, err))
|
||||
return
|
||||
return fmt.Errorf("Loading %q/%q: %w", ndata.bucketPath[0], key, err)
|
||||
}
|
||||
dataEntry.SubItems().Add(val)
|
||||
f.contentBox.SetCells(2, rpos, val)
|
||||
|
||||
case "hash":
|
||||
val, err := ld.db.HGetAll(ctx, key).Result()
|
||||
if err != nil {
|
||||
vcl.ShowMessage(fmt.Sprintf("Loading %q/%q: %v", ndata.bucketPath[0], key, err))
|
||||
return
|
||||
return fmt.Errorf("Loading %q/%q: %w", ndata.bucketPath[0], key, err)
|
||||
}
|
||||
// It's a map[string]string
|
||||
dataEntry.SubItems().Add(formatAny(val))
|
||||
f.contentBox.SetCells(2, rpos, formatAny(val))
|
||||
|
||||
case "lists":
|
||||
fallthrough
|
||||
@ -129,17 +116,18 @@ func (ld *redisLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
|
||||
fallthrough
|
||||
|
||||
default:
|
||||
dataEntry.SubItems().Add("<<<other object type>>>")
|
||||
f.contentBox.SetCells(2, rpos, "<<<other object type>>>")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Valid
|
||||
vcl_stringgrid_columnwidths(f.contentBox)
|
||||
f.contentBox.SetEnabled(true)
|
||||
return nil
|
||||
|
||||
} else {
|
||||
vcl.ShowMessage(fmt.Sprintf("Unexpected nav position %q", ndata.bucketPath))
|
||||
return
|
||||
return fmt.Errorf("Unexpected nav position %q", ndata.bucketPath)
|
||||
|
||||
}
|
||||
}
|
||||
@ -170,48 +158,50 @@ func (ld *redisLoadedDatabase) NavContext(ndata *navData) ([]contextAction, erro
|
||||
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()
|
||||
|
||||
// Need to parse the query into separate string+args fields for the protocol
|
||||
fields, err := lexer.Fields(query)
|
||||
if err != nil {
|
||||
vcl.ShowMessage(fmt.Sprintf("Parsing the query: %v", err))
|
||||
return
|
||||
return fmt.Errorf("Parsing the query: %w", err)
|
||||
}
|
||||
|
||||
fields_boxed := box_interface(fields)
|
||||
|
||||
ret, err := ld.db.Do(ctx, fields_boxed...).Result()
|
||||
if err != nil {
|
||||
vcl.ShowMessage(fmt.Sprintf("The redis query returned an error: %v", err))
|
||||
return
|
||||
return fmt.Errorf("The redis query returned an error: %w", err)
|
||||
}
|
||||
|
||||
resultArea.SetEnabled(false)
|
||||
resultArea.Clear()
|
||||
vcl_stringgrid_clear(resultArea)
|
||||
|
||||
resultArea.Columns().Clear()
|
||||
colVal := resultArea.Columns().Add()
|
||||
colVal.SetCaption("Result")
|
||||
colVal.Title().SetCaption("Result")
|
||||
|
||||
// The result is probably a single value or a string slice
|
||||
switch ret := ret.(type) {
|
||||
case []string:
|
||||
// Multiple values
|
||||
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:
|
||||
// Single value
|
||||
dataEntry := resultArea.Items().Add()
|
||||
dataEntry.SetCaption(formatAny(ret)) // formatUtf8
|
||||
rpos := resultArea.RowCount()
|
||||
resultArea.SetRowCount(rpos + 1)
|
||||
resultArea.SetCells(0, rpos, formatAny(ret)) // formatUtf8
|
||||
|
||||
}
|
||||
|
||||
vcl_stringgrid_columnwidths(resultArea)
|
||||
resultArea.SetEnabled(true)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (ld *redisLoadedDatabase) Close() {
|
||||
|
226
db_sqlite.go
@ -1,19 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
_ "yvbolt/sqliteclidriver"
|
||||
|
||||
"github.com/ying32/govcl/vcl"
|
||||
"github.com/ying32/govcl/vcl/types"
|
||||
)
|
||||
|
||||
const (
|
||||
sqliteTablesCaption = "Tables"
|
||||
sqliteFilter = "SQLite database|*.db;*.db3;*.sqlite;*.sqlite3|All files|*.*"
|
||||
)
|
||||
|
||||
type sqliteLoadedDatabase struct {
|
||||
@ -37,19 +40,17 @@ func (ld *sqliteLoadedDatabase) Keepalive(ndata *navData) {
|
||||
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 {
|
||||
// Top-level
|
||||
f.propertiesBox.SetText("Please select...")
|
||||
f.contentBox.SetEnabled(false)
|
||||
f.contentBox.Clear()
|
||||
return nil
|
||||
|
||||
} else if len(ndata.bucketPath) == 1 {
|
||||
// Category (tables, ...)
|
||||
f.propertiesBox.SetText("Please select...")
|
||||
f.contentBox.SetEnabled(false)
|
||||
f.contentBox.Clear()
|
||||
return nil
|
||||
|
||||
} else if len(ndata.bucketPath) == 2 && ndata.bucketPath[0] == sqliteTablesCaption {
|
||||
// Render for specific table
|
||||
@ -66,17 +67,13 @@ func (ld *sqliteLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
|
||||
// Display table properties
|
||||
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
|
||||
// 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
|
||||
// work even when there are 0 results
|
||||
columnNames, err := ld.sqliteGetColumnNamesForTable(tableName)
|
||||
if err != nil {
|
||||
vcl.ShowMessageFmt("Failed to load columns for table %q: %s", tableName, err.Error())
|
||||
return
|
||||
return fmt.Errorf("Failed to load columns for table %q: %w", tableName)
|
||||
}
|
||||
|
||||
populateColumns(columnNames, f.contentBox)
|
||||
@ -85,19 +82,21 @@ func (ld *sqliteLoadedDatabase) RenderForNav(f *TMainForm, ndata *navData) {
|
||||
// TODO
|
||||
|
||||
// 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 {
|
||||
vcl.ShowMessageFmt("Failed to load data for table %q: %s", tableName, err.Error())
|
||||
return
|
||||
return fmt.Errorf("Failed to load data for table %q: %w", tableName, err)
|
||||
}
|
||||
defer datar.Close()
|
||||
populateRows(datar, f.contentBox)
|
||||
|
||||
// We successfully populated the data grid
|
||||
vcl_stringgrid_columnwidths(f.contentBox)
|
||||
f.contentBox.SetEnabled(true)
|
||||
return nil
|
||||
|
||||
} else {
|
||||
// ??? unknown
|
||||
return errors.New("?")
|
||||
|
||||
}
|
||||
|
||||
@ -129,17 +128,14 @@ func (ld *sqliteLoadedDatabase) sqliteGetColumnNamesForTable(tableName string) (
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func populateColumns(names []string, dest *vcl.TListView) {
|
||||
dest.Columns().Clear()
|
||||
func populateColumns(names []string, dest *vcl.TStringGrid) {
|
||||
for _, columnName := range names {
|
||||
col := dest.Columns().Add()
|
||||
col.SetCaption(columnName)
|
||||
col.SetWidth(MY_WIDTH)
|
||||
col.SetAlignment(types.TaLeftJustify)
|
||||
col.Title().SetCaption(columnName)
|
||||
}
|
||||
}
|
||||
|
||||
func populateRows(rr *sql.Rows, dest *vcl.TListView) {
|
||||
func populateRows(rr *sql.Rows, dest *vcl.TStringGrid) {
|
||||
|
||||
numColumns := int(dest.Columns().Count())
|
||||
|
||||
@ -156,10 +152,10 @@ func populateRows(rr *sql.Rows, dest *vcl.TListView) {
|
||||
return
|
||||
}
|
||||
|
||||
dataEntry := dest.Items().Add()
|
||||
dataEntry.SetCaption(formatAny(fields[0]))
|
||||
for i := 1; i < len(fields); i += 1 {
|
||||
dataEntry.SubItems().Add(formatAny(fields[i]))
|
||||
rpos := dest.RowCount()
|
||||
dest.SetRowCount(rpos + 1)
|
||||
for i := 0; i < len(fields); i += 1 {
|
||||
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)
|
||||
if err != nil {
|
||||
vcl.ShowMessage(err.Error())
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
defer rr.Close()
|
||||
|
||||
resultArea.SetEnabled(false)
|
||||
resultArea.Clear()
|
||||
vcl_stringgrid_clear(resultArea)
|
||||
|
||||
columns, err := rr.Columns()
|
||||
if err != nil {
|
||||
vcl.ShowMessage(err.Error())
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
populateColumns(columns, resultArea)
|
||||
|
||||
populateRows(rr, resultArea)
|
||||
|
||||
vcl_stringgrid_columnwidths(resultArea)
|
||||
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) {
|
||||
@ -232,8 +348,52 @@ func (ld *sqliteLoadedDatabase) NavChildren(ndata *navData) ([]string, error) {
|
||||
return nil, fmt.Errorf("unknown nav path %#v", ndata.bucketPath)
|
||||
}
|
||||
|
||||
func (ld *sqliteLoadedDatabase) NavContext(ndata *navData) ([]contextAction, error) {
|
||||
return nil, nil // No special actions are supported
|
||||
func (ld *sqliteLoadedDatabase) NavContext(ndata *navData) (ret []contextAction, err error) {
|
||||
|
||||
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() {
|
||||
|
@ -3,10 +3,16 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"yvbolt/sqliteclidriver"
|
||||
|
||||
sqlite3 "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
func (ld *sqliteLoadedDatabase) DriverName() string {
|
||||
if _, ok := ld.db.Driver().(*sqliteclidriver.SCDriver); ok {
|
||||
return "SQLite (sqliteclidriver)"
|
||||
}
|
||||
|
||||
ver1, _, _ := sqlite3.Version()
|
||||
return "SQLite " + ver1
|
||||
}
|
||||
|
@ -3,9 +3,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"yvbolt/sqliteclidriver"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
func (ld *sqliteLoadedDatabase) DriverName() string {
|
||||
if _, ok := ld.db.Driver().(*sqliteclidriver.SCDriver); ok {
|
||||
return "SQLite (sqliteclidriver)"
|
||||
}
|
||||
|
||||
return "SQLite (modernc.org)"
|
||||
}
|
||||
|
94
debconf/debconf.go
Normal file
@ -0,0 +1,94 @@
|
||||
package debconf
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultConfigDat = `/var/cache/debconf/config.dat`
|
||||
DefaultPasswordsDat = `/var/cache/debconf/passwords.dat`
|
||||
DefaultTemplatesDat = `/var/cache/debconf/templates.dat`
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Name string
|
||||
Properties [][2]string
|
||||
}
|
||||
|
||||
type Database struct {
|
||||
Entries []Entry
|
||||
AllColumnNames []string
|
||||
}
|
||||
|
||||
func Parse(r io.Reader) (*Database, error) {
|
||||
sc := bufio.NewScanner(r)
|
||||
|
||||
var entries []Entry
|
||||
var wip Entry
|
||||
var linenum int = 0
|
||||
|
||||
knownColumnNames := map[string]struct{}{
|
||||
"Name": struct{}{},
|
||||
}
|
||||
var discoveredColumns []string = []string{"Name"}
|
||||
|
||||
for sc.Scan() {
|
||||
linenum++
|
||||
line := sc.Text()
|
||||
|
||||
if line == "" {
|
||||
if wip.Name != "" {
|
||||
entries = append(entries, wip)
|
||||
wip = Entry{}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if line[0] == ' ' {
|
||||
// continuation of last text entry
|
||||
if len(wip.Properties) == 0 {
|
||||
return nil, fmt.Errorf("Continuation of nonexistent entry on line %d", linenum)
|
||||
}
|
||||
|
||||
wip.Properties[len(wip.Properties)-1][1] += line[1:]
|
||||
|
||||
} else {
|
||||
// New pair on current element
|
||||
key, rest, ok := strings.Cut(line, `:`)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("Missing : on line %d", linenum)
|
||||
}
|
||||
|
||||
if _, ok := knownColumnNames[key]; !ok {
|
||||
knownColumnNames[key] = struct{}{}
|
||||
discoveredColumns = append(discoveredColumns, key)
|
||||
}
|
||||
|
||||
rest = strings.TrimLeft(rest, " \t")
|
||||
|
||||
if key == `Name` {
|
||||
wip.Name = rest
|
||||
} else {
|
||||
wip.Properties = append(wip.Properties, [2]string{key, rest})
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if sc.Err() != nil {
|
||||
return nil, sc.Err()
|
||||
}
|
||||
|
||||
if wip.Name != "" {
|
||||
entries = append(entries, wip)
|
||||
}
|
||||
|
||||
return &Database{
|
||||
Entries: entries,
|
||||
AllColumnNames: discoveredColumns,
|
||||
}, nil
|
||||
}
|
30
debconf/debconf_test.go
Normal file
@ -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
@ -5,6 +5,7 @@ go 1.19
|
||||
require (
|
||||
github.com/cockroachdb/pebble v1.0.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/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
|
||||
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/snappy v0.0.4 // 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/klauspost/compress v1.16.0 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
|
4
go.sum
@ -160,8 +160,8 @@ github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hf
|
||||
github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
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.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
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.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
|
||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||
|
23
images.go
@ -10,19 +10,28 @@ import (
|
||||
var assetsFs embed.FS
|
||||
|
||||
const (
|
||||
imgArrowRefresh int32 = iota
|
||||
imgAdd int32 = iota
|
||||
imgArrowRefresh
|
||||
imgChartBar
|
||||
imgDatabase
|
||||
imgDatabaseAdd
|
||||
imgDatabaseDelete
|
||||
imgDatabaseLightning
|
||||
imgDatabaseSave
|
||||
imgDelete
|
||||
imgLightning
|
||||
imgLightningGo
|
||||
imgPencil
|
||||
imgPencilAdd
|
||||
imgPencilDelete
|
||||
imgPencilGo
|
||||
imgResultsetNext
|
||||
imgTable
|
||||
imgTableAdd
|
||||
imgTableDelete
|
||||
imgTableSave
|
||||
imgVendorCockroach
|
||||
imgVendorDebian
|
||||
imgVendorDgraph
|
||||
imgVendorGithub
|
||||
imgVendorMySQL
|
||||
@ -47,6 +56,9 @@ func loadImages(owner vcl.IComponent) *vcl.TImageList {
|
||||
}
|
||||
|
||||
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/chart_bar.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_lightning.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_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_add.png"), nil)
|
||||
ilist.Add(mustLoad("assets/table_delete.png"), nil)
|
||||
ilist.Add(mustLoad("assets/table_save.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_github.png"), nil)
|
||||
ilist.Add(mustLoad("assets/vendor_mysql.png"), nil)
|
||||
ilist.Add(mustLoad("assets/vendor_redis.png"), nil)
|
||||
ilist.Add(mustLoad("assets/vendor_sqlite.png"), nil)
|
||||
|
||||
return ilist
|
||||
|
||||
}
|
||||
|
@ -65,6 +65,15 @@ func Fields(input string) ([]string, error) {
|
||||
} else if c == '\\' {
|
||||
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 {
|
||||
wip += string(c)
|
||||
}
|
||||
|
@ -59,6 +59,24 @@ func TestLexer(t *testing.T) {
|
||||
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
|
||||
|
||||
testCase{
|
||||
@ -99,7 +117,7 @@ func TestLexer(t *testing.T) {
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -10,7 +10,7 @@ var ErrNavNotExist error = errors.New("The selected item no longer exists")
|
||||
|
||||
type contextAction struct {
|
||||
Name string
|
||||
Callback func(ndata *navData)
|
||||
Callback func(sender vcl.IComponent, ndata *navData) error
|
||||
}
|
||||
|
||||
// loadedDatabase is a DB-agnostic interface for each loaded database.
|
||||
@ -18,14 +18,21 @@ type loadedDatabase interface {
|
||||
DisplayName() string
|
||||
DriverName() string
|
||||
RootElement() *vcl.TTreeNode
|
||||
RenderForNav(f *TMainForm, ndata *navData)
|
||||
ExecQuery(query string, resultArea *vcl.TListView)
|
||||
RenderForNav(f *TMainForm, ndata *navData) error
|
||||
NavChildren(ndata *navData) ([]string, error)
|
||||
NavContext(ndata *navData) ([]contextAction, error)
|
||||
Keepalive(ndata *navData)
|
||||
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.
|
||||
type navData struct {
|
||||
ld loadedDatabase
|
||||
|
381
main.go
@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
"unsafe"
|
||||
@ -16,6 +17,11 @@ import (
|
||||
const (
|
||||
APPNAME = "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 {
|
||||
@ -30,9 +36,17 @@ type TMainForm struct {
|
||||
Buckets *vcl.TTreeView
|
||||
Tabs *vcl.TPageControl
|
||||
propertiesBox *vcl.TMemo
|
||||
contentBox *vcl.TListView
|
||||
queryInput *vcl.TMemo
|
||||
queryResult *vcl.TListView
|
||||
contentBox *vcl.TStringGrid
|
||||
dataInsertBtn *vcl.TToolButton
|
||||
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
|
||||
dbs []loadedDatabase
|
||||
@ -51,6 +65,8 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
|
||||
|
||||
f.SetCaption(APPNAME)
|
||||
f.ScreenCenter()
|
||||
f.SetWidth(1280)
|
||||
f.SetHeight(640)
|
||||
f.ImageList.GetIcon(imgDatabaseLightning, f.Icon())
|
||||
|
||||
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.SetCaption("Pebble")
|
||||
mnuFilePebble.SetImageIndex(imgVendorCockroach)
|
||||
@ -126,7 +151,7 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
|
||||
mnuQueryExecute.SetCaption("Execute")
|
||||
mnuQueryExecute.SetShortCutFromString("F5")
|
||||
mnuQueryExecute.SetOnClick(f.OnQueryExecute)
|
||||
mnuQueryExecute.SetImageIndex(imgLightning)
|
||||
mnuQueryExecute.SetImageIndex(imgResultsetNext)
|
||||
mnuQuery.Add(mnuQueryExecute)
|
||||
|
||||
mnuHelp := vcl.NewMenuItem(f)
|
||||
@ -187,8 +212,8 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
|
||||
f.propertiesBox.BorderSpacing().SetAround(MY_SPACING)
|
||||
f.propertiesBox.SetAlign(types.AlClient) // fill remaining space
|
||||
f.propertiesBox.SetReadOnly(true)
|
||||
f.propertiesBox.SetEnabled(true) // Need to leave it enabled so scrolling works
|
||||
f.propertiesBox.SetColor(colors.ClForm) // 0x00f0f0f0
|
||||
f.propertiesBox.SetEnabled(true) // Need to leave it enabled so scrolling works
|
||||
f.propertiesBox.SetColor(vcl_default_tab_background())
|
||||
|
||||
f.propertiesBox.SetBorderStyle(types.BsNone)
|
||||
f.propertiesBox.SetScrollBars(types.SsAutoVertical)
|
||||
@ -198,14 +223,56 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
|
||||
dataTab.SetCaption("Data")
|
||||
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.BorderSpacing().SetAround(MY_SPACING)
|
||||
f.contentBox.SetAlign(types.AlClient) // fill remaining space
|
||||
f.contentBox.SetViewStyle(types.VsReport) // "Report style" i.e. has columns
|
||||
f.contentBox.SetAutoWidthLastColumn(true)
|
||||
f.contentBox.SetReadOnly(true)
|
||||
f.contentBox.Columns().Clear()
|
||||
f.contentBox.BorderSpacing().SetLeft(MY_SPACING)
|
||||
f.contentBox.BorderSpacing().SetRight(MY_SPACING)
|
||||
f.contentBox.BorderSpacing().SetBottom(MY_SPACING)
|
||||
f.contentBox.SetAlign(types.AlClient) // fill remaining space
|
||||
f.contentBox.SetOptions(f.contentBox.Options().Include(types.GoThumbTracking, types.GoColSizing, types.GoDblClickAutoSize, types.GoEditing))
|
||||
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.SetParent(f.Tabs)
|
||||
@ -217,42 +284,50 @@ func (f *TMainForm) OnFormCreate(sender vcl.IObject) {
|
||||
queryButtonBar.SetAlign(types.AlTop)
|
||||
queryButtonBar.BorderSpacing().SetLeft(MY_SPACING)
|
||||
queryButtonBar.BorderSpacing().SetTop(MY_SPACING)
|
||||
queryButtonBar.BorderSpacing().SetBottom(0)
|
||||
//queryButtonBar.BorderSpacing().SetBottom(1)
|
||||
queryButtonBar.BorderSpacing().SetRight(MY_SPACING)
|
||||
queryButtonBar.SetEdgeBorders(0)
|
||||
queryButtonBar.SetImages(f.ImageList)
|
||||
queryButtonBar.SetShowCaptions(true)
|
||||
|
||||
queryExecBtn := vcl.NewToolButton(queryButtonBar)
|
||||
queryExecBtn.SetParent(queryButtonBar)
|
||||
queryExecBtn.SetCaption("Execute")
|
||||
// queryExecBtn.SetImageIndex(imgLightning)
|
||||
queryExecBtn.SetOnClick(f.OnQueryExecute)
|
||||
f.queryExecBtn = vcl.NewToolButton(queryButtonBar)
|
||||
f.queryExecBtn.SetParent(queryButtonBar)
|
||||
f.queryExecBtn.SetHint("Execute")
|
||||
f.queryExecBtn.SetShowHint(true)
|
||||
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.SetHeight(MY_HEIGHT)
|
||||
f.queryInput.SetAlign(types.AlTop)
|
||||
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().SetTop(0)
|
||||
//f.queryInput.BorderSpacing().SetTop(1)
|
||||
f.queryInput.BorderSpacing().SetRight(MY_SPACING)
|
||||
f.queryInput.SetBorderStyle(types.BsFrame)
|
||||
// f.queryInput.SetOnKeyUp(f.OnQueryTextChanged) // Performs extremely slowly
|
||||
|
||||
vsplit := vcl.NewSplitter(queryTab)
|
||||
vsplit.SetParent(queryTab)
|
||||
vsplit.SetAlign(types.AlTop)
|
||||
vsplit.SetTop(2)
|
||||
|
||||
f.queryResult = vcl.NewListView(queryTab)
|
||||
f.queryResult = vcl.NewStringGrid(queryTab)
|
||||
f.queryResult.SetParent(queryTab)
|
||||
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.SetAlign(types.AlClient) // fill remaining space
|
||||
f.queryResult.BorderSpacing().SetLeft(MY_SPACING)
|
||||
f.queryResult.BorderSpacing().SetRight(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.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) {
|
||||
dlg := vcl.NewSaveDialog(f)
|
||||
dlg.SetTitle("Save database as...")
|
||||
dlg.SetFilter("Bolt database|*.db|All files|*.*")
|
||||
dlg.SetFilter(boltFilter)
|
||||
ret := dlg.Execute() // Fake blocking
|
||||
if ret {
|
||||
f.boltAddDatabaseFromFile(dlg.FileName(), false)
|
||||
@ -271,7 +346,7 @@ func (f *TMainForm) OnMnuFileBoltNewClick(sender vcl.IObject) {
|
||||
func (f *TMainForm) OnMnuFileBoltOpenClick(sender vcl.IObject) {
|
||||
dlg := vcl.NewOpenDialog(f)
|
||||
dlg.SetTitle("Select a database file...")
|
||||
dlg.SetFilter("Bolt database|*.db|All files|*.*")
|
||||
dlg.SetFilter(boltFilter)
|
||||
ret := dlg.Execute() // Fake blocking
|
||||
if ret {
|
||||
f.boltAddDatabaseFromFile(dlg.FileName(), false)
|
||||
@ -281,7 +356,7 @@ func (f *TMainForm) OnMnuFileBoltOpenClick(sender vcl.IObject) {
|
||||
func (f *TMainForm) OnMnuFileBoltOpenReadonlyClick(sender vcl.IObject) {
|
||||
dlg := vcl.NewOpenDialog(f)
|
||||
dlg.SetTitle("Select a database file...")
|
||||
dlg.SetFilter("Bolt database|*.db|All files|*.*")
|
||||
dlg.SetFilter(boltFilter)
|
||||
ret := dlg.Execute() // Fake blocking
|
||||
if ret {
|
||||
f.boltAddDatabaseFromFile(dlg.FileName(), true)
|
||||
@ -293,7 +368,7 @@ func (f *TMainForm) OnMnuFileSqliteOpenClick(sender vcl.IObject) {
|
||||
|
||||
dlg := vcl.NewOpenDialog(f)
|
||||
dlg.SetTitle("Select a database file...")
|
||||
dlg.SetFilter("SQLite database|*.db;*.db3;*.sqlite;*.sqlite3|All files|*.*")
|
||||
dlg.SetFilter(sqliteFilter)
|
||||
ret := dlg.Execute() // Fake blocking
|
||||
if ret {
|
||||
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) {
|
||||
f.pebbleAddDatabaseFromMemory()
|
||||
}
|
||||
@ -368,7 +456,7 @@ func (f *TMainForm) OnMenuHelpVersion(sender vcl.IObject) {
|
||||
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 {
|
||||
|
||||
// Filter to only interesting things
|
||||
@ -423,7 +511,10 @@ func (f *TMainForm) OnNavContextPopup(sender vcl.IObject, mousePos types.TPoint,
|
||||
mnuAction.SetCaption(action.Name)
|
||||
cb := action.Callback // Copy to avoid reuse of loop variable
|
||||
mnuAction.SetOnClick(func(sender vcl.IObject) {
|
||||
cb(ndata)
|
||||
err = cb(f, ndata)
|
||||
if err != nil {
|
||||
vcl.ShowMessage(err.Error())
|
||||
}
|
||||
f.OnNavContextRefresh(curItem, ndata)
|
||||
})
|
||||
mnu.Items().Add(mnuAction)
|
||||
@ -447,6 +538,18 @@ func (f *TMainForm) OnNavContextPopup(sender vcl.IObject, mousePos types.TPoint,
|
||||
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) {
|
||||
isExpanded := item.Expanded()
|
||||
|
||||
@ -463,6 +566,131 @@ func (f *TMainForm) OnNavContextRefresh(item *vcl.TTreeNode, ndata *navData) {
|
||||
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) {
|
||||
curItem := f.Buckets.Selected()
|
||||
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{}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// If query tab is not selected, switch to it, but do not exec
|
||||
if f.Tabs.ActivePageIndex() != 2 {
|
||||
@ -492,6 +760,10 @@ func (f *TMainForm) OnQueryExecute(sender vcl.IObject) {
|
||||
queryString = f.queryInput.SelText() // Just the selected text
|
||||
}
|
||||
|
||||
if strings.TrimSpace(queryString) == "" {
|
||||
return // prevent blank query
|
||||
}
|
||||
|
||||
// Execute
|
||||
node := f.Buckets.Selected()
|
||||
if node == nil {
|
||||
@ -500,7 +772,18 @@ func (f *TMainForm) OnQueryExecute(sender vcl.IObject) {
|
||||
}
|
||||
|
||||
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) {
|
||||
@ -513,7 +796,33 @@ func (f *TMainForm) OnNavChange(sender vcl.IObject, node *vcl.TTreeNode) {
|
||||
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
|
||||
f.StatusBar.SetSimpleText(ld.DisplayName() + " | " + ld.DriverName())
|
||||
|
@ -26,9 +26,9 @@ type TRedisConnectionDialog struct {
|
||||
func (f *TRedisConnectionDialog) OnFormCreate(sender vcl.IObject) {
|
||||
|
||||
f.SetCaption("Connect to Redis...")
|
||||
f.ScreenCenter()
|
||||
f.SetWidth(320)
|
||||
f.SetHeight(160)
|
||||
f.SetPosition(types.PoOwnerFormCenter)
|
||||
|
||||
// row 1
|
||||
|
||||
|
115
sqliteclidriver/eventcmd.go
Normal file
@ -0,0 +1,115 @@
|
||||
package sqliteclidriver
|
||||
|
||||
import (
|
||||
"io"
|
||||
"os/exec"
|
||||
"sync"
|
||||
)
|
||||
|
||||
const (
|
||||
evtypeStdout int = iota
|
||||
evtypeStderr
|
||||
evtypeExit
|
||||
)
|
||||
|
||||
type processEvent struct {
|
||||
evtype int
|
||||
data []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (pe processEvent) Error() string {
|
||||
if pe.err != nil {
|
||||
return pe.err.Error()
|
||||
}
|
||||
|
||||
if pe.evtype == evtypeStderr {
|
||||
return string(pe.data)
|
||||
}
|
||||
|
||||
return "<no error>"
|
||||
}
|
||||
|
||||
func (pe processEvent) Unwrap() error {
|
||||
return pe.err
|
||||
}
|
||||
|
||||
//
|
||||
|
||||
func ExecEvents(cmd *exec.Cmd) (<-chan processEvent, io.WriteCloser, error) {
|
||||
|
||||
pw, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
pr, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
pe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
err = cmd.Start()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
chEvents := make(chan processEvent, 0)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
processEventWorker(pr, evtypeStdout, chEvents)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
processEventWorker(pe, evtypeStderr, chEvents)
|
||||
}()
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
// Only call cmd.Wait() after pipes are closed
|
||||
wg.Wait()
|
||||
|
||||
err = cmd.Wait()
|
||||
chEvents <- processEvent{
|
||||
evtype: evtypeExit,
|
||||
err: err,
|
||||
}
|
||||
|
||||
close(chEvents)
|
||||
}()
|
||||
|
||||
return chEvents, pw, nil
|
||||
}
|
||||
|
||||
func processEventWorker(p io.Reader, evtype int, dest chan<- processEvent) {
|
||||
for {
|
||||
buf := make([]byte, 1024)
|
||||
n, err := p.Read(buf)
|
||||
|
||||
if n > 0 {
|
||||
dest <- processEvent{
|
||||
evtype: evtype,
|
||||
data: buf[0:n],
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
dest <- processEvent{
|
||||
evtype: evtype,
|
||||
err: err,
|
||||
}
|
||||
|
||||
// Assume all errors are permanent
|
||||
// Ordering can produce either io.EOF, ErrClosedPipe, or PathError{"file already closed"}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
69
sqliteclidriver/eventcmd_test.go
Normal file
@ -0,0 +1,69 @@
|
||||
package sqliteclidriver
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEventCmd(t *testing.T) {
|
||||
cmd := exec.Command("/bin/bash", "-c", `echo "hello world"`)
|
||||
|
||||
ch, _, err := ExecEvents(cmd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var consume []processEvent
|
||||
for ev := range ch {
|
||||
consume = append(consume, ev)
|
||||
}
|
||||
|
||||
expect := []processEvent{
|
||||
processEvent{evtype: evtypeStdout, data: []byte("hello world\n")},
|
||||
processEvent{evtype: evtypeStdout, err: io.EOF},
|
||||
processEvent{evtype: evtypeStderr, err: io.EOF},
|
||||
processEvent{evtype: evtypeExit, err: nil},
|
||||
}
|
||||
|
||||
require.EqualValues(t, expect, consume)
|
||||
}
|
||||
|
||||
func TestEventCmdStdin(t *testing.T) {
|
||||
cmd := exec.Command("/usr/bin/tr", "a-z", "A-Z")
|
||||
|
||||
ch, pw, err := ExecEvents(cmd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wg := sync.WaitGroup{}
|
||||
wg.Add(1)
|
||||
var consume []processEvent
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
|
||||
for ev := range ch {
|
||||
if ev.err != nil && errors.Is(ev.err, io.EOF) {
|
||||
continue // skip flakey ordering of two EOF statements
|
||||
}
|
||||
consume = append(consume, ev)
|
||||
}
|
||||
}()
|
||||
|
||||
pw.Write([]byte("hello world"))
|
||||
pw.Close()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
expect := []processEvent{
|
||||
processEvent{evtype: evtypeStdout, data: []byte("HELLO WORLD")},
|
||||
processEvent{evtype: evtypeExit, err: nil},
|
||||
}
|
||||
|
||||
require.EqualValues(t, expect, consume)
|
||||
}
|
85
sqliteclidriver/orderedkv.go
Normal file
@ -0,0 +1,85 @@
|
||||
package sqliteclidriver
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Pair struct {
|
||||
Key string
|
||||
Value any
|
||||
}
|
||||
|
||||
type OrderedKV []Pair
|
||||
|
||||
func (o *OrderedKV) UnmarshalJSON(data []byte) error {
|
||||
// Rough estimate malloc size based on number of `:`
|
||||
// This is a lower bound since there might be nested elements
|
||||
var elCount int64
|
||||
for _, c := range data {
|
||||
if c == ':' {
|
||||
elCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
*o = make([]Pair, 0, elCount)
|
||||
|
||||
// Parse the initial opening { delimiter from the JSON stream
|
||||
|
||||
reader := bytes.NewReader(data)
|
||||
dec := json.NewDecoder(reader)
|
||||
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("expected '{': %w", err)
|
||||
}
|
||||
if d, ok := tok.(json.Delim); !ok || (rune(d) != '{') {
|
||||
return fmt.Errorf("expected '{', got %v", tok)
|
||||
}
|
||||
|
||||
// Read remaining content
|
||||
|
||||
for dec.More() {
|
||||
|
||||
var p Pair
|
||||
|
||||
// Parse key: either string or Delim('}')
|
||||
tok, err := dec.Token()
|
||||
if err != nil {
|
||||
return fmt.Errorf("expected '{': %w", err)
|
||||
}
|
||||
switch tok := tok.(type) {
|
||||
case json.Delim:
|
||||
if rune(tok) == '}' {
|
||||
// Finished
|
||||
break
|
||||
}
|
||||
// Something else
|
||||
return fmt.Errorf("expected string or }, got %v", tok)
|
||||
|
||||
case string:
|
||||
// Valid key
|
||||
p.Key = tok
|
||||
|
||||
default:
|
||||
return fmt.Errorf("expected string or }, got %v", tok)
|
||||
}
|
||||
|
||||
// Parse value (any)
|
||||
err = dec.Decode(&p.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
*o = append(*o, p)
|
||||
}
|
||||
|
||||
// Assert that there is no remaining content
|
||||
if reader.Len() != 0 {
|
||||
return fmt.Errorf("Unexpected trailing data (%d bytes remaining)", reader.Len())
|
||||
}
|
||||
|
||||
// Done
|
||||
return nil
|
||||
}
|
30
sqliteclidriver/orderedkv_test.go
Normal file
@ -0,0 +1,30 @@
|
||||
package sqliteclidriver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOrderedKV(t *testing.T) {
|
||||
|
||||
input := `
|
||||
{
|
||||
"zzz": "foo",
|
||||
"aaa": "bar"
|
||||
}
|
||||
`
|
||||
var got OrderedKV
|
||||
err := json.Unmarshal([]byte(input), &got)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
expect := OrderedKV{
|
||||
Pair{Key: "zzz", Value: "foo"},
|
||||
Pair{Key: "aaa", Value: "bar"},
|
||||
}
|
||||
|
||||
require.EqualValues(t, expect, got)
|
||||
}
|
@ -5,7 +5,6 @@
|
||||
// Functionality is limited.
|
||||
//
|
||||
// Known caveats:
|
||||
// - Lexer only understands ? if it's separated by spaces
|
||||
// - Bad error handling
|
||||
// - Few supported types
|
||||
// - Has to escape parameters for CLI instead of preparing them, so not safe for untrusted usage
|
||||
@ -23,7 +22,10 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
|
||||
"yvbolt/lexer"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
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 `--`
|
||||
|
||||
pw, err := cmd.StdinPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pr, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pe, err := cmd.StderrPipe()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = cmd.Start()
|
||||
chEvents, pw, err := ExecEvents(cmd)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &SCConn{
|
||||
stdout: pr,
|
||||
stderr: pe,
|
||||
listen: chEvents,
|
||||
w: pw,
|
||||
}, nil
|
||||
}
|
||||
@ -90,8 +76,7 @@ var _ driver.Connector = &SCConnector{} // interface assertion
|
||||
//
|
||||
|
||||
type SCConn struct {
|
||||
stdout io.Reader
|
||||
stderr io.Reader
|
||||
listen <-chan processEvent
|
||||
w io.WriteCloser
|
||||
}
|
||||
|
||||
@ -105,6 +90,10 @@ func (c *SCConn) Prepare(query string) (driver.Stmt, error) {
|
||||
return nil, errors.New("Empty query")
|
||||
}
|
||||
|
||||
if f[len(f)-1] != ";" {
|
||||
f = append(f, ";") // Query must end in semicolon
|
||||
}
|
||||
|
||||
return &SCStmt{
|
||||
conn: c,
|
||||
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) {
|
||||
ctx := context.Background()
|
||||
|
||||
submit, err := s.buildQuery(args)
|
||||
if err != nil {
|
||||
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)))
|
||||
if err != nil {
|
||||
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
|
||||
ret := []map[string]any{}
|
||||
err = json.NewDecoder(s.conn.stdout).Decode(&ret)
|
||||
// If something happens on stderr, or to the process, pr will read an error
|
||||
ret := []OrderedKV{}
|
||||
decoder := json.NewDecoder(pr)
|
||||
err = decoder.Decode(&ret)
|
||||
if err != nil {
|
||||
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
|
||||
// TODO
|
||||
|
||||
@ -243,8 +310,8 @@ func (s *SCStmt) Query(args []driver.Value) (driver.Rows, error) {
|
||||
|
||||
var columnNames []string
|
||||
if len(ret) > 0 {
|
||||
for k, _ := range ret[0] {
|
||||
columnNames = append(columnNames, k)
|
||||
for _, cell := range ret[0] {
|
||||
columnNames = append(columnNames, cell.Key)
|
||||
}
|
||||
}
|
||||
|
||||
@ -278,7 +345,7 @@ var _ driver.Result = &SCResult{} // interface assertion
|
||||
type SCRows struct {
|
||||
idx int
|
||||
columns []string
|
||||
data []map[string]any
|
||||
data []OrderedKV
|
||||
}
|
||||
|
||||
func (r *SCRows) Columns() []string {
|
||||
@ -301,12 +368,9 @@ func (r *SCRows) Next(dest []driver.Value) error {
|
||||
}
|
||||
|
||||
for i := 0; i < len(dest); i++ {
|
||||
cell, ok := r.data[r.idx][r.columns[i]]
|
||||
if !ok {
|
||||
return fmt.Errorf("Result row %d is missing column #%d %q, unexpected", r.idx, i, r.columns[i])
|
||||
}
|
||||
cell := r.data[r.idx][i]
|
||||
|
||||
dest[i] = cell
|
||||
dest[i] = cell.Value
|
||||
}
|
||||
|
||||
r.idx++
|
||||
|
@ -55,3 +55,24 @@ func TestSqliteCliDriver(t *testing.T) {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func TestSqliteCliDriverNoResults(t *testing.T) {
|
||||
db, err := sql.Open("sqliteclidriver", ":memory:")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Repeat this part to ensure we can make followup queries on the same connection
|
||||
for i := 0; i < 3; i++ {
|
||||
_, err = db.Query(`SELECT 1 AS expect_no_result WHERE 1=2`)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Mix of results and no-results
|
||||
rr := db.QueryRow(`SELECT 1 AS expect_result WHERE 1=1`)
|
||||
require.NoError(t, rr.Err())
|
||||
|
||||
var result int64
|
||||
err = rr.Scan(&result)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, result, 1)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -8,4 +8,14 @@ func box_interface[T any](input []T) []interface{} {
|
||||
ret = append(ret, v)
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func int32slice_contains(haystack []int32, needle int32) bool {
|
||||
for _, v := range haystack {
|
||||
if v == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
72
util_vcl.go
@ -1,14 +1,19 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
|
||||
"github.com/ying32/govcl/vcl"
|
||||
"github.com/ying32/govcl/vcl/types"
|
||||
"github.com/ying32/govcl/vcl/types/colors"
|
||||
)
|
||||
|
||||
const (
|
||||
MY_SPACING = 6
|
||||
MY_HEIGHT = 90
|
||||
MY_WIDTH = 180
|
||||
|
||||
MAX_AUTO_COL_WIDTH = 240
|
||||
)
|
||||
|
||||
// vcl_row makes a TPanel row inside the target component.
|
||||
@ -49,3 +54,70 @@ func vcl_menuseparator(parent *vcl.TMenuItem) *vcl.TMenuItem {
|
||||
|
||||
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
|
||||
}
|
||||
|