Categorygithub.com/domonda/go-sqldb
modulepackage
0.0.0-20250312140307-836afd7c42ad
Repository: https://github.com/domonda/go-sqldb.git
Documentation: pkg.go.dev

# README

go-sqldb

Go Reference license

This package started out as an extension wrapper of github.com/jmoiron/sqlx but turned into a complete rewrite using the same philosophy of representing table rows as Go structs.

It has been used and refined for years in production by domonda using the database driver github.com/lib/pq.

The design patterns evolved mostly through discovery led by the desire to minimize boilerplate code while maintaining the full power of SQL.

Philosophy

  • Use reflection to map db rows to structs, but not as full blown ORM that replaces SQL queries (just as much ORM to increase productivity but not alienate developers who like the full power of SQL)
  • Transactions are run in callback functions that can be nested
  • Option to store the db connection and transactions in the context argument to pass it down into nested functions

Database drivers

Usage

Creating a connection

The connection is pinged with the passed context and only returned when there was no error from the ping:

config := &sqldb.Config{
    Driver:   "postgres",
    Host:     "localhost",
    User:     "postgres",
    Database: "demo",
    Extra:    map[string]string{"sslmode": "disable"},
}

fmt.Println("Connecting to:", config.ConnectURL())

conn, err := pqconn.New(context.Background(), config)

Struct field mapping

Every new connection initially uses DefaultStructFieldTagNaming

package sqldb

// DefaultStructFieldTagNaming provides the default StructFieldTagNaming
// using "db" as NameTag and IgnoreStructField as UntaggedNameFunc.
// Implements StructFieldNamer.
var DefaultStructFieldTagNaming = StructFieldTagNaming{
	NameTag:          "db",
	IgnoreName:       "-",
	UntaggedNameFunc: IgnoreStructField,
}

Use a different mapping:

conn = conn.WithStructFieldNamer(sqldb.StructFieldTagNaming{
    NameTag:          "col",
    IgnoreName:       "_ignore_",
    UntaggedNameFunc: sqldb.ToSnakeCase,
})

Exec SQL without reading rows

err = conn.Exec(`delete from public.user where id = $1`, userID)

Single row query

type User struct {
	ID    uu.ID  `db:"id,pk"`
	Email string `db:"email"`
	Name  string `db:"name"`
}

var user User
err = conn.QueryRow(`select * from public.user where id = $1`, userID).ScanStruct(&user)

var userExists bool
err = conn.QueryRow(`select exists(select from public.user where email = $1)`, userEmail).Scan(&userExists)

Multi rows query

var users []*User
err = conn.QueryRows(`select * from public.user`).ScanStructSlice(&users)

var userEmails []string
err = conn.QueryRows(`select email from public.user`).ScanSlice(&userEmails)

// Use reflection for callback function arguments
err = conn.QueryRows(`select name, email from public.user`).ForEachRowCall(
    func(name, email string) {
        fmt.Printf("%q <%s>\n", name, email)
    },
)

err = conn.QueryRows(`select name, email from public.user`).ForEachRow(
    func(row sqldb.RowScanner) error {
        var name, email string
        err := row.Scan(&name, &email)
        if err != nil {
            return err
        }
        _, err = fmt.Printf("%q <%s>\n", name, email)
        return err
    },
)

Insert rows

newUser := &User{ /* ... */ }

err = conn.InsertStruct("public.user", newUser)

// Use column defaults for insert instead of struct fields
err = conn.InsertStructIgnoreColumns("public.user", newUser, "id", "created_at")

// Upsert uses columns marked as primary key like `db:"id,pk"`
err = conn.UpsertStructIgnoreColumns("public.user", newUser, "created_at")

// Without structs
err = conn.Insert("public.user", sqldb.Values{
    "name":  "Erik Unger",
    "email": "[email protected]",
})

Transactions

txOpts := &sql.TxOptions{Isolation: sql.LevelWriteCommitted}

err = sqldb.Transaction(conn, txOpts, func(tx sqldb.Connection) error {
    err := tx.Exec("...")
    if err != nil {
        return err // roll back tx
    }
    return tx.Exec("...")
})

Using the context

Saving a context in a struct is an antipattern in Go but it turns out that it allows neat call chaining pattern.

ctx, cancel := context.WithTimeout(context.Background(), time.Second*30)
defer cancel()

// Note that this timout is a deadline and does not restart for every query
err = conn.WithContext(ctx).Exec("...")

// Usually the context comes from some top-level handler
_ = http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) {
    // Pass request cancellation through to db query
    err := conn.WithContext(request.Context()).Exec("...")
    if err != nil {
        http.Error(response, err.Error(), http.StatusInternalServerError)
        return
    }
    response.Write([]byte("OK"))
})

Putting it all together with the db package

The github.com/domonda/go-sqldb/db package enables a design pattern where a "current" db connection or transaction can be stored in the context and then retrieved by nested functions from the context without having to know if this connection is a transaction or not. This allows re-using the same functions within transactions or standalone.

// Configure the global parent connection
db.SetConn(conn)

// db.Conn(ctx) is the standard pattern
// to retrieve a connection anywhere in the code base
err = db.Conn(ctx).Exec("...")

Here if GetUserOrNil will use the global db connection if no other connection is stored in the context.

But when called from within the function passed to db.Transaction it will re-use the transaction saved in the context.

func GetUserOrNil(ctx context.Context, userID uu.ID) (user *User, err error) {
	err = db.Conn(ctx).QueryRow(
		`select * from public.user where id = $1`,
		userID,
	).ScanStruct(&user)
	if err != nil {
		return nil, db.ReplaceErrNoRows(err, nil)
	}
	return user, nil
}

func DoStuffWithinTransation(ctx context.Context, userID uu.ID) error {
	return db.Transaction(ctx, func(ctx context.Context) error {
		user, err := GetUserOrNil(ctx, userID)
		if err != nil {
			return err
		}
		if user == nil {
			return db.Conn(ctx).Exec("...")
		}
		return db.Conn(ctx).Exec("...")
	})
}

Small helpers:

err = db.TransactionOpts(ctx, &sql.TxOptions{ReadOnly: true}, func(context.Context) error { ... })

err = db.TransactionReadOnly(ctx, func(context.Context) error { ... })

// Execute the passed function without transaction
err = db.DebugNoTransaction(ctx, func(context.Context) error { ... })

More sophisticated transactions:

Serialized transactions are typically necessary when an insert depends on a previous select within the transaction, but that pre-insert select can't lock the table like it's possible with SELECT FOR UPDATE.

err = db.SerializedTransaction(ctx, func(context.Context) error { ... })

TransactionSavepoint executes txFunc within a database transaction or uses savepoints for rollback. If the passed context already has a database transaction connection, then a savepoint with a random name is created before the execution of txFunc. If txFunc returns an error, then the transaction is rolled back to the savepoint but the transaction from the context is not rolled back. If the passed context does not have a database transaction connection, then Transaction(ctx, txFunc) is called without savepoints.

err = db.TransactionSavepoint(ctx, func(context.Context) error { ... })

# Packages

No description provided by the author
No description provided by the author
Package information contains structs and functions to query the information_schema.
No description provided by the author

# Functions

CheckTxOptionsCompatibility returns an error if the parent transaction options are less strict than the child options.
ConnectionWithError returns a dummy Connection where all methods return the passed error.
No description provided by the author
No description provided by the author
IgnoreStructField can be used as TaggedStructFieldMapping.UntaggedNameFunc to ignore fields that don't have TaggedStructFieldMapping.NameTag.
No description provided by the author
IsNull returns if val would be interpreted as NULL by a SQL driver.
IsNullOrZero returns if val would be interpreted as NULL by a SQL driver or if it is the types zero value or if it implements interface{ IsZero() bool } returning true.
IsolatedTransaction executes txFunc within a database transaction that is passed in to txFunc as tx Connection.
IsOtherThanErrNoRows returns true if the passed error is not nil and does not unwrap to, or is sql.ErrNoRows.
NewTaggedStructFieldMapping returns a default mapping.
NextTransactionNo returns the next globally unique number for a new transaction in a threadsafe way.
No description provided by the author
No description provided by the author
ReplaceErrNoRows returns the passed replacement error if errors.Is(err, sql.ErrNoRows), else the passed err is returned unchanged.
RowScannerWithError returns a dummy RowScanner where all methods return the passed error.
RowsScannerWithError returns a dummy RowsScanner where all methods return the passed error.
SanitizeString returns valid UTF-8 only with printable characters.
SanitizeStringTrimSpace returns valid UTF-8 only with printable characters with leading and trailing whitespace trimmed away.
ScanDriverValue scans a driver.Value into destPtr.
Transaction executes txFunc within a database transaction that is passed in to txFunc as tx Connection.

# Constants

ErrNotWithinTransaction is returned by methods that are are only allowed within DB transactions when the DB connection is not a transaction.
No description provided by the author
ErrWithinTransaction is returned by methods that are not allowed within DB transactions when the DB connection is a transaction.
FieldFlagDefault marks a field as having a column default value.
FieldFlagPrimaryKey marks a field as primary key.
FieldFlagReadOnly marks a field as read-only.

# Variables

DefaultStructFieldMapping provides the default StructFieldTagNaming using "db" as NameTag and IgnoreStructField as UntaggedNameFunc.
ErrLogger will be used to log errors that could not be returned by any of the package functions directly.
No description provided by the author
No description provided by the author
No description provided by the author
No description provided by the author
No description provided by the author
No description provided by the author
ToSnakeCase converts s to snake case by lower casing everything and inserting '_' before every new upper case character in s.

# Structs

AnyValue wraps a driver.Value and is useful for generic code that can handle unknown column types.
Config for a connection.
No description provided by the author
No description provided by the author
No description provided by the author
No description provided by the author
No description provided by the author
No description provided by the author
No description provided by the author
No description provided by the author
No description provided by the author
TaggedStructFieldMapping implements StructFieldMapper with a struct field NameTag to be used for naming and a UntaggedNameFunc in case the NameTag is not set.

# Interfaces

No description provided by the author
Connection represents a database connection or transaction.
Logger has a Printf method used for logging information that could not be returned by any of the package functions directly.
PlaceholderFormatter is an interface for formatting query parameter placeholders implemented by database connections.
RowScanner scans the values from a single row.
RowsScanner scans the values from multiple rows.
StructFieldMapper is used to map struct type fields to column names and indicate special column properies via flags.

# Type aliases

No description provided by the author
FieldFlag is a bitmask for special properties of how struct fields relate to database columns.
No description provided by the author
No description provided by the author
StringScannable implements the sql.Scanner interface and converts all scanned values to string.
Values is a map from column names to values.