Skip to content
mpywPublic

About

Go linter that reports a database handle used from inside a transaction it bypasses

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

txbypass

CI Codecov Go Reference

Go linter that reports a database handle used inside a transaction it opened.

Overview

package app

import (
	"context"

	"gorm.io/gorm"
)

func (r *OrderRepo) Place(
	ctx context.Context,
	o *Order,
) error {
	return r.db.WithContext(ctx).Transaction(
		func(tx *gorm.DB) error {
			if err := tx.Create(o).Error; err != nil {
				return err
			}
			log := &AuditLog{OrderID: o.ID}
			return r.db.Create(log).Error
		},
	)
}
$ txbypass ./...
order.go:19:11: r.db bypasses the transaction opened with Transaction; use tx instead [outer-handle]

The callback receives the transaction as tx. r.db is the pool the transaction came from, so the audit log is written outside the transaction.

CodeWhat goes wrong
r.db.Transaction(func(tx *gorm.DB) error {
	r.db.Create(&AuditLog{}) // kept
	return errFailed // rolls back tx only
})
The write survives a rollback. It was never part of the transaction
r.db.Transaction(func(tx *gorm.DB) error {
	// locks the row
	tx.Model(o).Update("status", "paid")
	// waits for that lock, on another
	// connection, until tx ends
	return r.db.Model(o).
		Update("note", "paid").Error
})
The write can wait forever. It runs on another connection, and needs a lock the transaction holds. With a pool of one connection, it waits for the connection itself
r.db.Transaction(func(tx *gorm.DB) error {
	tx.Create(o)
	var n int64
	// does not count o
	r.db.Model(&Order{}).Count(&n)
	return nil
})
The read cannot see the transaction's rows. They are not committed yet

The code compiles. A test where nothing fails passes too. txbypass finds it from the source.

Every report of a rule ends with the rule's name. That name is what //txbypass:ignore takes. A report about a directive itself names no rule, and no ignore silences it.

Install

Method Command Needs
mise (recommended) mise use "github:mpyw/txbypass@0.2.0" Nothing. Installs the prebuilt binary
go tool go get -tool github.com/mpyw/txbypass/cmd/txbypass@latest Go 1.27+
go install go install github.com/mpyw/txbypass/cmd/txbypass@latest Go 1.27+
Release archive See below Nothing
txbypass ./...
txbypass -V=full   # the release this binary was built from
Pin a version, run through go vet or golangci-lint, or install from an archive

mise use pins the version in the project's mise.toml, so every checkout and CI run the same one. Add -g to install it for every project on your machine instead.

[tools]
"github:mpyw/txbypass" = "0.2.0"

As a tool dependency in go.mod:

go get -tool github.com/mpyw/txbypass/cmd/txbypass@latest
go tool txbypass ./...

Through go vet, which runs it with the same package loading as the rest of your vet checks, and caches it:

go vet -vettool=$(which txbypass) ./...

Without installing anything:

go run github.com/mpyw/txbypass/cmd/txbypass@latest ./...

From a release archive, verified against the published checksums:

VERSION=0.2.0
REPO=https://github.com/mpyw/txbypass
BASE="${REPO}/releases/download/v${VERSION}"
curl -LO "${BASE}/txbypass_${VERSION}_darwin_arm64.tar.gz"
curl -LO "${BASE}/checksums.txt"
shasum -a 256 -c checksums.txt --ignore-missing
tar xzf "txbypass_${VERSION}_darwin_arm64.tar.gz"

Inside golangci-lint, as a module plugin. txbypass is not bundled with golangci-lint, so build a binary that holds it. Write .custom-gcl.yml:

version: v2.13.1  # the golangci-lint release to build
plugins:
  - module: github.com/mpyw/txbypass
    import: github.com/mpyw/txbypass/plugin
    version: v0.2.0

Turn it on in .golangci.yml. Each key under settings is a rule's name, as the flags take it, or invokes, begins or closes. A rule left out keeps its default. An unknown name stops the run:

version: "2"
linters:
  enable:
    - txbypass
  settings:
    custom:
      txbypass:
        type: module
        description: Transaction bypass linter
        settings:
          outer-handle: true
          # One function per item, not comma-separated.
          invokes:
            - (example.com/db.Manager).InTx

Then build and run it:

golangci-lint custom  # writes ./custom-gcl
./custom-gcl run ./...

Rules

Rule Default Reports
outer-handle 🟢 On The handle a transaction was opened on, used while the transaction is open
→ Use the transaction
outer-context 🟢 On The outer context, used inside a callback whose context carries the transaction
→ Use the callback's context

Each rule has a flag of its name:

# every rule except outer-context
txbypass -outer-context=false ./...

Nothing is reported in a generated file. Test files are checked like any other file.

Supported libraries

An invoker opens a transaction and invokes a callback inside it. A begin returns the transaction, which stays open until it is committed or rolled back.

Library Invoker Begin ... Commit
database/sql DB.Begin, DB.BeginTx
sqlx DB.Beginx, DB.BeginTxx, DB.MustBegin, DB.MustBeginTx
pgx v5 BeginFunc, BeginTxFunc Tx.Begin, pgxpool.Pool.Begin, pgxpool.Pool.BeginTx
pgx v4 Tx.BeginFunc, pgxpool.Pool.BeginFunc, pgxpool.Pool.BeginTxFunc Tx.Begin, pgxpool.Pool.Begin, pgxpool.Pool.BeginTx
gorm DB.Transaction DB.Begin
bun DB.RunInTx, Tx.RunInTx, IDB.RunInTx DB.Begin, DB.BeginTx, Tx.Begin, Tx.BeginTx, IDB.BeginTx
bob DB.RunInTx DB.Begin, DB.BeginTx
ent Client.Tx, Client.BeginTx, generated into your package
goqu Database.WithTx Database.Begin, Database.BeginTx
go-pg DB.RunInTransaction DB.Begin, DB.BeginContext
pop Connection.Transaction, Connection.Rollback Connection.NewTransaction, Connection.NewTransactionContext, Connection.NewTransactionContextOptions
upper/db Session.Tx, Session.TxContext
xorm v1, v2 Engine.Transaction
go-redis v9 Client.Watch, Client.TxPipelined, UniversalClient.Watch, Cmdable.TxPipelined
go-redis v8 Client.Watch, Client.TxPipelined, UniversalClient.Watch, Cmdable.TxPipelined
Firestore Client.RunTransaction
Spanner Client.ReadWriteTransaction, Client.ReadWriteTransactionWithOptions
Datastore Client.RunInTransaction Client.NewTransaction
MongoDB v2 Session.WithTransaction, WithSession, Client.UseSession, Client.UseSessionWithOptions, read by outer-context
MongoDB v1 Session.WithTransaction, WithSession, Client.UseSession, Client.UseSessionWithOptions, read by outer-context
go-rel Repository.Transaction, read by outer-context
go-transaction-manager v2 trm.Manager.Do, trm.Manager.DoWithSettings, manager.Manager.Do, manager.Manager.DoWithSettings, read by outer-context

Query builders that take the handle as an argument, such as jet and squirrel, need nothing of their own. Passing the outer handle to them is a use. sqlc is covered through WithTx.

The exact receivers and signatures are in design/rules.md.

Note

A transaction begun on one connection, rather than a pool, is not read. *sql.Conn, *pgx.Conn, *pgxpool.Conn, bun.Conn, bob.Conn and go-pg's *pg.Conn are such connections. A statement on the connection runs inside the transaction, so it bypasses nothing. A savepoint, a transaction opened on a transaction, is not read either, for the same reason. The Tx of upper/db is no savepoint on a transaction: it begins another transaction on the pool.

Helpers of your own

A function of your project that opens a transaction and runs a callback in it is read as an invoker too. No configuration is needed, and a helper in another package works the same way.

func (s *Store) withTx(
	ctx context.Context,
	fn func(*sql.Tx) error,
) error {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return err
	}
	if err := fn(tx); err != nil {
		return errors.Join(err, tx.Rollback())
	}
	return tx.Commit()
}

func (s *Store) Place(ctx context.Context) error {
	return s.withTx(ctx, func(tx *sql.Tx) error {
		// s.db bypasses the transaction opened with
		// withTx; use tx instead
		_, err := s.db.ExecContext(ctx, audit)
		return err
	})
}

The helper may open the transaction with a begin, as above, or with the callback of an invoker or of another helper.

A helper that returns the transaction it begins is read as a begin:

func (s *Store) begin(
	ctx context.Context,
) (*sql.Tx, error) {
	return s.db.BeginTx(ctx, nil)
}

tx, err := s.begin(ctx)
if err != nil {
	return err
}
defer tx.Rollback()
// s.db bypasses the transaction opened with begin;
// use tx instead
s.db.ExecContext(ctx, audit)
Helpers that are not read, so a bypass in a callback given to one is not reported
HelperWhy it is not read
func (s *Store) reuse(
	ctx context.Context,
	cur *sql.Tx,
	fn func(*sql.Tx) error,
) error {
	if cur != nil {
		return fn(cur)
	}
	tx, err := s.db.BeginTx(ctx, nil)
	// ...
}
It may call the callback outside the transaction it opens. Every use inside would be reported on that path
func (s *Store) withTx(
	ctx context.Context,
	fn func(*sql.Tx) error,
) error {
	s.hooks = append(s.hooks, fn)
	// ...
	go fn(tx)
	// ...
}
It stores the callback, passes it on, or runs it with go or defer. The callback may run outside the transaction
var db *sql.DB

func withTx(
	ctx context.Context,
	fn func(*sql.Tx) error,
) error {
	tx, err := db.BeginTx(ctx, nil)
	// ...
}
It opens on a package-level variable, or on a handle a call returns. Only a parameter, or a field of one, can name the handle at the call
func (s *Store) withTx(
	ctx context.Context,
	fn func(context.Context) error,
) error {
	tx, err := s.db.BeginTx(ctx, nil)
	// ...
	return fn(ctxWithTx(ctx, tx))
}
It carries the transaction in a context. Its body is not read for that yet. Declare it
func (s *Store) begin(
	ctx context.Context,
) (*Tx, error) {
	tx, err := s.db.BeginTx(ctx, nil)
	return &Tx{tx}, err
}
It returns the transaction wrapped in a struct, or one it was given. The struct is not followed, and a transaction it was given is not new
func (s *Store) begin(
	ctx context.Context,
) (*sql.Tx, error) {
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback()
	return tx, nil
}
It defers a call given the transaction it returns. The call may end the transaction before the caller uses it
func (s *Store) begin(
	ctx context.Context,
) (tx *sql.Tx, err error) {
	defer annotate(&err)
	return s.db.BeginTx(ctx, nil)
}
It gives the address of its error to a call. The call may return an open transaction beside an error
func (s *Store) withTx(
	ctx context.Context,
	pre func(*sql.Tx) error,
	fn func(*sql.Tx) error,
) error
It takes two callbacks. Only the first that runs inside the transaction is read, here pre
s := Store{db: db}
s.withTx(ctx, func(tx *sql.Tx) error {
	_, err := s.db.ExecContext(ctx, q)
	return err
})
It is a method with a pointer receiver, called on a struct held by value. The call takes the address of s, and a method of the module given it may change what s.db holds

The exact conditions are in design/rules.md and Helpers that are begins.

Declaring helpers

A helper whose body cannot be read has to be declared. That is usually an interface method, as a transaction manager given by dependency injection.

type Store interface {
	//txbypass:invokes
	WithTx(ctx context.Context, fn func(q Querier) error) error

	//txbypass:begins
	Begin(ctx context.Context) (Tx, error)
}

type Tx interface {
	//txbypass:closes
	Done(err error) error
}
Directive Declares
//txbypass:invokes A call opens a transaction, and runs the callback inside it
//txbypass:begins A call returns a transaction, open
//txbypass:closes A call ends every transaction it is given, its receiver included

The handle is the receiver. The callback is the first parameter of a function type. It is given the transaction at its first parameter that may be one. A number, a string, a bool, an error, a context or a function may not. A callback that takes a context and no transaction gets the transaction in the context, and outer-context reads it. The exact rules are in design/rules.md.

For code you cannot edit, pass the names in a flag:

txbypass \
  -invokes '(example.com/db.Manager).InTx' \
  -begins '(example.com/db.Manager).Begin' \
  -closes '(example.com/db.Tx).Done' \
  ./...

Each flag takes a comma-separated list of names of its kind. A flag given twice declares the names of both:

txbypass \
  -invokes '(example.com/db.Manager).InTx,(example.com/db.Store).WithTx' \
  ./...

A comma inside the brackets of type arguments does not split, as in (example.com/db.Map[K, V]).InTx. The golangci-lint plugin takes one name per item of a list instead.

Kind Spelling
A function example.com/db.InTx
A method (example.com/db.Manager).InTx
A method with a pointer receiver (*example.com/db.Manager).InTx. The * may be left out

A name not spelled this way stops the run. A name that is spelled right but names nothing is not reported, so check the package path.

outer-handle

Inside a transaction, every use of the handle it was opened on is reported. The transaction can be opened in two ways.

Opened byInside meansExample
A callback The callback's body. Its goroutines and deferred calls count too. They run while the transaction is open
return pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error {
	if _, err := tx.Exec(ctx, debit); err != nil {
		return err
	}
	// pool bypasses the transaction opened with
	// pgx.BeginFunc; use tx instead
	_, err := pool.Exec(ctx, credit)
	return err
})
A begin Where the transaction is open on every path: after the begin, and before its commit or rollback
tx, err := db.BeginTx(ctx, nil)
if err != nil {
	return err // the transaction did not begin here
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, debit); err != nil {
	return err
}
// db bypasses the transaction opened with
// BeginTx; use tx instead
if _, err := db.ExecContext(ctx, credit); err != nil {
	return err
}
return tx.Commit()

A handle is a value and the fields selected from it. Two expressions are the same handle when they hold the same value. The SSA form of the function says so. A copy of the handle is the handle.

CodeValid?Reason
db.Transaction(func(tx *gorm.DB) error {
	return db.Create(u).Error
})
❌ db opened the transaction
db.Transaction(func(tx *gorm.DB) error {
	return db.WithContext(ctx).Create(u).Error
})
❌ A method that returns its receiver's type gives the same handle back
d := db
db.Transaction(func(tx *gorm.DB) error {
	return d.Create(u).Error
})
❌ A copy of the handle is the handle
db.Transaction(func(tx *gorm.DB) error {
	return audit(db)
})
❌ Passing the handle on is a use, as to jet's stmt.Query(db, &dest)
r.db.Transaction(func(tx *gorm.DB) error {
	return r.db.Save(u).Error
})
❌ A field selected from the same variable is the same handle
type Store struct{ *gorm.DB }

s.Transaction(func(tx *gorm.DB) error {
	return s.Create(u).Error
})
❌ A method promoted from an embedded handle reaches it
type Store struct {
	*gorm.DB
	logger *log.Logger
}

s.Transaction(func(tx *gorm.DB) error {
	s.logger.Print("saving")
	return tx.Create(u).Error
})
✅ A struct that embeds the handle is not the handle. Its other fields and methods are no uses
var after []func()
err := db.Transaction(func(tx *gorm.DB) error {
	after = append(after, func() { db.Create(&Event{}) })
	return tx.Create(u).Error
})
✅ A literal kept in a variable declared outside the callback is an after-commit hook. It runs after the transaction, on purpose
dbs.Main.Transaction(func(tx *gorm.DB) error {
	return dbs.Main.Create(u).Error
})
❌ A variable of another package is a handle too
tx.Transaction(func(sp *gorm.DB) error {
	return tx.Create(u).Error
})
✅ A savepoint runs on the connection of its transaction, so tx runs inside it
//txbypass:ignore outer-handle // must survive a rollback
res := db.Create(&log)
if res.Error != nil {
	return res.Error
}
✅ The statement is reported once. Reading its error or RowsAffected runs nothing
db.Transaction(func(tx *gorm.DB) error {
	t.Cleanup(func() { db.Delete(&u) })
	return tx.Create(&u).Error
})
✅ A function given to t.Cleanup runs after the test, when the transaction has ended
db.RunInTx(ctx, nil, func(
	ctx context.Context,
	tx bun.Tx,
) error {
	_, err := db.NewInsert().
		Model(u).
		Conn(tx).
		Exec(ctx)
	return err
})
✅ bun's Conn(tx) runs the query built on db on the transaction
db.Transaction(func(tx *gorm.DB) error {
	return logs.Create(u).Error
})
✅ logs is another handle
r.db.Transaction(func(tx *gorm.DB) error {
	return r.logs.Save(u).Error
})
✅ r.logs is another field
db.Transaction(func(db *gorm.DB) error {
	return db.Create(u).Error
})
✅ The parameter shadows the outer db
CodeValid?Reason
tx, err := db.Begin()
if err != nil {
	db.Exec(logFailure)
	return err
}
defer tx.Rollback()
✅ The transaction did not begin
tx, _ := db.Begin()
if err := tx.Commit(); err != nil {
	return err
}
db.Exec(refresh)
✅ The transaction has ended
tx, _ := db.Begin()
if done {
	tx.Commit()
}
db.Exec(refresh)
✅ It may have ended
tx, _ := db.Begin()
defer db.Exec(audit)
return tx.Commit()
✅ It runs when the function returns
tx, _ := db.Begin()
t := tx
t.Commit()
db.Exec(refresh)
✅ A copy of the transaction is the transaction
tx, err := db.Begin()
finish(tx, err) // commits or rolls back
db.Exec(refresh)
✅ A helper that ends the transaction ends it, in this package or another
tx, _ := db.Begin()
h.tx = tx
db.Exec(refresh)
✅ The transaction went where this analysis does not follow. Other code may end it
tx, err := db.Begin()
if err != nil {
	log.Fatal(err)
}
if _, err := tx.Exec(debit); err != nil {
	tx.Rollback()
	log.Fatal(err)
}
db.Exec(audit)
❌ The path through log.Fatal never comes back, so the transaction is open on every other one
tx, _ := db.Begin()
go func() {
	db.Exec(audit)
}()
return tx.Commit()
❌ It starts while the transaction is open

Firestore references

Firestore's client builds the references a transaction reads and writes. Building one runs nothing, so only a use that runs is reported:

CodeValid?Reason
tx.Get(client.Doc("users/a"))
✅ The chain of calls on client takes no context. It only builds a reference
ref := client.Collection("users").Doc("b")
✅ The same
client.Doc("users/c").Set(ctx, v)
❌ A call in the chain takes a context, so it runs
save(ctx, client)
❌ The client on its own is given to a function

sqlc's WithTx

q.WithTx(tx) rebinds sqlc's generated Queries to the transaction. Code that calls it means to run on the transaction, so every other use of q there is reported:

tx, err := db.BeginTx(ctx, nil)
if err != nil {
	return err
}
defer tx.Rollback()
qtx := q.WithTx(tx)
if err := qtx.CreateUser(ctx, a); err != nil {
	return err
}
// q bypasses the transaction opened with
// BeginTx; use qtx instead
if err := q.CreateUser(ctx, b); err != nil {
	return err
}
return tx.Commit()

Any method named WithTx qualifies, when it takes the transaction and returns its receiver's type. The transaction may be a field of it, as q.WithTx(tx.Tx) with sqlx or bun. The method may be promoted, as from the *Queries that sqlc's Store embeds. Its receiver must hold nothing but the database, as sqlc's Queries does. The statements sqlc prepares with emit_prepared_queries count as the database. A repository that holds more, as metrics or a logger, is not rebound: its other members run no statement.

Tip

Some writes must survive a rollback, such as an audit log of a failed attempt. Write an ignore with the reason:

//txbypass:ignore outer-handle // must survive a rollback
db.Create(&AuditLog{Action: "failed"})

Fix

Use the transaction:

return r.db.WithContext(ctx).Transaction(
	func(tx *gorm.DB) error {
		if err := tx.Create(o).Error; err != nil {
			return err
		}
		return tx.Create(&AuditLog{OrderID: o.ID}).Error
	},
)

A helper that takes the handle takes the transaction instead:

LibraryA helper can take
database/sql
// *sql.DB and *sql.Tx both have these.
type DBTX interface {
	ExecContext(
		ctx context.Context,
		query string,
		args ...any,
	) (sql.Result, error)
	QueryContext(
		ctx context.Context,
		query string,
		args ...any,
	) (*sql.Rows, error)
	QueryRowContext(
		ctx context.Context,
		query string,
		args ...any,
	) *sql.Row
}
gorm *gorm.DB. The transaction has the handle's type
bun bun.IDB. *bun.DB, bun.Conn and bun.Tx implement it
pgx
// A pool, a connection and a transaction all have these.
type DBTX interface {
	Exec(
		ctx context.Context,
		sql string,
		args ...any,
	) (pgconn.CommandTag, error)
	Query(
		ctx context.Context,
		sql string,
		args ...any,
	) (pgx.Rows, error)
	QueryRow(
		ctx context.Context,
		sql string,
		args ...any,
	) pgx.Row
}
sqlc DBTX, which sqlc generates. Or q.WithTx(tx)

outer-context

Some invokers carry the transaction in the callback's context, not in a value. A statement run with the outer context runs outside the transaction:

_, err := sess.WithTransaction(
	ctx,
	func(sc context.Context) (any, error) {
		if _, err := orders.InsertOne(sc, o); err != nil {
			return nil, err
		}
		// ctx bypasses the transaction opened with
		// WithTransaction; use sc instead
		return audits.InsertOne(ctx, a)
	},
)
CodeValid?Reason
sess.WithTransaction(
	ctx,
	func(sc context.Context) (any, error) {
		return coll.InsertOne(ctx, doc)
	},
)
❌ ctx was given to the invoker, and carries no transaction
sess.WithTransaction(
	ctx,
	func(sc context.Context) (any, error) {
		return coll.InsertOne(sc, doc)
	},
)
✅ sc carries the transaction
sess.WithTransaction(
	ctx,
	func(ctx context.Context) (any, error) {
		return coll.InsertOne(ctx, doc)
	},
)
✅ The parameter shadows the outer ctx
repo.Transaction(ctx, func(txCtx context.Context) error {
	return repo.Insert(txCtx, u)
})
✅ The repository is not the handle here. The context decides the transaction

Fix

Use the callback's context. Name it as the outer one, so the outer one cannot be reached:

_, err := sess.WithTransaction(
	ctx,
	func(ctx context.Context) (any, error) {
		if _, err := orders.InsertOne(ctx, o); err != nil {
			return nil, err
		}
		return audits.InsertOne(ctx, a)
	},
)

Agent skill

skills/txbypass-authoring is a skill for an AI agent writing code under txbypass. It says how to fix each report, which fixes only hide a problem, and when an ignore is right. The binary carries it:

# the agents already set up in this project
txbypass skill install
txbypass skill install --agent claude-code --scope user
# where it is, and whether it is current
txbypass skill list

Without the binary, gh skill install mpyw/txbypass txbypass-authoring --agent claude-code writes to the same directories. The installer is go-skill-embed.

Ignoring a report

Write //txbypass:ignore with the rules to silence and a reason after //.

//txbypass:ignore outer-handle // must survive a rollback
db.Create(&AuditLog{Action: "failed"})
Placement Silences
On a line of its own The line below
After code Its own line
Directive Result
//txbypass:ignore outer-handle // reason Silences outer-handle
//txbypass:ignore // reason Silences every rule
No reason Reported, and silences nothing
An unknown rule name Reported
An ignore that silences nothing Reported as unused, unless a rule it covers is turned off. A bare ignore covers every rule

Limits

⚠️ marks code that bypasses the transaction, but is not reported:

CodeValid?Reason
db.Transaction(r.place)

func (r *Repo) place(tx *gorm.DB) error {
	return r.db.Create(u).Error
}
⚠️ A callback that is not a function literal has no body here to read
s.reuse(ctx, cur, func(tx *sql.Tx) error {
	_, err := s.db.ExecContext(ctx, q)
	return err
})
⚠️ A helper that may run the callback outside the transaction is not read as one. See Helpers of your own
db.Transaction(func(tx *gorm.DB) error {
	return r.audit(ctx) // audit uses r.db
})
⚠️ Only the code inside the transaction is read for uses, not the functions it calls
open().Transaction(func(tx *gorm.DB) error {
	return open().Create(u).Error
})
⚠️ Each call gives a value of its own, so a second open() is another handle
r.tx, err = db.Begin()
db.Exec(audit)
⚠️ Only a begin kept in a variable opens a region
client.RunTransaction(
	ctx,
	func(
		ctx context.Context,
		tx *firestore.Transaction,
	) error {
		ref := client.Doc(p)
		_, err := ref.Set(ctx, v)
		return err
	},
)
⚠️ ref is a Firestore reference, not the client
tx, _ := db.Begin()
job := Job{tx: tx}
job.Run()
db.Exec(audit)
⚠️ A transaction placed in a struct ends the region: where it goes from there is not followed
tx, _ := db.Begin() // db is a *sqlx.DB
db.MustExec(audit)
⚠️ Begin is promoted from the embedded *sql.DB, and db is a struct that embeds it. Use sqlx's Beginx
(*gorm.DB).Transaction(db, func(tx *gorm.DB) error {
	return db.Create(u).Error
})
⚠️ A method expression takes its receiver as an argument
db.Transaction(func(tx *gorm.DB) error {
	return audit(db, tx)
})
⚠️ A call given the transaction too keeps the handle beside it, as a constructor newStore(db, tx) does. What the call does with the handle is not read
sess := engine.NewSession()
defer sess.Close()
if err := sess.Begin(); err != nil {
	return err
}
engine.Insert(&u)
return sess.Commit()
⚠️ xorm's session is the transaction, and its Begin returns only an error, so no region is read. engine.Transaction(...) is read
m.Do(c, func(ctx context.Context) error {
	return saveOrder(c) // takes *gin.Context
})
⚠️ A framework context given to a parameter of its own type is taken as that type, not as a context. What the helper does with it is not read

These are reported, although the transaction may have ended before the use:

CodeValid?Reason
tx, _ := db.Begin()
end(tx) // end is a func(*sql.Tx) parameter
db.Exec(refresh)
❌ The body of a function value, or of an interface method, is not known. The transaction is taken to stay open
tx, _ := db.Begin()
repo.Keep(tx) // stores tx; other code commits it
db.Exec(refresh)
❌ Inside a helper, only an end it writes counts. Commit in the function, or write an ignore

The full specification, with every limit, is design/rules.md.

License

MIT

The Go gopher was designed by Renée French, under CC BY 4.0.

About

Go linter that reports a database handle used from inside a transaction it bypasses

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages