summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStefan Majewsky <majewsky@gmx.net>2026-08-26 21:49:42 +0200
committerStefan Majewsky <majewsky@gmx.net>2026-08-26 21:49:42 +0200
commit50c91a360ac3f42682d998a826087c4bb41d0af4 (patch)
treed96f112c4fe4d0eaac1b018ff568ab7343068e3b
parentd2118d8e07f9486d8880eee59d5ff83cd8c13c0c (diff)
downloadgo-oblast-50c91a360ac3f42682d998a826087c4bb41d0af4.tar.gz
add ReadOnly plan option
-rw-r--r--CHANGELOG.md6
-rw-r--r--oblast.go9
-rw-r--r--plan.go16
-rw-r--r--plan_test.go34
-rw-r--r--query.go17
-rw-r--r--query_test.go21
6 files changed, 92 insertions, 11 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 50fa3f1..d2b3838 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,12 @@ SPDX-FileCopyrightText: 2026 Stefan Majewsky <majewsky@gmx.net>
SPDX-License-Identifier: Apache-2.0
-->
+# v0.14.0 (TBD)
+
+API changes:
+
+- Add plan option `ReadOnly`.
+
# v0.13.2 (2026-07-31)
No changes to the previous version. After v0.13.1 also failed, I noticed that `git clone` on the primary repo fails with `Cannot obtain needed object 96727093c88e5db251c55eda31700bf67c832ce1 while processing commit 238b5820a9968cb4c775fd9cf2e7e2cfeb24c78e.`, the former being the digest of the `v0.1.0` tag object. No idea why, but a `git repack -adf` on the primary repo (i.e. the bare repo on the server) fixed that. However, the Go module proxy seems to once again have cached the fetch error, so here we go again with yet another release. Fingers crossed that this one will be picked up.
diff --git a/oblast.go b/oblast.go
index 588471b..837742d 100644
--- a/oblast.go
+++ b/oblast.go
@@ -144,6 +144,15 @@ func StructTagKeyIs(key string) PlanOption {
return func(opts *planOpts) { opts.StructTagKey = key }
}
+// ReadOnly is a PlanOption that disables all write operations for the resulting [Store] type
+// (i.e., [Store.Insert], [Store.Update], [Store.Upsert] and [Store.Delete]).
+// Besides read-only tables (i.e. tables where the current user lacks write permissions),
+// this is useful for record types that only model a few columns of a table and which,
+// when used in write operations, might result in incomplete records.
+func ReadOnly() PlanOption {
+ return func(opts *planOpts) { opts.ReadOnly = true }
+}
+
// Store holds information on how to read and write data into record type R,
// and can also be used to execute autogenerated queries if the respective [PlanOption] values were provided during [NewStore].
type Store[R any] struct {
diff --git a/plan.go b/plan.go
index 010569d..ed0f381 100644
--- a/plan.go
+++ b/plan.go
@@ -15,6 +15,7 @@ import (
// planOpts holds additional arguments to buildPlan().
type planOpts struct {
+ ReadOnly bool
StructTagKey string // defaults to "db"
TableName string
PrimaryKeyColumnNames []string
@@ -100,6 +101,10 @@ type plan struct {
Upsert plannedQuery
Update plannedQuery
Delete plannedQuery
+
+ // Whether Insert/Upsert/Update/Delete query planning was inhibited by the ReadOnly option.
+ // This information is preserved in order to render more useful error messages.
+ ReadOnly bool
}
// fieldInfo appears in type plan.
@@ -130,6 +135,7 @@ func buildPlan(t reflect.Type, dialect Dialect, opts planOpts) (plan, error) {
TableName: opts.TableName,
PrimaryKeyColumnNames: opts.PrimaryKeyColumnNames,
IndexByColumnName: make(map[string][]int),
+ ReadOnly: opts.ReadOnly,
}
var (
@@ -272,10 +278,12 @@ func buildPlan(t reflect.Type, dialect Dialect, opts planOpts) (plan, error) {
// prepare query strings
p.Select = p.buildSelectQueryIfPossible(dialect)
- p.Insert = p.buildInsertQueryIfPossible(dialect, false)
- p.Upsert = p.buildInsertQueryIfPossible(dialect, true)
- p.Update = p.buildUpdateQueryIfPossible(dialect)
- p.Delete = p.buildDeleteQueryIfPossible(dialect)
+ if !opts.ReadOnly {
+ p.Insert = p.buildInsertQueryIfPossible(dialect, false)
+ p.Upsert = p.buildInsertQueryIfPossible(dialect, true)
+ p.Update = p.buildUpdateQueryIfPossible(dialect)
+ p.Delete = p.buildDeleteQueryIfPossible(dialect)
+ }
return p, nil
}
diff --git a/plan_test.go b/plan_test.go
index 4338865..3247928 100644
--- a/plan_test.go
+++ b/plan_test.go
@@ -100,6 +100,7 @@ func TestQueryConstructionBasic(t *testing.T) {
}
t.Run("MariaDBDialect", func(t *testing.T) {
+ opts.ReadOnly = false
p, err := buildPlan(reflect.TypeFor[record](), MariaDBDialect(), opts)
if err != nil {
t.Error(err)
@@ -123,9 +124,20 @@ func TestQueryConstructionBasic(t *testing.T) {
ArgumentIndexes: [][]int{{0}},
},
})
+
+ opts.ReadOnly = true
+ p2, err := buildPlan(reflect.TypeFor[record](), MariaDBDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p2), plan{
+ ReadOnly: true,
+ Select: p.Select,
+ })
})
t.Run("PostgresDialect", func(t *testing.T) {
+ opts.ReadOnly = false
p, err := buildPlan(reflect.TypeFor[record](), PostgresDialect(), opts)
if err != nil {
t.Error(err)
@@ -150,9 +162,21 @@ func TestQueryConstructionBasic(t *testing.T) {
ArgumentIndexes: [][]int{{0}},
},
})
+
+ opts.ReadOnly = true
+ p2, err := buildPlan(reflect.TypeFor[record](), PostgresDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p2), plan{
+ InsertUsesQueryRow: true,
+ ReadOnly: true,
+ Select: p.Select,
+ })
})
t.Run("SqliteDialect", func(t *testing.T) {
+ opts.ReadOnly = false
p, err := buildPlan(reflect.TypeFor[record](), SqliteDialect(), opts)
if err != nil {
t.Error(err)
@@ -176,6 +200,16 @@ func TestQueryConstructionBasic(t *testing.T) {
ArgumentIndexes: [][]int{{0}},
},
})
+
+ opts.ReadOnly = true
+ p2, err := buildPlan(reflect.TypeFor[record](), SqliteDialect(), opts)
+ if err != nil {
+ t.Error(err)
+ }
+ assert.Equal(t, onlyQueryPlans(p2), plan{
+ ReadOnly: true,
+ Select: p.Select,
+ })
})
}
diff --git a/query.go b/query.go
index e9d6862..6e375e4 100644
--- a/query.go
+++ b/query.go
@@ -28,8 +28,11 @@ import (
var PrepareThreshold int = 8
// prepare behaves like [Handle.Prepare].
-func prepare(ctx context.Context, db gsql.Handle, query, operation string, inputSize int) (gsql.Statement, error) {
+func prepare(ctx context.Context, db gsql.Handle, readOnly bool, query, operation string, inputSize int) (gsql.Statement, error) {
if query == "" {
+ if readOnly {
+ return nil, fmt.Errorf("cannot execute %s() because query planning used the ReadOnly() option", operation)
+ }
return nil, fmt.Errorf("cannot execute %s() because query could not be autogenerated", operation)
}
@@ -51,7 +54,7 @@ func (s Store[R]) Insert(ctx context.Context, db gsql.Handle, records ...*R) err
// NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
// Any expression that does not depend on type R should be factored out into a reusable function.
- stmt, err := prepare(ctx, db, s.plan.Insert.Query, "Insert", len(records))
+ stmt, err := prepare(ctx, db, s.plan.ReadOnly, s.plan.Insert.Query, "Insert", len(records))
if err != nil {
return err
}
@@ -159,7 +162,7 @@ func (s Store[R]) Update(ctx context.Context, db gsql.Handle, records ...R) erro
// NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
// Any expression that does not depend on type R should be factored out into a reusable function.
- stmt, err := prepare(ctx, db, s.plan.Update.Query, "Update", len(records))
+ stmt, err := prepare(ctx, db, s.plan.ReadOnly, s.plan.Update.Query, "Update", len(records))
if err != nil {
return err
}
@@ -208,7 +211,7 @@ func (s Store[R]) Delete(ctx context.Context, db gsql.Handle, records ...R) erro
// NOTE: This function body should be as short as possible to reduce the binary size after monomorphization.
// Any expression that does not depend on type R should be factored out into a reusable function.
- stmt, err := prepare(ctx, db, s.plan.Delete.Query, "Delete", len(records))
+ stmt, err := prepare(ctx, db, s.plan.ReadOnly, s.plan.Delete.Query, "Delete", len(records))
if err != nil {
return err
}
@@ -257,7 +260,7 @@ func (s Store[R]) Upsert(ctx context.Context, db gsql.Handle, records ...*R) err
// Any expression that does not depend on type R should be factored out into a reusable function.
if len(s.plan.AutoColumnNames) == 0 {
- stmt, err := prepare(ctx, db, s.plan.Upsert.Query, "Upsert", len(records))
+ stmt, err := prepare(ctx, db, s.plan.ReadOnly, s.plan.Upsert.Query, "Upsert", len(records))
if err != nil {
return err
}
@@ -265,11 +268,11 @@ func (s Store[R]) Upsert(ctx context.Context, db gsql.Handle, records ...*R) err
}
// TODO: respect PrepareThreshold (or not? may be too much bookkeeping overhead for not a whole lot of benefit)
- insertStmt, err := prepare(ctx, db, s.plan.Insert.Query, "Insert", 0)
+ insertStmt, err := prepare(ctx, db, s.plan.ReadOnly, s.plan.Insert.Query, "Insert", 0)
if err != nil {
return err
}
- updateStmt, err := prepare(ctx, db, s.plan.Update.Query, "Update", 0)
+ updateStmt, err := prepare(ctx, db, s.plan.ReadOnly, s.plan.Update.Query, "Update", 0)
if err != nil {
return errext.WithCleanup(err, "InsertStmt.Close", insertStmt.Close())
}
diff --git a/query_test.go b/query_test.go
index 4dd3caf..0a363cf 100644
--- a/query_test.go
+++ b/query_test.go
@@ -242,6 +242,27 @@ func TestWriteQueriesNotPossible(t *testing.T) {
err = store.Delete(ctx, db, r)
assert.ErrEqual(t, err, "cannot execute Delete() because query could not be autogenerated")
+
+ store = oblast.MustNewStore[basicRecord](
+ oblast.SqliteDialect(),
+ oblast.TableNameIs("records"),
+ oblast.PrimaryKeyIs("id"),
+ oblast.ReadOnly(),
+ )
+
+ r = basicRecord{Name: "foo"}
+ err = store.Insert(ctx, db, &r)
+ assert.ErrEqual(t, err, "cannot execute Insert() because query planning used the ReadOnly() option")
+
+ err = store.Upsert(ctx, db, &r)
+ assert.ErrEqual(t, err, "cannot execute Insert() because query planning used the ReadOnly() option")
+
+ r.ID = 42
+ err = store.Update(ctx, db, r)
+ assert.ErrEqual(t, err, "cannot execute Update() because query planning used the ReadOnly() option")
+
+ err = store.Delete(ctx, db, r)
+ assert.ErrEqual(t, err, "cannot execute Delete() because query planning used the ReadOnly() option")
}
func TestWriteQueriesFailDuringPrepare(t *testing.T) {