diff --git a/v3/pkg/services/sqlite/sqlite.go b/v3/pkg/services/sqlite/sqlite.go index c8cde7b7f..2b8d5f908 100644 --- a/v3/pkg/services/sqlite/sqlite.go +++ b/v3/pkg/services/sqlite/sqlite.go @@ -1,39 +1,83 @@ +//wails:include stmt.js package sqlite import ( + "bytes" "context" "database/sql" - "errors" + "fmt" + "sync" + "sync/atomic" + "github.com/pkg/errors" "github.com/wailsapp/wails/v3/pkg/application" _ "modernc.org/sqlite" ) -// ---------------- Service Setup ---------------- -// This is the main Service struct. It can be named anything you like. - type Config struct { - DBFile string + // DBSource is the database URI to use. + // The string ":memory:" can be used to create an in-memory database. + // The sqlite driver can be configured through query parameters. + // For more details see https://pkg.go.dev/modernc.org/sqlite#Driver.Open + DBSource string } +//wails:inject export { +//wails:inject ExecContext as Execute, +//wails:inject QueryContext as Query +//wails:inject }; +//wails:inject +//wails:inject import { Stmt } from "./stmt.js"; +//wails:inject +//wails:inject **:/** +//wails:inject **: * Prepare creates a prepared statement for later queries or executions. +//wails:inject **: * Multiple queries or executions may be run concurrently from the returned statement. +//wails:inject **: * +//wails:inject **: * The caller must call the statement's Close method when it is no longer needed. +//wails:inject **: * Statements are closed automatically +//wails:inject **: * when the connection they are associated with is closed. +//wails:inject **: * +//wails:inject **: * Prepare supports early cancellation. +//wails:inject j*: * +//wails:inject j*: * @param {string} query +//wails:inject j*: * @returns {Promise & { cancel(): void }} +//wails:inject **: */ +//wails:inject j*:export function Prepare(query) { +//wails:inject t*:export function Prepare(query: string): Promise & { cancel(): void } { +//wails:inject **: const promise = PrepareContext(query); +//wails:inject j*: const wrapper = /** @type {any} */(promise.then(function (id) { +//wails:inject t*: const wrapper: any = (promise.then(function (id) { +//wails:inject **: return id == null ? null : new Stmt( +//wails:inject **: ClosePrepared.bind(null, id), +//wails:inject **: ExecPrepared.bind(null, id), +//wails:inject **: QueryPrepared.bind(null, id)); +//wails:inject **: })); +//wails:inject **: wrapper.cancel = promise.cancel; +//wails:inject **: return wrapper; +//wails:inject **:} type Service struct { + lock sync.RWMutex config *Config conn *sql.DB + stmts map[uint64]struct{} } -func New(config *Config) *Service { - return &Service{ - config: config, - } +// New initialises a sqlite service with the default configuration. +func New() *Service { + return NewWithConfig(nil) } -// ServiceShutdown is called when the app is shutting down -// You can use this to clean up any resources you have allocated -func (s *Service) ServiceShutdown() error { - if s.conn != nil { - return s.conn.Close() - } - return nil +// NewWithConfig initialises a sqlite service with a custom configuration. +// If config is nil, it falls back to the default configuration, i.e. an in-memory database. +// +// The database connection is not opened right away. +// A call to [Service.Open] must succeed before using all other methods. +// If the service is registered with the application, +// [Service.Open] will be called automatically at startup. +func NewWithConfig(config *Config) *Service { + result := &Service{} + result.Configure(config) + return result } // ServiceName returns the name of the plugin. @@ -42,58 +86,325 @@ func (s *Service) ServiceName() string { return "github.com/wailsapp/wails/v3/plugins/sqlite" } -// ServiceStartup is called when the app is starting up. You can use this to -// initialise any resources you need. +// ServiceStartup opens the database connection. +// It returns a non-nil error in case of failures. func (s *Service) ServiceStartup(ctx context.Context, options application.ServiceOptions) error { - if s.config.DBFile == "" { - return errors.New(`no database file specified. Please set DBFile in the config to either a filename or use ":memory:" to use an in-memory database`) + return s.Open() +} + +// ServiceShutdown closes the database connection. +// It returns a non-nil error in case of failures. +func (s *Service) ServiceShutdown() error { + return s.Close() +} + +// Configure changes the database service configuration. +// The connection state at call time is preserved. +// Consumers will need to call [Service.Open] manually after Configure +// in order to reconnect with the new configuration. +// +// See [NewWithConfig] for details on configuration. +// +//wails:ignore +func (s *Service) Configure(config *Config) { + if config == nil { + config = &Config{DBSource: ":memory:"} + } else { + // Clone to prevent changes from the outside. + clone := new(Config) + *clone = *config + config = clone } - db, err := s.Open(s.config.DBFile) - if err != nil { + + s.lock.Lock() + defer s.lock.Unlock() + + s.config = config +} + +// Open validates the current configuration, +// closes the current connection if one is present, +// then opens and validates a new connection. +// +// Even when a non-nil error is returned, +// the database service is left in a consistent state, +// ready for a new call to Open. +func (s *Service) Open() error { + s.lock.Lock() + defer s.lock.Unlock() + + if s.config.DBSource == "" { + return errors.New(`no database source specified; please set DBSource in the config to a filename or specify ":memory:" to use an in-memory database`) + } + + if err := s.closeImpl(); err != nil { return err } - _ = db + + conn, err := sql.Open("sqlite", s.config.DBSource) + if err != nil { + return errors.Wrap(err, "error opening database connection") + } + + // Test connection + if err := conn.Ping(); err != nil { + _ = conn.Close() + return errors.Wrap(err, "error opening database connection") + } + + s.conn = conn + s.stmts = make(map[uint64]struct{}) return nil } -func (s *Service) Open(dbPath string) (string, error) { - var err error - s.conn, err = sql.Open("sqlite", dbPath) - if err != nil { - return "", err - } - return "Database connection opened", nil +// Close closes the current database connection if one is open, otherwise has no effect. +// Additionally, Close closes all open prepared statements associated to the connection. +// +// Even when a non-nil error is returned, +// the database service is left in a consistent state, +// ready for a call to [Service.Open]. +func (s *Service) Close() error { + s.lock.Lock() + defer s.lock.Unlock() + + return s.closeImpl() } -func (s *Service) Execute(query string, args ...any) error { +// closeImpl performs the close operation without acquiring the lock first. +// It is the caller's responsibility +// to ensure the lock is held exclusively (in write mode) +// for the entire duration of the call. +func (s *Service) closeImpl() error { if s.conn == nil { + return nil + } + + for id := range s.stmts { + if stmt, ok := stmts.Load(id); ok { + // WARN: do not delegate to [Stmt.Close], it would cause a deadlock. + // Ignore errors, closing the connection should free up all resources. + _ = stmt.(*Stmt).sqlStmt.Close() + } + } + + err := s.conn.Close() + + // Clear the connection even in case of errors: + // if [sql.DB.Close] returns an error, + // the connection becomes unusable. + s.conn = nil + s.stmts = nil + + return err +} + +// Execute executes a query without returning any rows. +// +//wails:ignore +func (s *Service) Execute(query string, args ...any) error { + return s.ExecContext(context.Background(), query, args...) +} + +// ExecContext executes a query without returning any rows. +// It supports early cancellation. +// +//wails:internal +func (s *Service) ExecContext(ctx context.Context, query string, args ...any) error { + s.lock.RLock() + conn := s.conn + s.lock.RUnlock() + + if conn == nil { return errors.New("no open database connection") } - _, err := s.conn.Exec(query, args...) - if err != nil { + _, err := conn.ExecContext(ctx, query, args...) + if err != nil && !errors.Is(err, context.Canceled) { return err } + return nil } -func (s *Service) Select(query string, args ...any) ([]map[string]any, error) { - if s.conn == nil { +// Query executes a query and returns a slice of key-value records, +// one per row, with column names as keys. +// +//wails:ignore +func (s *Service) Query(query string, args ...any) (Rows, error) { + return s.QueryContext(context.Background(), query, args...) +} + +// QueryContext executes a query and returns a slice of key-value records, +// one per row, with column names as keys. +// It supports early cancellation, returning the slice of results fetched so far. +// +//wails:internal +func (s *Service) QueryContext(ctx context.Context, query string, args ...any) (Rows, error) { + s.lock.RLock() + conn := s.conn + s.lock.RUnlock() + + if conn == nil { return nil, errors.New("no open database connection") } - rows, err := s.conn.Query(query, args...) + rows, err := conn.QueryContext(ctx, query, args...) if err != nil { - return nil, err + if errors.Is(err, context.Canceled) { + return Rows{}, nil + } else { + return nil, err + } } + + return parseRows(ctx, rows) +} + +// Prepare creates a prepared statement for later queries or executions. +// Multiple queries or executions may be run concurrently from the returned statement. +// +// The caller should call the statement's Close method when it is no longer needed. +// Statements are closed automatically +// when the connection they are associated with is closed. +// +//wails:ignore +func (s *Service) Prepare(query string) (*Stmt, error) { + return s.PrepareContext(context.Background(), query) +} + +// PrepareContext creates a prepared statement for later queries or executions. +// Multiple queries or executions may be run concurrently from the returned statement. +// +// The caller must call the statement's Close method when it is no longer needed. +// Statements are closed automatically +// when the connection they are associated with is closed. +// +// PrepareContext supports early cancellation. +// +//wails:internal +func (s *Service) PrepareContext(ctx context.Context, query string) (*Stmt, error) { + s.lock.RLock() + conn := s.conn + s.lock.RUnlock() + + if conn == nil { + return nil, errors.New("no open database connection") + } + + id := nextId.Load() + for id != 0 && !nextId.CompareAndSwap(id, id+1) { + } + if id == 0 { + return nil, errors.New("prepared statement ids exhausted") + } + + stmt, err := conn.PrepareContext(ctx, query) + if err != nil { + if errors.Is(err, context.Canceled) { + return nil, nil + } else { + return nil, err + } + } + + func() { + s.lock.Lock() + defer s.lock.Unlock() + + s.stmts[id] = struct{}{} + }() + + wrapper := &Stmt{ + sqlStmt: stmt, + db: s, + id: id, + } + stmts.Store(id, wrapper) + + return wrapper, nil +} + +// ClosePrepared closes a prepared statement +// obtained with [Service.Prepare] or [Service.PrepareContext]. +// ClosePrepared is idempotent: +// it has no effect on prepared statements that are already closed. +// +//wails:internal +func (s *Service) ClosePrepared(stmt *Stmt) error { + return stmt.Close() +} + +// ExecPrepared executes a prepared statement +// obtained with [Service.Prepare] or [Service.PrepareContext] +// without returning any rows. +// It supports early cancellation. +// +//wails:internal +func (s *Service) ExecPrepared(ctx context.Context, stmt *Stmt, args ...any) error { + if stmt == nil { + return errors.New("no prepared statement provided") + } else if stmt.sqlStmt == nil { + return errors.New("prepared statement is not valid") + } + + _, err := stmt.ExecContext(ctx, args...) + if err != nil && !errors.Is(err, context.Canceled) { + return err + } + + return nil +} + +// QueryPrepared executes a prepared statement +// obtained with [Service.Prepare] or [Service.PrepareContext] +// and returns a slice of key-value records, one per row, with column names as keys. +// It supports early cancellation, returning the slice of results fetched so far. +// +//wails:internal +func (s *Service) QueryPrepared(ctx context.Context, stmt *Stmt, args ...any) (Rows, error) { + if stmt == nil { + return nil, errors.New("no prepared statement provided") + } else if stmt.sqlStmt == nil { + return nil, errors.New("prepared statement is not valid") + } + + rows, err := stmt.sqlStmt.QueryContext(ctx, args...) + if err != nil { + if errors.Is(err, context.Canceled) { + return Rows{}, nil + } else { + return nil, err + } + } + + return parseRows(ctx, rows) +} + +type ( + // Row holds a single row in the result of a query. + // It is a key-value map where keys are column names. + Row = map[string]any + + // Rows holds the result of a query + // as an array of key-value maps where keys are column names. + Rows = []Row +) + +func parseRows(ctx context.Context, rows *sql.Rows) (Rows, error) { defer rows.Close() - columns, err := rows.Columns() - var results []map[string]any + columns, _ := rows.Columns() + values := make([]any, len(columns)) + pointers := make([]any, len(columns)) + results := []map[string]any{} + for rows.Next() { - values := make([]any, len(columns)) - pointers := make([]any, len(columns)) + select { + default: + case <-ctx.Done(): + return results, nil + } for i := range values { pointers[i] = &values[i] @@ -107,21 +418,83 @@ func (s *Service) Select(query string, args ...any) ([]map[string]any, error) { for i, column := range columns { row[column] = values[i] } + results = append(results, row) } return results, nil } -func (s *Service) Close() (string, error) { - if s.conn == nil { - return "", errors.New("no open database connection") +var ( + // stmts holds all currently active prepared statements, + // for all [Service] instances. + stmts sync.Map + + // nextId holds the next available prepared statement id. + // We use a counter to make sure IDs are never reused. + nextId atomic.Uint64 +) + +func init() { + nextId.Store(1) +} + +type ( + sqlStmt = *sql.Stmt + + // Stmt wraps a prepared sql statement pointer. + // It provides the same methods as the [sql.Stmt] type. + // + //wails:internal + Stmt struct { + sqlStmt + db *Service + id uint64 + } +) + +// Close closes the statement. +// It has no effect when the statement is already closed. +func (s *Stmt) Close() error { + if s == nil || s.sqlStmt == nil { + return nil } - err := s.conn.Close() - if err != nil { - return "", err - } - s.conn = nil - return "Database connection closed", nil + err := s.sqlStmt.Close() + stmts.Delete(s.id) + + func() { + s.db.lock.Lock() + defer s.db.lock.Unlock() + + delete(s.db.stmts, s.id) + }() + + return errors.Wrap(err, "error closing prepared statement") +} + +func (s *Stmt) MarshalText() ([]byte, error) { + var buf bytes.Buffer + buf.Grow(16) + + if _, err := fmt.Fprintf(&buf, "%016x", s.id); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func (s *Stmt) UnmarshalText(text []byte) error { + if n, err := fmt.Fscanf(bytes.NewReader(text), "%x", &s.id); n < 1 || err != nil { + return errors.New("invalid prepared statement id") + } + + if stmt, ok := stmts.Load(s.id); ok { + *s = *(stmt.(*Stmt)) + } else { + s.sqlStmt = nil + s.db = nil + } + + return nil } diff --git a/v3/pkg/services/sqlite/stmt.js b/v3/pkg/services/sqlite/stmt.js new file mode 100644 index 000000000..948b0c3dd --- /dev/null +++ b/v3/pkg/services/sqlite/stmt.js @@ -0,0 +1,79 @@ +//@ts-check + +//@ts-ignore: Unused imports +import * as $models from "./models.js"; + +const execSymbol = Symbol("exec"), + querySymbol = Symbol("query"), + closeSymbol = Symbol("close"); + +/** + * Stmt represents a prepared statement for later queries or executions. + * Multiple queries or executions may be run concurrently on the same statement. + * + * The caller must call the statement's Close method when it is no longer needed. + * Statements are closed automatically + * when the connection they are associated with is closed. + */ +export class Stmt { + /** + * Constructs a new prepared statement instance. + * @param {(...args: any[]) => Promise} close + * @param {(...args: any[]) => Promise & { cancel(): void }} exec + * @param {(...args: any[]) => Promise<$models.Rows> & { cancel(): void }} query + */ + constructor(close, exec, query) { + /** + * @member + * @private + * @type {typeof close} + */ + this[closeSymbol] = close; + + /** + * @member + * @private + * @type {typeof exec} + */ + this[execSymbol] = exec; + + /** + * @member + * @private + * @type {typeof query} + */ + this[querySymbol] = query; + } + + /** + * Closes the prepared statement. + * It has no effect when the statement is already closed. + * @returns {Promise} + */ + Close() { + return this[closeSymbol](); + } + + /** + * Executes the prepared statement without returning any rows. + * It supports early cancellation. + * + * @param {any[]} args + * @returns {Promise & { cancel(): void }} + */ + Exec(...args) { + return this[execSymbol](...args); + } + + /** + * Executes the prepared statement + * and returns a slice of key-value records, one per row, with column names as keys. + * It supports early cancellation, returning the array of results fetched so far. + * + * @param {any[]} args + * @returns {Promise<$models.Rows> & { cancel(): void }} + */ + Query(...args) { + return this[querySymbol](...args); + } +}