structbind

package module
v0.1.1-0...-3e4869c Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 11, 2026 License: Apache-2.0 Imports: 9 Imported by: 0

README

contrib/structbind — columns and bindings from struct tags

import "github.com/ubgo/sqlb/contrib/structbind"

Derives a table's column list, scan bindings and insert assignments from a struct's db: tags, so the list is written once instead of once per statement.

Contents

Usage

Declare the mapping once, at package level:

type account struct {
	ID    string `db:"id"`
	Email string `db:"email"`
	Age   int    `db:"age"`
}

var accounts = expr.Table{Name: "accounts", Alias: "a"}
var accountMap = structbind.MustNew[account](accounts)

Read into a struct:

var out account
q, _ := stmt.NewSelect(d).
	Columns(accountMap.Bindings(&out)...).
	From(accounts).
	Where(expr.Eq(id, "a1")).
	Build()
exec.One(ctx, db, q)

Read many rows as values:

rows, err := scan.All(ctx, db,
	stmt.NewSelect(d).From(accounts).Order(expr.OrderBy(id, expr.SortAsc)),
	accountMap.Reader())
// rows is []account

Reader and Bindings are the two halves of reading, and the difference is who owns the memory. Bindings point into a struct you keep, so a query holding them is not safe to share and reading many rows means copying the struct inside the loop; that is the right shape for exec.One and for a caller who wants the struct filled in place. A Reader allocates per row and returns values, so scan.All gives a []account directly and one Mapping may be read by any number of goroutines at once — pinned under -race by TestReader_SafeAcrossGoroutines. Prefer Reader for reads unless you specifically want the caller-owned struct.

Write from one:

in := account{ID: "a1", Email: "ada@x.com", Age: 36}
q, _ := stmt.NewInsert(d).Into(accounts).Row(accountMap.Assignments(&in)...).Build()
INSERT INTO "accounts" AS "a" ("id", "email", "age") VALUES ($1, $2, $3)

Exprs gives the same columns with no scan targets, for a subquery or a caller that scans on its own. Columns gives the names.

A Mapping does all its reflection in New, so it holds no mutable state and is safe to keep in a package-level variable and use from any number of goroutines. The per-statement calls take the struct they point at, which is why a Mapping is shared and the bindings are not.

Column names

A field with a db: tag uses that tag, always. A field without one gets its name from a Namer, and the default is Snake:

type user struct {
	ID        string
	CreatedAt time.Time
	IsActive  bool
}

structbind.MustNew[user](users).Columns()
// [id created_at is_active]

There is no universally correct convention, which is why this is a parameter. Columns named createdAt are ordinary in schemas generated by JavaScript-side tooling, so a fixed rule would be wrong for those schemas rather than merely inconvenient:

structbind.MustNew[user](users, structbind.Camel).Columns()
// [id createdAt isActive]
Namer CreatedAt HTTPStatus URLs
Snake (default) created_at http_status urls
Camel createdAt httpStatus urls
Pascal CreatedAt HttpStatus Urls
Lower createdat httpstatus urls
Exact CreatedAt HTTPStatus URLs

Anything else is a function:

structbind.MustNew[user](users, func(field string) string {
	return "col_" + strings.ToLower(field)
})

The Namer is an argument to New, never a setting. Handle-wide mappers put the equivalent on the shared handle as a package-level function, which makes the meaning of a struct depend on mutable state elsewhere in the process; two libraries sharing a handle then fight over it and the loser's columns are wrong at runtime with nothing to point at. Passing it here keeps one Mapping's answer knowable from one call site.

Word splitting

Snake, Camel and Pascal split a run of capitals as one word, so HTTPStatus is http_status rather than h_t_t_p_status, and a trailing lowercase s belongs to the run before it, so URLs is urls rather than ur_ls. Both rules are there because the obvious implementation produces names no database has.

There is no list of known initialisms, deliberately. Such a list is a decision about somebody else's vocabulary that can never be complete, and it makes the output depend on whether a word happened to be in it. The one visible consequence is that OAuth2Token becomes o_auth2_token:

type token struct {
	OAuth2Token string `db:"oauth2_token"` // derivation is wrong here, so say it
}

That is what the tag is for, and it is exact, local, and already the documented escape hatch.

Two fields, one column

A Mapping whose fields do not resolve to distinct columns is an error:

type bad struct {
	ID string
	Id string
}

_, err := structbind.New[bad](users)
// structbind: two fields resolve to the same column: ID and Id both map to "id"

This is an error rather than last-one-wins because the failure is otherwise invisible. Two fields mapping to id render SELECT "id", "id", which every engine accepts; both scan targets then receive the same value and one of your fields silently holds the wrong data, with no error at any layer. A db: tag that happens to match a name derived from another field does the same thing, and is caught the same way.

The check runs against the derived names, not the field names, so the same struct can be legal under one Namer and rejected under another — ID and Id are distinct under Exact and collide under Snake.

Tag rules

Field Column
Name string \db:"name"`` name
Email string (no tag, exported) from the Namer; email by default
CreatedAt time.Time (no tag) created_at by default, createdAt under Camel
Secret string \db:"-"`` excluded
cache int (unexported) excluded
embedded struct with no tag its fields are contributed, so a shared base struct works

Errors

New returns an error rather than panicking, and MustNew is the package-level-variable form that panics because there is nobody to return it to. Every failure is detected once, in New, not per statement:

Error When
ErrNotAStruct the type parameter is not a struct
ErrNoColumns the struct has no usable fields, which would build a statement selecting nothing
ErrDuplicateColumn two fields resolve to one column; the message names both Go fields
ErrEmptyColumn a custom Namer returned "", which would render "u".""

All four wrap with %w, so errors.Is works:

if _, err := structbind.New[user](users); errors.Is(err, structbind.ErrDuplicateColumn) {
	// two fields want the same column
}

ErrEmptyColumn is only reachable through a custom Namer: every Namer in this package returns a non-empty name for a non-empty field, and a field name is never empty. It is checked anyway because the alternative renders a quoted empty identifier, which is valid SQL that no table has, so the failure would arrive from the engine pointing nowhere near the cause.

The struct tag this package reads is structbind.TagKey, which is "db" — the convention most Go database packages use, so a struct already annotated for one of them works here unchanged.

The cost

This is stated plainly because it is the whole trade.

Column names come from struct tags or from a Namer, so a typo in a tag, or a convention that does not match the schema, is a runtime error where a typo in an expr.Col declaration is a compile error. And the Go type of a field is never checked against the column's: a string field mapped to an integer column builds a perfectly good statement and fails at the scan.

That is exactly the checking sqlb exists to provide, and this package gives it up in exchange for not writing the list.

Code generation over schema is the better answer where a schema exists: a generator can emit the Col declarations and the bindings, giving the same convenience with the checking intact. That is the design's intended path. This package is for the case where no schema declaration exists and the list is the thing you want gone.

Why this package can exist at all

bind.Bind takes an Expr[T] and a *T. Reflection has no static T to give it, so a reflection-driven binder cannot use it.

It works because stmt.Binding is an interface with three methods and nothing unexported:

type Binding interface {
	SQL(a *Args) string
	Target() any
	ColumnName() string
}

So this package implements it, from outside the module, and its bindings go straight into Columns alongside ones built by bind.Bind.

That is worth saying because it is the claim the architecture makes: a community package can supply its own result strategy. This package is the test of it. If Binding were ever turned back into a struct, structbind would become impossible, and TestMapping_ImplementsStmtBinding is what would say so.

Documentation

Overview

Package structbind derives columns, bindings and assignments from a struct's tags, so a table's column list is written once instead of once per statement.

It exists for two reasons, and the second is the more interesting.

The first is convenience. Declaring a table's columns and then repeating them in a select list, a scan list and an insert is three lists that have to stay in step, and other builders remove that with reflection. This does the same.

The second is that it is a test of the library's extension points. Nothing here is privileged: it implements stmt.Binding, which is an interface with no unexported methods, and builds columns through expr and render like any other package. If a reflection-driven binder could not be written outside the core, the claim that a community package can replace the result strategy would be false, and this package is what says otherwise.

The cost is stated plainly because it is the whole trade. Column names come from struct tags, so a typo in a tag is a runtime error where a typo in an expr.Col declaration is a compile error, and the Go type of a field is never checked against the column's. Code generation over github.com/ubgo/sqlb/schema gives the same convenience without giving that up, and is the better answer where a schema exists. This is for the case where one does not.

Index

Constants

View Source
const TagKey = "db"

TagKey is the struct tag this package reads. It matches the convention most Go database packages use, so a struct already annotated for one of them works here unchanged.

Variables

View Source
var ErrDuplicateColumn = errors.New("structbind: two fields resolve to the same column")

ErrDuplicateColumn is returned when two fields resolve to one column name.

It is an error rather than a last-one-wins because the failure is otherwise invisible. Two fields mapping to "id" render SELECT "id", "id", which every engine accepts; both scan targets then receive the same value and one field silently holds the wrong data, with no error at any layer. A struct with an ID and an Id field does it, and so does a db tag that happens to match a name derived from another field.

The error names both Go fields, because the column name alone does not say which two collided in a struct with embedded types.

View Source
var ErrEmptyColumn = errors.New("structbind: namer produced an empty column name")

ErrEmptyColumn is returned when a Namer produces an empty column name.

Only a custom Namer can cause this, since an empty tag falls through to the Namer and every Namer here returns a non-empty name for a non-empty field. It is caught because the alternative renders "u"."" , which is a valid identifier that no table has, so the failure would surface as a confusing error from the engine rather than as the bug it is.

View Source
var ErrNoColumns = errors.New("structbind: struct has no exported fields with a db tag")

ErrNoColumns is returned when a struct declares no usable fields, which is always a mistake: it would produce a statement selecting nothing.

View Source
var ErrNotAStruct = errors.New("structbind: type parameter must be a struct")

ErrNotAStruct is returned when the type parameter is not a struct.

Functions

func Camel

func Camel(name string) string

Camel converts a Go field name to camelCase: createdAt, userId.

For a schema whose columns are camelCase, which is what JavaScript-side tooling generates. The same word splitting as Snake, so HTTPStatus is httpStatus rather than hTTPStatus.

func Exact

func Exact(name string) string

Exact uses the Go field name verbatim: CreatedAt.

For a schema whose columns are the Go spelling, which happens where the tables were generated from these structs. Every dialect here quotes identifiers, so a mixed-case column survives.

func Lower

func Lower(name string) string

Lower lowercases the whole field name: createdat.

This was the behaviour before a Namer could be chosen, and it exists so that code relying on it can say so rather than depend on a default. It is almost never what a schema looks like; it is here to be named, not to be used.

func Pascal

func Pascal(name string) string

Pascal converts a Go field name to PascalCase: CreatedAt, UserId.

Distinct from Exact for exactly the reason Camel is distinct from it: splitting normalises a capital run, so HTTPStatus becomes HttpStatus rather than being passed through unchanged. Use Exact when the column really is the Go spelling.

func Snake

func Snake(name string) string

Snake converts a Go field name to snake_case, and is the default.

It is the default because it is the convention SQL itself uses and the one most schemas follow, but it is only a default: a column genuinely named "createdAt" is common in schemas generated by JavaScript tooling, which is why Camel exists and why any func is accepted.

A run of capitals is one word, so HTTPStatus is http_status rather than h_t_t_p_status, and a trailing lowercase s belongs to the run before it, so URLs is urls rather than ur_ls. Both rules exist because the alternative produced names no database has ever had.

It has no list of known initialisms, deliberately. Such a list is a decision about somebody else's vocabulary that can never be complete, and it would make the output depend on whether a word happened to be in it. OAuth2Token becomes o_auth2_token rather than oauth2_token; when the derivation is wrong the answer is a db tag, which is exact and local and already the documented escape hatch.

Types

type Mapping

type Mapping[T any] struct {
	// contains filtered or unexported fields
}

Mapping is the column list derived from a struct, bound to one table.

It is built once and shared. Nothing in it is mutable and the reflection all happens in New, so a Mapping is safe to keep in a package-level variable and use from any number of goroutines, which is the point of separating it from the per-statement calls below.

func MustNew

func MustNew[T any](t expr.Table, namer ...Namer) *Mapping[T]

MustNew is New for a package-level variable, where an error can only be a programming mistake and there is nobody to return it to.

func New

func New[T any](t expr.Table, namer ...Namer) (*Mapping[T], error)

New derives a Mapping for T against table t.

Fields are included when they carry a db tag, or when they are exported and the tag is absent, in which case the column comes from the Namer. A tag of "-" excludes a field, which is how a struct carries something that is not a column.

The optional Namer sets how an untagged field name becomes a column name. The default is Snake, so CreatedAt is created_at; pass Camel for a schema whose columns are createdAt, Exact for one whose columns are the Go spelling, or any func for anything else. A tag always wins over the Namer.

Only the first Namer is used. It is variadic so that omitting it is the ordinary call rather than a nil argument, and because a second one could only mean that two parts of a program disagree about the schema.

func (*Mapping[T]) Assignments

func (m *Mapping[T]) Assignments(src *T) []stmt.Assignment

Assignments returns one assignment per column, reading from src, for an INSERT row or an UPDATE SET list.

func (*Mapping[T]) Bindings

func (m *Mapping[T]) Bindings(dst *T) []stmt.Binding

Bindings returns one binding per column, pointing into dst.

The pointers are into the struct the caller passed, so everything the binding API documents about concurrency applies: the query holds this memory, and two goroutines executing it write through the same fields.

func (*Mapping[T]) Columns

func (m *Mapping[T]) Columns() []string

Columns returns the column names in struct order.

func (*Mapping[T]) Exprs

func (m *Mapping[T]) Exprs() []render.SQLer

Exprs returns the columns as select items, for a statement that scans on its own or does not scan at all.

func (*Mapping[T]) Reader

func (m *Mapping[T]) Reader() scan.Reader[T]

Reader returns a scan.Reader that produces a fresh T per row.

This is the counterpart to Bindings and the difference is who owns the memory. Bindings point into a struct the caller keeps, so a query holding them is not safe to share and reading many rows means copying the struct inside the loop. A Reader allocates per row and returns values, so scan.All gives a []T directly and one Mapping may be read by any number of goroutines at once.

Prefer this for reads unless you specifically want the caller-owned struct, which is the shape exec.One and the binding API are built around.

func (*Mapping[T]) Table

func (m *Mapping[T]) Table() expr.Table

Table returns the table this Mapping was built against.

type Namer

type Namer func(goFieldName string) string

Namer derives a column name from a Go field name.

It is the fallback for a field with no db tag, never an override: a tag is what somebody wrote down deliberately, so it always wins.

This is a parameter rather than a package-level setting on purpose. Other mappers put the equivalent on the handle as a package-wide function, which makes the meaning of a struct depend on mutable state somewhere else in the process; two libraries sharing a handle then fight over it, and the loser's columns are wrong at runtime with nothing to point at. Passing it to New keeps one Mapping's answer knowable from one call site.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL