https://github.com/go-sqlex/sqlex
A drop-in modernization of jmoiron/sqlx that fixes lexer bugs, automates IN expansion, and adds pluggable hooks — built for Go 1.21+.
https://github.com/go-sqlex/sqlex
databases mysql postgresql sql sqlserver sqlx
Last synced: about 3 hours ago
JSON representation
A drop-in modernization of jmoiron/sqlx that fixes lexer bugs, automates IN expansion, and adds pluggable hooks — built for Go 1.21+.
- Host: GitHub
- URL: https://github.com/go-sqlex/sqlex
- Owner: go-sqlex
- License: mit
- Created: 2026-06-16T12:59:26.000Z (18 days ago)
- Default Branch: main
- Last Pushed: 2026-06-23T13:10:55.000Z (11 days ago)
- Last Synced: 2026-06-23T15:13:23.613Z (11 days ago)
- Topics: databases, mysql, postgresql, sql, sqlserver, sqlx
- Language: Go
- Homepage:
- Size: 402 KB
- Stars: 20
- Watchers: 1
- Forks: 0
- Open Issues: 1
-
Metadata Files:
- Readme: README.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
Awesome Lists containing this project
- fucking-awesome-go - sqlex - Drop-in modernization of jmoiron/sqlx with fixed SQL lexer bugs, automatic IN-clause expansion, pluggable hooks, and unified DB/Tx/Conn interfaces. (Utilities / Utility/Miscellaneous)
- awesome-go - sqlex - Drop-in modernization of jmoiron/sqlx with fixed SQL lexer bugs, automatic IN-clause expansion, pluggable hooks, and unified DB/Tx/Conn interfaces. (Utilities / Utility/Miscellaneous)
README
**English** | [中文](README_zh.md)
[](https://github.com/go-sqlex/sqlex/actions/workflows/ci.yml)
[](https://goreportcard.com/report/github.com/go-sqlex/sqlex)
[](https://pkg.go.dev/github.com/go-sqlex/sqlex)
[](https://opensource.org/licenses/MIT)
[](https://github.com/avelino/awesome-go)
# sqlex
> A **drop-in replacement** for [jmoiron/sqlx](https://github.com/jmoiron/sqlx) — 100% API-compatible, with added Hook aspects, generic JSON types, bug fixes, and more.
**sqlex is fully API-compatible with sqlx.** All sqlx methods (`Get`, `Select`, `Exec`, `NamedQuery`, `Preparex`, etc.) work identically. Migrating takes 30 seconds — just change the import path. New features are purely additive and optional.
```diff
- import "github.com/jmoiron/sqlx"
+ import "github.com/go-sqlex/sqlex"
```
What you get for free after migrating:
- 🚀 **Auto-Rebind** — write `?` everywhere, works on PostgreSQL (`$1`), MySQL (`?`), SQLite (`?`), SQL Server (`@p1`). No more manual `db.Rebind()`. Including `Preparex`.
- 🐛 **SQL parsing fixes** — colons in strings, `::` type casts, `?` in comments are correctly handled. Silent bugs from sqlx are gone.
- 🎯 **Unified interfaces** — `Ext` / `ExtContext` / `NamedExt` / `BindExt` / `Preparer` / `PreparerContext` with compile-time checks. Write `func f(ext NamedExt)` and pass DB, Tx, or Conn.
- 🔀 **Auto IN expansion** — slices in `IN (?)` detected and expanded automatically on all methods.
- 🪝 **Hook system** — pluggable SQL interceptors for logging, tracing, metrics (onion model).
- 📦 **JSONValue[T]** — generic JSON column type with auto serialize/deserialize.
- 🛡️ **StrictMode** — lenient by default (matching sqlx `Unsafe()`), optionally strict for debugging.
- 🛠️ **20+ bug fixes** — data corruption, panics, silent data loss, and cross-database failures from jmoiron/sqlx, all fixed. See [Critical Bug Fixes](#critical-bug-fixes-from-sqlx).
→ [Migration Guide](#migration-from-jmoironsqlx)
## Installation
```bash
go get github.com/go-sqlex/sqlex
```
Requires Go 1.21 or later.
## Migration from jmoiron/sqlx
**30 seconds, 3 steps:**
**1. Change import path:**
```go
// old
import "github.com/jmoiron/sqlx"
// new
import "github.com/go-sqlex/sqlex"
```
**2. Change package references:**
```go
// old
db, err := sqlx.Connect("postgres", dsn)
// new
db, err := sqlex.Connect("postgres", dsn)
```
**3. Update go.mod:**
```bash
go get github.com/go-sqlex/sqlex
```
**Done.** All your existing sqlx code works without changes.
> **Note on StrictMode**: sqlex defaults to lenient mode (`strict=false`), matching sqlx's `db.Unsafe()` behavior (silently ignore extra columns). You kept `db.Unsafe()` in your codebase? No changes needed — sqlex inherits the same lenient default. To enable strict struct-field matching for debugging, call `db.SetStrict(true)`.
### Gradual adoption
New features are optional — adopt at your own pace:
| Step | Action | Time |
|------|--------|------|
| 1 | Replace import path | 30s |
| 2 | Switch transactions to `CloseWithErr` pattern | per-use |
| 3 | Use `NamedGet`/`NamedSelect` instead of `NamedQuery` + manual scan | per-use |
| 4 | Register custom Hooks (logging, tracing, metrics) | as needed |
## Quick Start
```go
package main
import (
"fmt"
"log"
"github.com/go-sqlex/sqlex"
_ "github.com/mattn/go-sqlite3"
)
type User struct {
ID int `db:"id"`
Name string `db:"name"`
Email string `db:"email"`
}
func main() {
// Connect to database
db, err := sqlex.Connect("sqlite3", ":memory:")
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Create table
db.MustExec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT, email TEXT)`)
db.MustExec(`INSERT INTO users (name, email) VALUES ('Alice', 'alice@example.com')`)
db.MustExec(`INSERT INTO users (name, email) VALUES ('Bob', 'bob@example.com')`)
// Query single row
var user User
err = db.Get(&user, "SELECT * FROM users WHERE id = ?", 1)
fmt.Printf("User: %+v\n", user)
// Query multiple rows
var users []User
err = db.Select(&users, "SELECT * FROM users")
fmt.Printf("Users: %+v\n", users)
}
```
## New Features
sqlex preserves all sqlx APIs and adds the following capabilities:
| Feature | Description |
|---------|-------------|
| **Hook aspects** | `AddHook` — pluggable SQL execution interceptors (onion model) |
| **JSONValue[T]** | `types.JSONValue[T]` — generic JSON column type |
| **NamedGet/NamedSelect** | Named parameter convenience methods on DB/Tx (built-in IN expansion) |
| **CloseWithErr** | Auto Commit/Rollback based on error |
| **Unified interfaces** | `Ext` / `ExtContext` / `NamedExt` / `BindExt` / `Preparer` / `PreparerContext` — DB, Tx, and Conn share identical method signatures with compile-time checks |
| **Auto IN expansion** | All methods auto-detect slice args and expand IN clauses |
| **Auto Rebind** | All query methods auto-convert `?` to target database placeholders |
| **StrictMode** | Optional strict struct-field matching for debugging (off by default) |
| **Cross-database out of the box** | Write SQL with `?` everywhere — works on PostgreSQL, MySQL, SQLite, SQL Server |
## Critical Bug Fixes from sqlx
sqlex fixes **20+ known bugs** from jmoiron/sqlx — including data corruption, panics, and silent failures:
| Bug | Impact | sqlx Issue |
|-----|--------|------------|
| `Select` + `sql.RawBytes` | **Data corruption** — driver buffer reuse across rows silently overwrites scanned data | [#931](https://github.com/jmoiron/sqlx/issues/931) |
| `In` panic on nil `driver.Valuer` | **Panic** — nil pointer Valuers crash instead of returning NULL | [#952](https://github.com/jmoiron/sqlx/issues/952) |
| `fixBound` VALUES drops rows | **Silent data loss** — batch INSERT/UPDATE with `VALUES (...)` silently skips rows | [#898](https://github.com/jmoiron/sqlx/issues/898) |
| `NextResultSet` cache stale | **Data corruption** — multi-result-set scans with different columns produce wrong data | [#857](https://github.com/jmoiron/sqlx/issues/857) |
| `Rebind` replaces `?` in strings | **Wrong SQL** — `?` inside string literals, comments, identifiers replaced with bind vars | — |
| Named query colons in strings | **Wrong SQL** — IPv6 addresses, URLs, time formats misidentified as named parameters | [#947](https://github.com/jmoiron/sqlx/issues/947) |
| `ConnectContext` connection leak | **Resource leak** — connection not closed on Ping failure | — |
| PostgreSQL `::` type cast | **Wrong SQL** — `::int` misidentified as named parameter | [#428](https://github.com/jmoiron/sqlx/issues/428) |
| Named queries fail on PostgreSQL | **Cross-DB broken** — Named methods don't Rebind, fail on `$N` databases | — |
| `IN(?)` not expanded on `Exec`/`Queryx` | **Runtime error** — slice args not expanded on some methods | — |
| Unified SQL lexer | **Root cause** — original has duplicated, inconsistent skip logic in `Rebind`/`In`/`compileNamedQuery`. sqlex uses shared `scanSkipSegment` | — |
> Additional fixes: escaped `??`/`\?` in Rebind, `db:"-"` skip in Named, missing field strict mode checks, `NamedStmt.Exec` return type, named parameter fallback tolerance ([#892](https://github.com/jmoiron/sqlx/issues/892)), and more.
## Usage Examples
### Basic CRUD
```go
// Use ? placeholders universally — the framework auto-converts
// to target database bindvar format ($N, :argN, @pN)
// Insert
result, err := db.Exec("INSERT INTO users (name, email) VALUES (?, ?)", "Alice", "alice@example.com")
// Query single row → struct
var user User
err = db.Get(&user, "SELECT * FROM users WHERE id = ?", 1)
// Query multiple rows → slice
var users []User
err = db.Select(&users, "SELECT * FROM users WHERE age > ?", 18)
// Update
_, err = db.Exec("UPDATE users SET name = ? WHERE id = ?", "Alice Updated", 1)
// Delete
_, err = db.Exec("DELETE FROM users WHERE id = ?", 1)
```
### Named Parameter Queries
```go
// Using struct as parameter
user := User{Name: "Alice", Email: "alice@example.com"}
_, err = db.NamedExec(`INSERT INTO users (name, email) VALUES (:name, :email)`, user)
// Using map as parameter
params := map[string]any{"name": "Alice"}
// NamedGet — query single row
var result User
err = db.NamedGet(&result, `SELECT * FROM users WHERE name = :name`, params)
// NamedSelect — query multiple rows
var results []User
err = db.NamedSelect(&results, `SELECT * FROM users WHERE name = :name`, params)
// NamedQuery — return *Rows for manual iteration
rows, err := db.NamedQuery(`SELECT * FROM users WHERE name = :name`, params)
defer rows.Close()
for rows.Next() {
var u User
rows.StructScan(&u)
}
```
### IN Queries
```go
ids := []int{1, 2, 3, 4, 5}
// Positional: auto-detects slice and expands IN
var users []User
err = db.Select(&users, "SELECT * FROM users WHERE id IN (?)", ids)
// Named: built-in IN expansion
err = db.NamedSelect(&users,
`SELECT * FROM users WHERE id IN (:ids) AND status = :status`,
map[string]any{"ids": ids, "status": "active"})
```
> Note: `sqlex.In()` / `sqlex.Named()` are legacy top-level functions; the framework calls them automatically. Use the high-level methods above which include Rebind/Hook/StrictMode.
#### Slice argument handling (IN list context recognition)
sqlex uses **IN list context recognition** to decide whether to auto-expand slices: slices are only expanded when `?` is in the `IN (?)` context. Two conditions must be met:
1. **Strict `(?)` form**: only one `?` and optional ASCII whitespace (space/Tab/newline/CR) between `(` and `)`
2. **The complete identifier immediately before `(` is the `IN` keyword** (case-insensitive); `NOT IN (?)` also matches
Other `(?)` contexts (`ANY(?)` / `ALL(?)` / `VALUES (?)` / `func(?)` / scalar subquery `= (?)` etc.) are treated as single values — **no need for `AsValue` escape hatch**.
**Detection rules**:
| SQL pattern | Argument | Behavior | Notes |
|---|---|---|---|
| `WHERE id IN (?)` | `[]int{1,2,3}` | Expand | Preceded by IN |
| `WHERE id NOT IN (?)` | `[]int{1,2,3}` | Expand | NOT IN still matches |
| `WHERE id IN (\n ?\n)` | `[]int{1,2,3}` | Expand | Multi-line IN (?) |
| `WHERE x = ANY(?)` | `[]int{1,2,3}` | No expand | Preceded by ANY, not IN |
| `INSERT ... VALUES (?)` | `[]int{1,2,3}` | No expand | Preceded by VALUES, not IN |
| `SELECT func(?)` | `[]int{1,2,3}` | No expand | Preceded by function name |
| `WHERE x = (?)` | `[]int{1,2,3}` | No expand | Preceded by `=`, not IN |
| `WHERE col_in (?)` | `[]int{1,2,3}` | No expand | Full token is `col_in`, not IN |
| `IN (?, ?, ?)` | `1, 2, 3` scalars | No expand | Multiple `?` → user already expanded |
| `WHERE x = ?` | `[]int{1,2,3}` | No expand | `?` not in `(?)` form |
**Escape hatch APIs**:
```go
import "github.com/go-sqlex/sqlex"
// ① sqlex.AsValue(v) — force no expansion (even in IN (?) context)
db.Select(&rows, `SELECT * FROM t WHERE id IN (?)`,
sqlex.AsValue([]int{1, 2, 3})) // entire slice as single value to driver
// ② sqlex.AsList(slice) — force expansion (even outside IN (?) context)
db.Select(&rows, `SELECT * FROM t WHERE id = ANY(?)`,
sqlex.AsList([]int{1, 2, 3})) // force expand to ?, ?, ?
// ③ Other native approaches still work
db.Exec(`INSERT INTO users (tags) VALUES (?)`, pq.Array([]int{1, 2, 3})) // driver.Valuer
data, _ := json.Marshal([]int{1, 2, 3})
db.Exec(`INSERT INTO t (json_col) VALUES (?)`, data) // []byte is a standard driver type
```
> Note: `ANY(?)` / `VALUES (?)` etc. now default to **no expansion** — just pass the slice directly or wrap with `pq.Array`. No `AsValue` needed.
**Priority** (high to low):
1. `sqlex.AsValue(v)` / `sqlex.AsList(s)` — explicit declaration, highest priority
2. `driver.Valuer` interface (including `pq.Array`) — treated as single value
3. `[]byte` — standard driver type, treated as single value
4. `IN (?)` context match + slice — auto-expand
5. Other positions + slice — no expansion, passed as single value (driver will likely error)
**Known edge case**: A comment between `IN` and `(` (e.g. `IN /* c */ (?)`) prevents IN recognition and won't expand. This pattern is extremely rare; use `sqlex.AsList` as a fallback if needed.
**Empty slice handling** (context-sensitive):
| Scenario | Behavior |
|---|---|
| `IN (?)` context + `[]int{}` | Error `sqlex: empty slice cannot be expanded into IN ()` (IN () is invalid SQL) |
| Non-IN context (`WHERE x = ?` / `VALUES (?)`) + `[]int{}` | OK, entire slice passed to driver |
| `sqlex.AsValue([]int{})` | OK (already single-value semantics) |
| `sqlex.AsList([]int{})` | Error `sqlex.AsList: empty slice` (expanding to nothing is meaningless) |
#### Named parameter name rules & lexical context
Named parameter `:name` rule: `[A-Za-z_][A-Za-z0-9_.]*` (letter/underscore start, digits/underscore/dot allowed; dots for nested fields like `:user.name`).
| Pattern | Recognized? | Notes |
|---|---|---|
| `:name` / `:user_id` / `:arg1` | ✅ | Standard named parameter |
| `:user.name` | ✅ | Dot-nested field |
| `:123` / `:1` | ❌ preserved as literal | Digit-start rejected (avoids Oracle `:N` / SQLite `?NNN` conflicts) |
| `:名字` (Unicode) | ❌ preserved as literal | ASCII-only param names (matches `db` tag / map key convention) |
| `::int` (PG type cast) | ❌ preserved as literal | `::` recognized as type cast, not parameter |
| `:=` (assignment) | ❌ preserved as literal | Output as-is |
**Lexical scanning**: `:name` / `?` inside these regions are skipped (shared `lexer.go` scanner):
- Single-quoted strings `'...'` (with `''` escapes), double-quoted identifiers `"..."`, backtick identifiers `` `...` ``
- Dollar-quoted strings `$$...$$` / `$tag$...$tag$`
- Line comments `-- ...`, block comments `/* ... */`
> If edge cases trigger a misparse, `compileNamedQuery` preserves unmatched `:name` as literals (same behavior as GORM's `@name` handling), allowing the original SQL to still execute correctly.
### Prepared Statements
```go
// Preparex auto-Rebinds — use ? uniformly across all databases
stmt, err := db.Preparex("SELECT * FROM users WHERE name = ?")
defer stmt.Close() // Stmt must be Closed to avoid resource leaks
var user User
err = stmt.Get(&user, "Alice")
// Also works within transactions
tx, _ := db.Beginx()
stmt, err = tx.Preparex("SELECT * FROM users WHERE age > ?")
defer stmt.Close()
var users []User
err = stmt.Select(&users, 18)
// PreparexContext — context-aware version
ctx := context.Background()
stmt, err = db.PreparexContext(ctx, "SELECT * FROM users WHERE name = ?")
defer stmt.Close()
var user2 User
err = stmt.Get(&user2, "Alice")
// PrepareNamed — named prepared statement (use :name uniformly, framework handles binding)
nstmt, err := db.PrepareNamed("SELECT * FROM users WHERE name = :name")
defer nstmt.Close()
err = nstmt.Get(&user, map[string]any{"name": "Alice"})
// PreparerContext — write generic prepare functions accepting DB/Tx/Conn
func prepareQuery(p sqlex.PreparerContext) (*sqlex.Stmt, error) {
return sqlex.PreparexContext(context.Background(), p, "SELECT * FROM users WHERE name = ?")
}
```
> **Unified experience**: `Preparex`/`PreparexContext` auto-Rebind like all other query methods, using `?` uniformly.
> `PrepareNamed`/`PrepareNamedContext` use named parameters `:name`; the framework handles binding internally.
>
> **Note**: Prepared statements (`Stmt`/`NamedStmt`) **do not support IN slice expansion**. The number of placeholders is fixed at `Prepare` time and cannot be dynamically expanded at execution time. For IN queries, use non-prepared methods like `db.Select` / `db.NamedSelect`.
>
> **Resource management**: `Stmt`/`NamedStmt` hold an underlying `sql.Stmt` and **must be Closed** after use. Forgetting `Close()` causes prepared statement resource leaks in the connection pool that are only reclaimed on `DB.Close()`. Recommended pattern: `defer stmt.Close()`.
>
> **Hook coverage**: `Stmt`/`NamedStmt` `Exec`/`Query` methods also fire Hooks. Hooks are auto-propagated from the parent DB/Tx/Conn to the Stmt.
### Transaction Management
```go
// Recommended pattern: CloseWithErr auto-management
func createUserWithProfile(db *sqlex.DB, user User, profile Profile) (err error) {
tx, err := db.Beginx()
if err != nil {
return err
}
defer func() { tx.CloseWithErr(err) }() // auto Commit or Rollback
_, err = tx.NamedExec(`INSERT INTO users (name) VALUES (:name)`, user)
if err != nil {
return err // CloseWithErr detects err != nil, auto Rollback
}
_, err = tx.NamedExec(`INSERT INTO profiles (user_name, bio) VALUES (:user_name, :bio)`, profile)
return nil // CloseWithErr detects err == nil, auto Commit
}
```
### JSONValue[T]
```go
import "github.com/go-sqlex/sqlex/types"
type Article struct {
ID int `db:"id"`
Title string `db:"title"`
Metadata types.JSONValue[ArticleMeta] `db:"metadata"`
}
type ArticleMeta struct {
Tags []string `json:"tags"`
ViewCount int `json:"view_count"`
}
// Write — auto-serializes to JSON
article := Article{
Title: "Hello World",
Metadata: types.NewJSONValue(ArticleMeta{
Tags: []string{"go", "sql"},
ViewCount: 0,
}),
}
db.NamedExec(`INSERT INTO articles (title, metadata) VALUES (:title, :metadata)`, article)
// Read — auto-deserializes
var a Article
db.Get(&a, "SELECT * FROM articles WHERE id = ?", 1)
if a.Metadata.Valid {
fmt.Println(a.Metadata.Val.Tags) // ["go", "sql"]
}
// Val is zero value when !Valid (guaranteed by Scan/zero-value init)
// Marshal/Unmarshal (implements json.Marshaler/Unmarshaler)
data, _ := json.Marshal(a.Metadata)
json.Unmarshal(data, &a.Metadata)
```
### Hook Aspects
```go
// Custom Hook — e.g., OpenTelemetry tracing
type TracingHook struct{}
func (h *TracingHook) BeforeQuery(ctx context.Context, event *sqlex.QueryEvent) context.Context {
ctx, span := tracer.Start(ctx, "sql."+event.OperationType)
span.SetAttributes(attribute.String("db.statement", event.Query))
return ctx
}
func (h *TracingHook) AfterQuery(ctx context.Context, event *sqlex.QueryEvent) {
span := trace.SpanFromContext(ctx)
if event.Error != nil {
span.RecordError(event.Error)
}
span.End()
}
db.AddHook(&TracingHook{})
// Hook covers the full lifecycle: query/exec/begin/commit/rollback
// Transaction operations (Begin/Commit/Rollback) also fire Hooks, regardless of success or failure
tx, _ := db.Beginx() // → Hook(OpBegin)
// tx queries also fire Hooks
tx.CloseWithErr(nil) // → Hook(OpCommit) or Hook(OpRollback)
```
#### QueryEvent Fields
```go
type QueryEvent struct {
Query string // SQL statement
Args []any // execution parameters
Duration time.Duration // total elapsed time (includes Hook chain overhead)
Error error // execution error (available in AfterQuery phase)
OperationType OpType // operation type: OpQuery/OpExec/OpBegin/OpCommit/OpRollback
RowsAffected int64 // rows affected (only for exec operations)
LastInsertID int64 // last inserted auto-increment ID (only for exec)
}
```
#### Conditional Hook Filtering
sqlex does not ship a built-in filter; use the decorator pattern to compose your own:
```go
// Only fire on slow queries
func SlowOnly(h sqlex.Hook, threshold time.Duration) sqlex.Hook {
return &slowHook{hook: h, threshold: threshold}
}
type slowHook struct {
hook sqlex.Hook
threshold time.Duration
}
func (h *slowHook) BeforeQuery(ctx context.Context, e *sqlex.QueryEvent) context.Context {
return h.hook.BeforeQuery(ctx, e)
}
func (h *slowHook) AfterQuery(ctx context.Context, e *sqlex.QueryEvent) {
if e.Duration >= h.threshold {
h.hook.AfterQuery(ctx, e)
}
}
// Only fire on errors
func OnError(h sqlex.Hook) sqlex.Hook { /* BeforeQuery passthrough, AfterQuery checks e.Error != nil */ }
db.AddHook(SlowOnly(&AlertHook{}, 500*time.Millisecond))
```
### StrictMode
```go
// Default: lenient mode (strict=false), silently ignores extra columns
db, _ := sqlex.Connect("postgres", dsn)
fmt.Println(db.IsStrict()) // false
// Enable strict mode: returns detailed error on field mismatch
db.SetStrict(true)
err = db.Select(&users, "SELECT * FROM users")
// err: missing destination name email (index 2), age (index 3) in UserPartial
// strict auto-propagates to Tx/Conn
tx, _ := db.Beginx() // inherits DB's strict setting
conn, _ := db.Connx(ctx) // inherits DB's strict setting
```
### Unified Interfaces
DB, Tx, and Conn implement a common set of interfaces (enforced by compile-time assertions). Interfaces are small and orthogonal — compose as needed, no need for a "god interface":
| Interface | Methods | Purpose |
|-----------|---------|---------|
| `Ext` | `Exec`, `Queryx`, `QueryRowx` | Basic query/execution |
| `ExtContext` | `ExecContext`, `QueryxContext`, `QueryRowxContext` | Context-aware variants |
| `NamedExt` | `NamedExec`, `NamedQuery`, `NamedGet`, `NamedSelect` | Named parameter queries |
| `BindExt` | `BindNamed`, `Get`, `Select`, `Rebind`, `DriverName` | Positional parameter queries |
| `Preparer` | `Preparex`, `PrepareNamed` | Prepared statement creation |
| `PreparerContext` | `PreparexContext`, `PrepareNamedContext` | Context-aware preparation |
```go
// Accept DB, Tx, or Conn via NamedExt
func getUserByName(ext sqlex.NamedExt, name string) (*User, error) {
var user User
err := ext.NamedGet(&user, `SELECT * FROM users WHERE name = :name`,
map[string]any{"name": name})
return &user, err
}
user, err := getUserByName(db, "Alice")
tx, _ := db.Beginx()
user, err = getUserByName(tx, "Bob")
conn, _ := db.Connx(ctx)
user, err = getUserByName(conn, "Charlie")
```
## Comparison with jmoiron/sqlx
| Feature | jmoiron/sqlx | sqlex |
|---------|-------------|-------|
| Go version | 1.10+ | 1.21+ |
| Struct scanning | ✅ | ✅ |
| Named queries | ✅ | ✅ |
| Bindvar conversion | ✅ | ✅ (enhanced: supports `\?` and `??` escape, skips string literals, identifiers, comments, PG dollar quoting) |
| IN clause expansion | ✅ `In()` | ✅ Auto-IN across all DB/Tx/Conn × Exec/Query/Select/Get/Named* paths |
| Cross-database placeholders | ❌ Manual Rebind | ✅ All methods auto-Rebind, use `?` uniformly (including `Preparex`) |
| Field matching | `unsafe` (default strict) | `StrictMode` (default lenient, more intuitive) |
| Hook aspects | ❌ | ✅ `AddHook` pluggable SQL interceptors |
| JSONValue[T] | ❌ | ✅ `types.JSONValue[T]` |
| NamedGet/NamedSelect | ❌ | ✅ DB/Tx convenience methods |
| CloseWithErr | ❌ | ✅ Auto transaction management |
| Unified interfaces | ❌ DB/Tx methods overlap but no shared interface | ✅ `Ext` / `ExtContext` / `NamedExt` / `BindExt` / `Preparer` / `PreparerContext` — DB/Tx/Conn unified with compile-time checks |
| Unicode named parameters | ⚠️ Unreliable | ❌ Not supported (ASCII only; Unicode elsewhere is safe) |
| PostgreSQL `::` | ❌ Misidentified | ✅ Correctly handled |
| Named query string literals | ❌ Colons misidentified | ✅ Skips colons in strings/comments |
| Named parameter fallback | ❌ Errors on misidentification | ✅ Missing params preserved as `:name` literals |
## Testing
```bash
# 1) Main package unit tests (no DB dependency, fastest)
go test -count=1 -timeout=120s .
# 2) cross_db MySQL only (isolate PG/SQLite for debugging)
SQLX_POSTGRES_DSN=skip SQLX_SQLITE_DSN=skip \
go test -count=1 -timeout=300s ./tests/cross_db/
# 3) cross_db PostgreSQL only
SQLX_MYSQL_DSN=skip SQLX_SQLITE_DSN=skip \
go test -count=1 -timeout=300s ./tests/cross_db/
# 4) cross_db SQLite only (no external dependencies)
SQLX_MYSQL_DSN=skip SQLX_POSTGRES_DSN=skip \
go test -count=1 -timeout=120s ./tests/cross_db/
# 5) cross_db all drivers (CI recommended)
go test -count=1 -timeout=300s ./tests/cross_db/
# 6) integration tests
go test -count=1 -timeout=120s ./tests/integration/
# 7) pg-specific tests (PostgreSQL unique features)
go test -count=1 -timeout=120s ./tests/pg/
# 8) types / reflectx subpackages
go test -count=1 -timeout=60s ./types/ ./reflectx/
```
**Why run per-driver**: A single `go test ./...` runs all drivers at once, making it hard to isolate driver-specific failures. Per-driver runs enable quick bisection.
**DSN configuration**: Write complete DSNs in `.env.test` using the `SQLX_*_DSN` namespace. Set to `skip` to skip that driver. SQLite defaults to `:memory:`.
| Env var | Value | Behavior |
|---------|-------|----------|
| `SQLX_MYSQL_DSN` | Full DSN | Uses this DSN |
| `SQLX_MYSQL_DSN` | `skip` or empty | Skips MySQL tests |
| `SQLX_POSTGRES_DSN` | Same | |
| `SQLX_SQLITE_DSN` | Same (default `:memory:`) | |
### Single test debugging
```bash
# Run a single test function
go test -count=1 -timeout=60s -run "TestNextPlaceholder" -v .
# Run a single sub-test
go test -count=1 -timeout=60s -run "TestNextPlaceholder/multiline_IN" -v .
# Race detection
go test -count=1 -race -timeout=180s .
# Coverage
go test -count=1 -cover -coverprofile=cover.out -timeout=120s .
go tool cover -html=cover.out
# Benchmarks
go test -bench=. -benchmem -run=NoSuch -benchtime=2s .
```
## Performance
- **Prepared statements**: `Preparex`/`PreparexContext` auto-Rebinds, unifying `?` placeholders regardless of database driver
- **Zero-overhead principle**: No Hook overhead when unregistered; auto-Rebind is a no-op for `QUESTION`-type drivers (MySQL/SQLite)
- **Auto Rebind**: All query methods always perform Rebind. For MySQL/SQLite (already `?`), Rebind returns the original string; for PostgreSQL etc., if the query has no `?` (e.g., already `$1`), a fast path returns immediately. Double Rebind is safe and zero-cost
- **Slice arg detection**: `needsInRewrite` uses reflection type checks (nanosecond-level for non-slice args)
- **Mapper caching**: Field mapping results cached after first use
- **Hook execution**: Hooks run synchronously; use lightweight operations or async for heavy ones
### About NameMapper
`NameMapper` is a global variable that controls field-name-to-column-name mapping, defaulting to `strings.ToLower`.
> **Concurrency warning**: `NameMapper` reads/writes are not concurrency-safe. Set it only in `init()`. Modifying at runtime may cause data races. For runtime per-instance mapping, use `DB.MapperFunc()`.
## License
[MIT License](LICENSE)
Based on [jmoiron/sqlx](https://github.com/jmoiron/sqlx) — thanks to Jason Moiron for the excellent work.
Please read our [Contributing Guide](CONTRIBUTING.md) before submitting a pull request.
See [CHANGELOG.md](CHANGELOG.md) for version history.