xsql

package module
v0.1.1 Latest Latest
Warning

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

Go to latest
Published: Aug 13, 2025 License: MIT Imports: 12 Imported by: 0

README

xsql - Type-Safe, Minimal SQL for Go

Go Reference Go Report Card Tests Codecov

xsql is a small, stdlib-style layer over database/sql that eliminates repetitive row-mapping code without hiding SQL behind an ORM. You keep full control of your queries while getting type-safe scanning into structs, primitives, and custom types that implement sql.Scanner.

It’s designed for developers who value clarity, simplicity, and performance,whether you’re working on a large production system or just learning Go’s database API.

Features

xsql is designed for Go developers who want to keep the simplicity of database/sql while eliminating repetitive boilerplate. It integrates seamlessly with your existing code without forcing an ORM or complex abstractions.

Key capabilities include:

  • Strongly typed query functions that map directly into structs, primitives, or custom scanner types without reflection-heavy frameworks.
  • Zero configuration required; works with *sql.DB, *sql.Tx, and *sql.Conn.
  • Automatic column-to-field mapping with db tags, falling back to case-insensitive field names.
  • Safe handling of empty results for single-row queries, returning nil instead of panics.
  • Built-in plan caching for performance, initialized lazily and safe for concurrent access.
  • Fully compatible with native SQL syntax, no query builders or DSLs.
  • First-class support for named parameters (:name) with safe slice/array expansion and placeholder rewriting for multiple SQL dialects (PostgreSQL, MySQL, SQLite, SQL Server, Oracle, DuckDB, ClickHouse, etc.).
  • Small, focused API: Query, Get, Exec, NamedQuery, and NamedExec cover the majority of use cases.

Why xsql

The standard database/sql package is intentionally low-level - it gives you complete control, but it also means you spend a lot of time writing the same patterns over and over: creating rows, looping, scanning values into variables, handling conversion errors, and appending to slices.

That’s great for ultimate flexibility, but not great for everyday productivity. Here’s the typical dance:

rows, err := db.QueryContext(ctx, "SELECT id, email FROM users WHERE active = ?", 1)
if err != nil {
    return err
}
defer rows.Close()

var results []User
for rows.Next() {
    var u User
    if err := rows.Scan(&u.ID, &u.Email); err != nil {
        return err
    }
    results = append(results, u)
}

if err := rows.Err(); err != nil {
    return err
}

With xsql, the same thing becomes:

users, err := xsql.Query[User](ctx, db, "SELECT id, email FROM users WHERE active = ?", 1)

That’s it. You still decide the SQL. You still decide how to structure your queries and joins. xsql simply takes care of the mechanical parts - scanning, type conversion, and slice building - while staying out of your way.

xsql is not an ORM, it doesn’t hide SQL, and it doesn’t try to reinvent how you talk to databases. Instead, it gives you minimal, type-safe helpers so you can:

  • Write SQL the way you like, with full control over queries and schema.
  • Map results into your Go types automatically without losing performance.
  • Reduce repetitive scanning code and keep your functions concise.
  • Maintain compatibility with all database drivers supported by database/sql.
  • Keep learning curves low for new developers while giving experts the control they expect.

The result is code that looks clean, compiles with type safety, and performs just as well as handwritten scanning - while remaining transparent and debuggable.

Installation

xsql works with Go 1.20+ and any modern SQL driver that implements the database/sql/driver interface. To install xsql, simply run:

go get github.com/go-mizu/xsql

You will also need a driver for your database. For example, you can use:

go get github.com/jackc/pgx/v5        # PostgreSQL  
go get github.com/go-sql-driver/mysql # MySQL  
go get modernc.org/sqlite             # SQLite (CGO-free)

In your code, import xsql alongside your chosen driver:

import (
    "context"
    "database/sql"
    "log"

    "github.com/go-mizu/xsql"
    _ "github.com/jackc/pgx/v5/stdlib" // PostgreSQL driver
)

Quick Start

The following is a minimal example showing how to use xsql to query data into a struct.

type User struct {
    ID    int64  `db:"id"`
    Email string `db:"email"`
}
func main() {
    db, err := sql.Open("pgx", "postgres://user:pass@localhost/dbname")
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()
    
    db.Exec(`CREATE TABLE users (id SERIAL PRIMARY KEY, email TEXT, active BOOLEAN)`)
    db.Exec(`INSERT INTO users (email, active) VALUES ('[email protected]', true), ('[email protected]', false)`)
    ctx := context.Background()
    users, err := xsql.Query[User](ctx, db, `SELECT id, email FROM users WHERE active = $1`, true)
    if err != nil {
        log.Fatal(err)
    }
    for _, u := range users {
        log.Println(u.ID, u.Email)
    }
}

You can also query directly into primitive slices when only one column is needed:

ids, err := xsql.Query[int64](ctx, db, `SELECT id FROM users WHERE active = $1`, true)

When you need just one record, Get returns a single value instead of a slice:

u, err := xsql.Get[User](ctx, db, `SELECT id, email FROM users WHERE id = $1`, 1)
if err != nil {
    log.Fatal(err)
}
if u != nil {
    log.Println(u.ID, u.Email)
}
Named Parameters Example

You can use named parameters (:name) with NamedQuery and NamedExec for safer, clearer queries. Instead of passing a generic map[string]any, define a struct with db tags for each parameter:

type UserFilter struct {
    Status string  `db:"status"`
    IDs    []int64 `db:"ids"`
}

filter := UserFilter{
    Status: "active",
    IDs:    []int64{1, 2, 3},
}

// Automatically picks correct placeholder style for your driver
ph := xsql.PlaceholderFor("pgx") // → xsql.PlaceholderDollar

users, err := xsql.NamedQuery[User](ctx, db, ph,
    `SELECT id, email FROM users WHERE status = :status AND id IN (:ids)`,
    filter)
if err != nil {
    log.Fatal(err)
}

NamedExec works the same way:

type UpdateStatus struct {
    Status string `db:"status"`
    IDs    []int  `db:"ids"`
}

upd := UpdateStatus{
    Status: "archived",
    IDs:    []int{5, 6},
}

ph := xsql.PlaceholderFor("sqlserver") // → xsql.PlaceholderAtP

_, err = xsql.NamedExec(ctx, db, ph,
    `UPDATE users SET status = :status WHERE id IN (:ids)`,
    upd)
if err != nil {
    log.Fatal(err)
}

Usage Guide

xsql keeps the surface small so you can learn it in minutes. There are three core functions:

Query

Query[T any](ctx context.Context, db DB, query string, args ...any) ([]T, error)

Runs a SQL query and maps all rows into a slice of type T. T can be a struct, a primitive type, or a type implementing sql.Scanner. The db argument can be a *sql.DB, *sql.Tx, or anything that implements QueryContext.

type Product struct {
    ID    int64   `db:"id"`
    Name  string  `db:"name"`
    Price float64 `db:"price"`
}

ctx := context.Background()
products, err := xsql.Query[Product](ctx, db,
    `SELECT id, name, price FROM products WHERE price > ?`, 10.0)
if err != nil {
    log.Fatal(err)
}
fmt.Println(products)
Get

Get[T any](ctx context.Context, db DB, query string, args ...any) (T, error)

Runs a SQL query and returns the first row mapped to type T. If no rows are found, it returns sql.ErrNoRows. Perfect for single-value lookups or when you expect at most one row.

ctx := context.Background()
price, err := xsql.Get[float64](ctx, db,
    `SELECT price FROM products WHERE id = ?`, 42)
if err != nil {
    if errors.Is(err, sql.ErrNoRows) {
        fmt.Println("No product found")
    } else {
        log.Fatal(err)
    }
}
fmt.Println("Price:", price)
Exec

Exec(ctx context.Context, db DB, query string, args ...any) (sql.Result, error)

Executes a SQL statement without returning rows, such as INSERT, UPDATE, or DELETE. The db argument can be a *sql.DB, *sql.Tx, or anything that implements ExecContext.

ctx := context.Background()
res, err := xsql.Exec(ctx, db,
    `UPDATE products SET price = price - 1.1 WHERE category_id = ?`, 5)
if err != nil {
    log.Fatal(err)
}
affected, _ := res.RowsAffected()
fmt.Println("Updated rows:", affected)
NamedQuery

NamedQuery[T any](ctx context.Context, db Querier, placeholder Placeholder, query string, params any) ([]T, error)

Like Query, but supports named parameters (:name) in the SQL string. The params argument can be a struct (fields tagged with db:"name") or a map[string]any. Slices and arrays (except []byte) are expanded automatically for IN clauses. The placeholder argument specifies the style for your database — use PlaceholderFor(driverName) to detect it.

type ProductFilter struct {
    MinPrice float64 `db:"min_price"`
    IDs      []int   `db:"ids"`
}

filter := ProductFilter{
    MinPrice: 10.0,
    IDs:      []int{1, 2, 3},
}

ph := xsql.PlaceholderFor("pgx") // → xsql.PlaceholderDollar

ctx := context.Background()
products, err := xsql.NamedQuery[Product](ctx, db, ph,
    `SELECT id, name, price
     FROM products
     WHERE price >= :min_price
       AND id IN (:ids)`,
    filter)
if err != nil {
    log.Fatal(err)
}
fmt.Println(products)
NamedExec

NamedExec(ctx context.Context, db Execer, placeholder Placeholder, query string, params any) (sql.Result, error)

Executes a SQL statement with named parameters, without returning rows — useful for INSERT, UPDATE, or DELETE. params follows the same rules as NamedQuery.

type UpdateStatus struct {
    Status string `db:"status"`
    IDs    []int  `db:"ids"`
}

upd := UpdateStatus{
    Status: "archived",
    IDs:    []int{5, 6},
}

ph := xsql.PlaceholderFor("sqlserver") // → xsql.PlaceholderAtP

ctx := context.Background()
res, err := xsql.NamedExec(ctx, db, ph,
    `UPDATE products
     SET status = :status
     WHERE id IN (:ids)`,
    upd)
if err != nil {
    log.Fatal(err)
}

affected, _ := res.RowsAffected()
fmt.Println("Updated rows:", affected)

Advanced Usage

Mapping to Custom Types

Any type implementing sql.Scanner can be directly used with xsql. This is useful for enums, JSON fields, or other domain-specific types.

type Email string

func (e *Email) Scan(src any) error {
    switch v := src.(type) {
        case []byte:
            *e = Email(string(v))
        case string:
            *e = Email(v)
        default:
            return fmt.Errorf("unexpected type %T", src)
    }
    return nil
}

ctx := context.Background()
emails, err := xsql.Query[Email](ctx, db, `SELECT email FROM users`)
if err != nil {
    log.Fatal(err)
}
fmt.Println(emails)
Nested Structs and Inline Fields

xsql supports flattening nested structs when tagged with db:",inline". This makes it easy to combine related data into one Go value without manual joins in your code.

type Address struct {
    City   string `db:"city"`
    Street string `db:"street"`
}
type Customer struct {
    ID      int64   `db:"id"`
    Name    string  `db:"name"`
    Address `db:",inline"`
}

ctx := context.Background()
customers, err := xsql.Query[Customer](ctx, db,
    `SELECT id, name, city, street FROM customers`)
if err != nil {
    log.Fatal(err)
}
fmt.Println(customers)
Transactions

xsql works seamlessly with transactions. Just pass a *sql.Tx in place of *sql.DB for any function.

ctx := context.Background()
tx, err := db.BeginTx(ctx, nil)
if err != nil {
    log.Fatal(err)
}
defer tx.Rollback()

_, err := xsql.Exec(ctx, tx, `INSERT INTO logs(message) VALUES (?)`, "started")
if err != nil {
    log.Fatal(err)
}

if err := tx.Commit(); err != nil {
    log.Fatal(err)
}

How It Works

Design Philosophy

xsql is not an ORM. It is a thin, type-safe wrapper around the database/sql standard library, designed to make mapping query results into Go types predictable and fast. Instead of generating code or maintaining complex metadata, xsql uses reflection only once per unique type/column set and stores the mapping in a cache. This keeps the runtime cost low without sacrificing developer experience.

The library avoids hidden behaviors. Every query you run maps directly to the SQL you write, so you remain in control of indexing, joins, and performance. This approach encourages developers to think about database performance from the start, while still enjoying concise and maintainable Go code.

We believe Go developers should not have to choose between bare database/sql and heavy ORMs. xsql sits in the middle - minimal abstraction, but with enough type awareness to eliminate repetitive boilerplate code.

Mapping Strategy

When you call Query or Get with a type parameter T, xsql checks if a column mapping for T already exists in its internal cache. If not, it inspects the type using reflection, looks for db struct tags, and matches the columns returned by the query to fields. Once computed, the mapping is stored in a concurrency-safe cache keyed by the type and column list.

For primitive types or types implementing sql.Scanner, no field mapping is required - values are scanned directly into the destination slice or variable.

Execution Flow
  1. Prepare mapping: For structs, find the field index for each column. For primitives, skip mapping entirely.
  2. Run query: Execute using the QueryContext or ExecContext method of the provided db.
  3. Scan rows: Use the mapping to scan each row directly into the appropriate fields or variables.
  4. Return typed result: For Query, return a slice of T; for Get, return a single T.
Performance Considerations

The first call for a given type/column set involves reflection and map allocation. Subsequent calls reuse the computed mapping. This approach yields ORM-like convenience without ORM-level overhead.

Custom sql.Scanner implementations receive raw database values, allowing domain types to handle parsing themselves ( e.g., JSON fields, enum types, or time formats).

Contributing

We welcome contributions from both seasoned Go developers and those just starting out. The goal of xsql is to remain minimal and type-safe while fitting naturally into the Go ecosystem. If you have an idea, improvement, or bug fix, here’s how you can help:

  1. Fork the repository and create a feature branch.
  2. Write clear, focused commits with descriptive messages.
  3. Add or update tests to cover your changes.
  4. Run the full test suite with go test ./... before submitting.
  5. Open a pull request with a description of the changes and reasoning.

If you’re unsure about an idea, feel free to open an issue first to discuss it. We’re happy to give feedback before you start coding.

Running Tests

The test suite includes example-based documentation tests. This means many examples from the README and doc comments are also run during testing, ensuring that documentation and implementation stay in sync.

To run tests locally:

go test ./... -v

License

xsql is released under the MIT License. This means you can freely use, modify, and distribute the library in your own projects, whether commercial or open source, as long as the license terms are included.

Documentation

Overview

Package xsql is a minimal, stdlib-style layer over database/sql that provides type-safe scanning into structs, primitives, and sql.Scanner types. You write plain SQL; xsql maps results into Go values with a tiny, predictable API.

Overview

xsql preserves database/sql semantics while removing repetitive row-mapping code. It works with *sql.DB, *sql.Tx, and *sql.Conn. Mapping is deterministic, fast, and easy to reason about in code review.

Mapping rules

  • Fields bind by `db:"name"` first; otherwise case-insensitive field ←→ column name.
  • Nested structs can be flattened with `db:",inline"`.
  • If a destination type (or field) implements sql.Scanner, its Scan method receives the driver value.
  • Primitives (bool, numbers, string, []byte, time.Time, sql.RawBytes) are supported directly.
  • Extra columns are ignored; missing columns yield zero values (favors robustness).

Performance

On first use of a (Type, ColumnSet) pair, xsql builds a scan plan (column → field index path and destination strategy). Plans and per-type indexes are cached in a lazily-initialized, concurrency-safe map (sync.Map). Subsequent scans reuse the plan and avoid reflection on the hot path. Common safe conversions (e.g., []byte→string, numeric widenings) are handled inline.

Error handling

  • Get returns sql.ErrNoRows when no row matches.
  • Query and Exec propagate underlying driver errors.
  • Iterator / protocol issues surface via rows.Err() at the end of Query.

Compatibility

xsql works with any database/sql driver (PostgreSQL, MySQL, SQLite, SQL Server, Oracle). It does not rewrite SQL or placeholders; write queries exactly as your driver expects.

Usage notes

Prefer explicit column lists over SELECT * to keep mapping stable. Add LIMIT 1 (or the equivalent) when you expect a single row. Use contexts to bound query timeouts. Keep Go types close to database types to minimize surprises. For large reads, consider streaming with Query and processing incrementally if memory usage matters.

xsql is intended for production systems that value clarity and performance over abstraction. It keeps the API small and predictable while giving you full control over your SQL.

named.go

Index

Constants

This section is empty.

Variables

View Source
var ErrDuplicateKeyTag = errors.New("xsql: named bind: duplicate key from struct tags/fields")

ErrDuplicateKeyTag is returned when two struct fields (including embedded) resolve to the same logical parameter name (case-insensitive), e.g. via db:"name".

View Source
var ErrNilParams = errors.New("xsql: named bind: nil params")

ErrNilParams is returned when named binding is requested with a nil pointer or nil params value. This typically means you passed a nil *struct to Rebind.

View Source
var ErrUnsupportedArg = errors.New("xsql: named bind: params must be struct or map[string]any")

ErrUnsupportedArg is returned when the single named-binding argument is not a struct or map[string]any (e.g., passing an int or map[int]any).

Functions

func Exec

func Exec(ctx context.Context, e Execer, query string, args ...any) (sql.Result, error)

Exec executes a statement that does not return rows (INSERT, UPDATE, DELETE, DDL).

It forwards to the underlying Execer. On success it returns the driver's sql.Result, which may support LastInsertId and RowsAffected depending on the database/driver.

Exec does not attempt SQL rendering or placeholder rewriting; write your SQL exactly as your driver expects.

Example:

// Given a *sql.DB (or *sql.Tx, *sql.Conn) in variable `db`:
ctx := context.Background()
res, err := xsql.Exec(ctx, db, `INSERT INTO users (email) VALUES (?)`, "[email protected]")
if err != nil {
    log.Fatal(err)
}
n, _ := res.RowsAffected()
fmt.Println("rows:", n)

Notes:

  • Use a transaction (BeginTx) around multiple Exec/Query calls when you need atomicity.
  • Not all drivers support LastInsertId; prefer RETURNING with Query/Get where available.

func Get

func Get[T any](ctx context.Context, q Querier, query string, args ...any) (out T, err error)

Get executes the SQL query and scans the first row into a value of type T.

It returns sql.ErrNoRows if the query yields no rows and does not enforce "exactly one row" beyond the first; if more rows exist, they are ignored. You should use LIMIT 1 (or an equivalent WHERE clause) when you require at-most-one row.

T may be a struct (supports `db` tags and ,inline), a primitive, or any type implementing sql.Scanner. Column mapping prefers `db:"name"` tags; otherwise it matches case-insensitive field names.

Extra columns are ignored and missing columns set zero values unless strict mode is enabled internally. Safe for concurrent use, Get internally uses a lazily-initialized, concurrency-safe plan cache based on sync.Map, which avoids global locks for most read operations.

Example:

// Given a *sql.DB (or *sql.Tx, *sql.Conn) in variable `db`:
type User struct {
    ID    int64  `db:"id"`
    Email string `db:"email"`
}

ctx := context.Background()
u, err := xsql.Get[User](ctx, db, `SELECT id, email FROM users WHERE id = $1`, 42)
if err != nil {
    if errors.Is(err, sql.ErrNoRows) {
        // handle not found
    } else {
        // handle other errors
    }
}
// use u

func NamedExec added in v0.1.1

func NamedExec(ctx context.Context, e Execer, ph Placeholder, query string, params ...any) (sql.Result, error)

NamedExec is a convenience for Exec with named or positional arguments. It calls Rebind, then ExecContext on your Execer (e.g., *sql.DB, *sql.Tx, *sql.Conn).

Example:

_, err := xsql.NamedExec(ctx, db, xsql.PlaceholderAtP,
    `UPDATE items SET price=:p WHERE id IN (:ids)`,
    map[string]any{"p": 100, "ids": []int{7,8,9}},
)

func NamedQuery added in v0.1.1

func NamedQuery[T any](ctx context.Context, q Querier, ph Placeholder, query string, params ...any) ([]T, error)

NamedQuery runs a query with named or positional arguments and scans results using your existing Query[T]. Use this when you want []T back with minimal ceremony.

Example:

type User struct { ID int64 `db:"id"`; Email string `db:"email"` }
rows, err := xsql.NamedQuery[User](ctx, db, xsql.PlaceholderDollar,
    `SELECT id, email FROM users WHERE status=:s`,
    map[string]any{"s":"active"},
)

func Query

func Query[T any](ctx context.Context, q Querier, query string, args ...any) (out []T, err error)

Query executes the SQL query and scans all result rows into a slice of T.

T may be a struct (supports `db` tags and ,inline), a primitive, or any type implementing sql.Scanner. Column mapping prefers `db:"name"` tags; otherwise it matches case-insensitive field names.

Extra columns are ignored and missing columns set zero values unless strict mode is enabled internally. Safe for concurrent use, Query internally uses a lazily-initialized, concurrency-safe plan cache based on sync.Map, which avoids global locks for most read operations.

Example:

// Given a *sql.DB (or *sql.Tx, *sql.Conn) in variable `db`:
type User struct {
    ID    int64  `db:"id"`
    Email string `db:"email"`
}

ctx := context.Background()
users, err := xsql.Query[User](ctx, db, `SELECT id, email FROM users ORDER BY id`)
if err != nil {
    log.Fatal(err)
}
for _, u := range users {
    fmt.Println(u.ID, u.Email)
}

func Rebind added in v0.1.1

func Rebind(query string, ph Placeholder, params ...any) (string, []any, error)

Rebind resolves :named parameters (if applicable) and rewrites placeholders.

Usage:

  • Named style (exactly one struct or map[string]any): sql, args, err := xsql.Rebind( `SELECT * FROM users WHERE status=:status AND id IN (:ids)`, xsql.PlaceholderDollar, map[string]any{"status":"active", "ids":[]int{1,2,3}}, ) // sql => SELECT * FROM users WHERE status=$1 AND id IN ($2,$3,$4) // args => ["active", 1, 2, 3]

    Notes: slices/arrays expand; []byte is scalar; empty slice/array becomes NULL (so `IN (NULL)` matches no rows on most engines).

  • Positional passthrough (any other params shape): // params are already positional; only placeholder rewriting is applied sql, args, _ := xsql.Rebind(`a=? AND b=?`, xsql.PlaceholderColonNum, "A", 10)

Rules of thumb:

  • Pass exactly one struct or map to use :named binding.
  • Pass multiple values (or a non-struct/map) to use positional args.
  • SQL scanning safely skips quoted strings, comments, and PostgreSQL $tag$…$tag$ blocks.

Types

type Beginner

type Beginner interface {
	BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
}

Beginner is implemented by *sql.DB and *sql.Conn. It starts a transaction.

type Execer

type Execer interface {
	ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
}

Execer is implemented by *sql.DB, *sql.Tx, *sql.Conn, and any wrapper that can execute a statement that does not return rows.

type Mapper

type Mapper struct {
	Strict bool // reserved: future strict mode (not enforced here)
	// contains filtered or unexported fields
}

Mapper owns caches. Use the package-level lazy getter (getMapper) or create your own in tests.

func NewMapper

func NewMapper() *Mapper

type Placeholder added in v0.1.1

type Placeholder int

Placeholder selects the positional parameter style for a target database.

Common choices:

  • PlaceholderQuestion → "?" (MySQL, SQLite, DuckDB, ClickHouse)
  • PlaceholderDollar → "$1, $2, …" (PostgreSQL)
  • PlaceholderAtP → "@p1, @p2…" (SQL Server)
  • PlaceholderColonNum → ":1, :2, …" (Oracle)
const (
	PlaceholderQuestion Placeholder = iota
	PlaceholderDollar
	PlaceholderAtP
	PlaceholderColonNum
)

func PlaceholderFor added in v0.1.1

func PlaceholderFor(driverName string) Placeholder

PlaceholderFor picks a Placeholder based on a driver name string. This is a convenience for one-off calls; you can also choose the enum directly.

Examples:

ph := xsql.PlaceholderFor("pgx")       // => PlaceholderDollar
ph := xsql.PlaceholderFor("sqlserver") // => PlaceholderAtP
ph := xsql.PlaceholderFor("mysql")     // => PlaceholderQuestion

type Querier

type Querier interface {
	QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
}

Querier is implemented by *sql.DB, *sql.Tx, *sql.Conn, and any wrapper that can execute a query returning rows.

Jump to

Keyboard shortcuts

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