From 01e028f7a1092d5c8deb8e0a2034778d9054f4d9 Mon Sep 17 00:00:00 2001 From: Andy Walker Date: Thu, 28 Mar 2024 14:01:28 -0400 Subject: [PATCH 1/5] feat: SingleRow - Add sqlitex.SingleRow - Add sqlitex.SingleRowFS --- sqlitex/exec.go | 44 ++++++++++++++++++++++++++++++ sqlitex/exec_test.go | 64 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+) diff --git a/sqlitex/exec.go b/sqlitex/exec.go index 91da538..ddf6748 100644 --- a/sqlitex/exec.go +++ b/sqlitex/exec.go @@ -128,6 +128,50 @@ func Execute(conn *sqlite.Conn, query string, opts *ExecOptions) error { return err } +// SingleRow is [Exec], but it returns an error if there is not exactly one result returned. +func SingleRow(conn *sqlite.Conn, query string, opts *ExecOptions) error { + oOpts, gotResult := oneResult(opts) + err := Execute(conn, query, oOpts) + if err != nil { + return err + } + if !gotResult() { + return errNoResults + } + return nil +} + +// SingleRowFS is [ExecuteFS], but but it returns an error if there is not exactly one result returned. +func SingleRowFS(conn *sqlite.Conn, fsys fs.FS, filename string, opts *ExecOptions) error { + oOpts, gotResult := oneResult(opts) + err := ExecuteFS(conn, fsys, filename, oOpts) + if err != nil { + return err + } + if !gotResult() { + return errNoResults + } + return nil +} + +func oneResult(opts *ExecOptions) (*ExecOptions, func() bool) { + if opts == nil { + opts = &ExecOptions{} + } + if opts.ResultFunc == nil { + opts.ResultFunc = func(*sqlite.Stmt) error { return nil } + } + called := false + opts.ResultFunc = func(stmt *sqlite.Stmt) error { + if called { + return errMultipleResults + } + called = true + return opts.ResultFunc(stmt) + } + return opts, func() bool { return called } +} + // ExecFS is an alias for [ExecuteFS]. // // Deprecated: Call [ExecuteFS] directly. diff --git a/sqlitex/exec_test.go b/sqlitex/exec_test.go index 97741ce..4f74c82 100644 --- a/sqlitex/exec_test.go +++ b/sqlitex/exec_test.go @@ -18,6 +18,7 @@ package sqlitex import ( + "errors" "fmt" "reflect" "testing" @@ -296,6 +297,69 @@ INSERT INTO t (a, b) VALUES ('a2', :a2); }) } +func TestSingleRow(t *testing.T) { + conn, err := sqlite.OpenConn(":memory:", 0) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + script := ` +CREATE TABLE t (a TEXT, b INTEGER); +INSERT INTO t (a, b) VALUES ('a1', 1); +INSERT INTO t (a, b) VALUES ('a2', 1); +` + err = ExecuteScript(conn, script, &ExecOptions{}) + if err != nil { + t.Fatal(err) + } + + aVal := "" + err = SingleRow(conn, `SELECT a FROM t WHERE b==?`, &ExecOptions{ + Args: []any{0}, + ResultFunc: func(stmt *sqlite.Stmt) error { + aVal = stmt.ColumnText(0) + return nil + }, + }) + if !errors.Is(err, errNoResults) { + t.Errorf("err=%v, want errNoResults", err) + } + if aVal != "" { + t.Errorf(`aVal=%q, want ""- ResultFunc should not have run`, aVal) + } + + aVal = "" + err = SingleRow(conn, `SELECT a FROM t WHERE b==?`, &ExecOptions{ + Args: []any{1}, + ResultFunc: func(stmt *sqlite.Stmt) error { + aVal = stmt.ColumnText(0) + return nil + }, + }) + if !errors.Is(err, errMultipleResults) { + t.Errorf("err=%v, want errMultipleresults", err) + } + if aVal != "a1" { + t.Errorf(`aVal=%q, want "a1"- ResultFunc should have run once`, aVal) + } + + bVal := 0 + err = SingleRow(conn, `SELECT b FROM t WHERE a==?`, &ExecOptions{ + Args: []any{"a1"}, + ResultFunc: func(stmt *sqlite.Stmt) error { + bVal = stmt.ColumnInt(0) + return nil + }, + }) + if err != nil { + t.Errorf("err=%v, want nil", err) + } + if bVal != 1 { + t.Errorf(`bVal=%d, want "1"- ResultFunc should have run`, bVal) + } +} + func TestBitsetHasAll(t *testing.T) { tests := []struct { bs bitset From 263b0e928e9fae4a87731ce707c1503529c552e5 Mon Sep 17 00:00:00 2001 From: Andy Walker Date: Thu, 28 Mar 2024 14:04:19 -0400 Subject: [PATCH 2/5] doc: CHANGELOG entry --- CHANGELOG.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c323080..0409307 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [Unreleased]: https://github.com/zombiezen/go-sqlite/compare/v1.1.2...main +Version 1.2.0 introduces `sqlitex.SingleRow` and `sqlitex.SingleRowFS` to +support the common use case of queries that are expected to return only +one result row. + +[1.2.0]: https://github.com/zombiezen/go-sqlite/releases/tag/v1.2.0 + +### Changed + +- Add `sqlitex.SingleRow` and `sqlite.SingleRowFS` + (follow-on from [#85](https://github.com/zombiezen/go-sqlite/issues/85)). + ## [1.1.2][] - 2024-02-14 Version 1.1.2 updates the `modernc.org/sqlite` version to 1.29.1 From 14354092cef898f808666e8e9d6fbe7d5e7e292f Mon Sep 17 00:00:00 2001 From: Andy Walker Date: Thu, 28 Mar 2024 14:08:45 -0400 Subject: [PATCH 3/5] chore: fix doc ref and relocate funcs --- sqlitex/exec.go | 88 ++++++++++++++++++++++++------------------------- 1 file changed, 44 insertions(+), 44 deletions(-) diff --git a/sqlitex/exec.go b/sqlitex/exec.go index ddf6748..b4c595b 100644 --- a/sqlitex/exec.go +++ b/sqlitex/exec.go @@ -128,50 +128,6 @@ func Execute(conn *sqlite.Conn, query string, opts *ExecOptions) error { return err } -// SingleRow is [Exec], but it returns an error if there is not exactly one result returned. -func SingleRow(conn *sqlite.Conn, query string, opts *ExecOptions) error { - oOpts, gotResult := oneResult(opts) - err := Execute(conn, query, oOpts) - if err != nil { - return err - } - if !gotResult() { - return errNoResults - } - return nil -} - -// SingleRowFS is [ExecuteFS], but but it returns an error if there is not exactly one result returned. -func SingleRowFS(conn *sqlite.Conn, fsys fs.FS, filename string, opts *ExecOptions) error { - oOpts, gotResult := oneResult(opts) - err := ExecuteFS(conn, fsys, filename, oOpts) - if err != nil { - return err - } - if !gotResult() { - return errNoResults - } - return nil -} - -func oneResult(opts *ExecOptions) (*ExecOptions, func() bool) { - if opts == nil { - opts = &ExecOptions{} - } - if opts.ResultFunc == nil { - opts.ResultFunc = func(*sqlite.Stmt) error { return nil } - } - called := false - opts.ResultFunc = func(stmt *sqlite.Stmt) error { - if called { - return errMultipleResults - } - called = true - return opts.ResultFunc(stmt) - } - return opts, func() bool { return called } -} - // ExecFS is an alias for [ExecuteFS]. // // Deprecated: Call [ExecuteFS] directly. @@ -287,6 +243,50 @@ func ExecuteTransientFS(conn *sqlite.Conn, fsys fs.FS, filename string, opts *Ex return nil } +// SingleRow is [Execute], but it returns an error if there is not exactly one result returned. +func SingleRow(conn *sqlite.Conn, query string, opts *ExecOptions) error { + oOpts, gotResult := oneResult(opts) + err := Execute(conn, query, oOpts) + if err != nil { + return err + } + if !gotResult() { + return errNoResults + } + return nil +} + +// SingleRowFS is [ExecuteFS], but but it returns an error if there is not exactly one result returned. +func SingleRowFS(conn *sqlite.Conn, fsys fs.FS, filename string, opts *ExecOptions) error { + oOpts, gotResult := oneResult(opts) + err := ExecuteFS(conn, fsys, filename, oOpts) + if err != nil { + return err + } + if !gotResult() { + return errNoResults + } + return nil +} + +func oneResult(opts *ExecOptions) (*ExecOptions, func() bool) { + if opts == nil { + opts = &ExecOptions{} + } + if opts.ResultFunc == nil { + opts.ResultFunc = func(*sqlite.Stmt) error { return nil } + } + called := false + opts.ResultFunc = func(stmt *sqlite.Stmt) error { + if called { + return errMultipleResults + } + called = true + return opts.ResultFunc(stmt) + } + return opts, func() bool { return called } +} + // PrepareTransientFS prepares an SQL statement from a file // that is not cached by the Conn. // Subsequent calls with the same query will create new Stmts. From 1d705b3687081e7ffa481ab1bfc8558318a96998 Mon Sep 17 00:00:00 2001 From: Andy Walker Date: Thu, 28 Mar 2024 14:46:29 -0400 Subject: [PATCH 4/5] fix: refactor tests as in repo and fix fn ref bug introduced in refac --- sqlitex/exec.go | 3 +- sqlitex/exec_test.go | 85 ++++++++++++++++++++++++-------------------- 2 files changed, 48 insertions(+), 40 deletions(-) diff --git a/sqlitex/exec.go b/sqlitex/exec.go index b4c595b..a328bd5 100644 --- a/sqlitex/exec.go +++ b/sqlitex/exec.go @@ -277,12 +277,13 @@ func oneResult(opts *ExecOptions) (*ExecOptions, func() bool) { opts.ResultFunc = func(*sqlite.Stmt) error { return nil } } called := false + rf := opts.ResultFunc opts.ResultFunc = func(stmt *sqlite.Stmt) error { if called { return errMultipleResults } called = true - return opts.ResultFunc(stmt) + return rf(stmt) } return opts, func() bool { return called } } diff --git a/sqlitex/exec_test.go b/sqlitex/exec_test.go index 4f74c82..0b8f8ba 100644 --- a/sqlitex/exec_test.go +++ b/sqlitex/exec_test.go @@ -314,50 +314,57 @@ INSERT INTO t (a, b) VALUES ('a2', 1); t.Fatal(err) } - aVal := "" - err = SingleRow(conn, `SELECT a FROM t WHERE b==?`, &ExecOptions{ - Args: []any{0}, - ResultFunc: func(stmt *sqlite.Stmt) error { - aVal = stmt.ColumnText(0) - return nil - }, + t.Run("NoResults", func(t *testing.T) { + aVal := "" + got := SingleRow(conn, `SELECT a FROM t WHERE b==?`, &ExecOptions{ + Args: []any{0}, + ResultFunc: func(stmt *sqlite.Stmt) error { + aVal = stmt.ColumnText(0) + return nil + }, + }) + if !errors.Is(got, errNoResults) { + t.Errorf("err = %v; want %v", got, errNoResults) + } + if aVal != "" { + t.Errorf(`aVal = %q; want ""`, aVal) + } }) - if !errors.Is(err, errNoResults) { - t.Errorf("err=%v, want errNoResults", err) - } - if aVal != "" { - t.Errorf(`aVal=%q, want ""- ResultFunc should not have run`, aVal) - } - aVal = "" - err = SingleRow(conn, `SELECT a FROM t WHERE b==?`, &ExecOptions{ - Args: []any{1}, - ResultFunc: func(stmt *sqlite.Stmt) error { - aVal = stmt.ColumnText(0) - return nil - }, + t.Run("MultipleResults", func(t *testing.T) { + aVal := "" + got := SingleRow(conn, `SELECT a FROM t WHERE b==?`, &ExecOptions{ + Args: []any{1}, + ResultFunc: func(stmt *sqlite.Stmt) error { + t.Logf("setting aval to %s", stmt.ColumnText(0)) + aVal = stmt.ColumnText(0) + return nil + }, + }) + if !errors.Is(got, errMultipleResults) { + t.Errorf("err = %v; want %v", got, errMultipleResults) + } + if aVal != "a1" { + t.Errorf(`aVal = %q; want "a1"`, aVal) + } }) - if !errors.Is(err, errMultipleResults) { - t.Errorf("err=%v, want errMultipleresults", err) - } - if aVal != "a1" { - t.Errorf(`aVal=%q, want "a1"- ResultFunc should have run once`, aVal) - } - bVal := 0 - err = SingleRow(conn, `SELECT b FROM t WHERE a==?`, &ExecOptions{ - Args: []any{"a1"}, - ResultFunc: func(stmt *sqlite.Stmt) error { - bVal = stmt.ColumnInt(0) - return nil - }, + t.Run("SingleResult", func(t *testing.T) { + bVal := 0 + got := SingleRow(conn, `SELECT b FROM t WHERE a==?`, &ExecOptions{ + Args: []any{"a1"}, + ResultFunc: func(stmt *sqlite.Stmt) error { + bVal = stmt.ColumnInt(0) + return nil + }, + }) + if got != nil { + t.Errorf("err = %v; want nil", got) + } + if bVal != 1 { + t.Errorf(`bVal = %d; want 1`, bVal) + } }) - if err != nil { - t.Errorf("err=%v, want nil", err) - } - if bVal != 1 { - t.Errorf(`bVal=%d, want "1"- ResultFunc should have run`, bVal) - } } func TestBitsetHasAll(t *testing.T) { From 2626b151529755465dbb7808ccb292e7cf43ca51 Mon Sep 17 00:00:00 2001 From: Andy Walker Date: Fri, 29 Mar 2024 11:32:01 -0400 Subject: [PATCH 5/5] doc: better naming, document onceWrap --- sqlitex/exec.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/sqlitex/exec.go b/sqlitex/exec.go index a328bd5..7e32659 100644 --- a/sqlitex/exec.go +++ b/sqlitex/exec.go @@ -245,12 +245,12 @@ func ExecuteTransientFS(conn *sqlite.Conn, fsys fs.FS, filename string, opts *Ex // SingleRow is [Execute], but it returns an error if there is not exactly one result returned. func SingleRow(conn *sqlite.Conn, query string, opts *ExecOptions) error { - oOpts, gotResult := oneResult(opts) - err := Execute(conn, query, oOpts) + opts, ranOnce := onceWrap(opts) + err := Execute(conn, query, opts) if err != nil { return err } - if !gotResult() { + if !ranOnce() { return errNoResults } return nil @@ -258,18 +258,24 @@ func SingleRow(conn *sqlite.Conn, query string, opts *ExecOptions) error { // SingleRowFS is [ExecuteFS], but but it returns an error if there is not exactly one result returned. func SingleRowFS(conn *sqlite.Conn, fsys fs.FS, filename string, opts *ExecOptions) error { - oOpts, gotResult := oneResult(opts) - err := ExecuteFS(conn, fsys, filename, oOpts) + opts, ranOnce := onceWrap(opts) + err := ExecuteFS(conn, fsys, filename, opts) if err != nil { return err } - if !gotResult() { + if !ranOnce() { return errNoResults } return nil } -func oneResult(opts *ExecOptions) (*ExecOptions, func() bool) { +// onceWrap wraps the ResultFunc of an [*ExecOptions] in a closure that returns +// errMultipleResults if it is run more than once. +// If no ResultFunc is set, a no-op handler is used. +// It returns the modified options along with a closure that can be called to +// check if ResultFunc was run, allowing the caller to return errNoResults if it +// was never called. +func onceWrap(opts *ExecOptions) (*ExecOptions, func() bool) { if opts == nil { opts = &ExecOptions{} }