feat(sqlite): initial stable release v0.9.0
Pure-Go CGO-free SQLite client with launcher lifecycle, write-mutex serialisation, health check, unit-of-work via context injection, and structured error mapping. What's included: - Executor / Tx / Client / Component interfaces using database/sql native types - Tx.Commit() / Tx.Rollback() without ctx, matching the honest database/sql contract - New(logger, cfg) constructor; database opened in OnInit - Config struct with env-tag support; default Pragmas: WAL + 5s busy timeout + FK enforcement - PRAGMA foreign_keys = ON enforced explicitly in OnInit - writeMu sync.Mutex acquired by UnitOfWork.Do to serialise writes and prevent SQLITE_BUSY - UnitOfWork via context injection; GetExecutor(ctx) returns active Tx or *sql.DB - HandleError mapping SQLite extended error codes to xerrors codes (unique/primary-key → AlreadyExists, foreign-key → InvalidInput, ErrNoRows → NotFound) - health.Checkable at LevelCritical; pure-Go modernc.org/sqlite driver (CGO_ENABLED=0 compatible) Tested-via: todo-api POC integration Reviewed-against: docs/adr/
This commit is contained in:
26
.devcontainer/devcontainer.json
Normal file
26
.devcontainer/devcontainer.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "Go",
|
||||
"image": "mcr.microsoft.com/devcontainers/go:2-1.25-trixie",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers-extra/features/claude-code:1": {}
|
||||
},
|
||||
"forwardPorts": [],
|
||||
"postCreateCommand": "go version",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"settings": {
|
||||
"files.autoSave": "afterDelay",
|
||||
"files.autoSaveDelay": 1000,
|
||||
"explorer.compactFolders": false,
|
||||
"explorer.showEmptyFolders": true
|
||||
},
|
||||
"extensions": [
|
||||
"golang.go",
|
||||
"eamodio.golang-postfix-completion",
|
||||
"quicktype.quicktype",
|
||||
"usernamehw.errorlens"
|
||||
]
|
||||
}
|
||||
},
|
||||
"remoteUser": "vscode"
|
||||
}
|
||||
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
# Binaries
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# Test binary, built with go test -c
|
||||
*.test
|
||||
|
||||
# Output of go build
|
||||
*.out
|
||||
|
||||
# Dependency directory
|
||||
vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
go.work.sum
|
||||
|
||||
# Environment files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
# Editor / IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# VCS files
|
||||
COMMIT.md
|
||||
RELEASE.md
|
||||
33
CHANGELOG.md
Normal file
33
CHANGELOG.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this module will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this module adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [0.9.0] - 2026-03-18
|
||||
|
||||
### Added
|
||||
|
||||
- `Executor` interface: `ExecContext`, `QueryContext`, `QueryRowContext` using `database/sql` types (`sql.Result`, `*sql.Rows`, `*sql.Row`).
|
||||
- `Tx` interface: embeds `Executor` and adds `Commit() error` and `Rollback() error` (no context argument, matching `database/sql` semantics).
|
||||
- `Client` interface: `GetExecutor(ctx context.Context) Executor`, `Begin(ctx context.Context) (Tx, error)`, `Ping(ctx context.Context) error`, `HandleError(err error) error`.
|
||||
- `Component` interface: composes `launcher.Component`, `health.Checkable`, and `Client`.
|
||||
- `UnitOfWork` interface: `Do(ctx context.Context, fn func(ctx context.Context) error) error`.
|
||||
- `Config` struct: fields `Path` (`SQLITE_PATH`), `MaxOpenConns` (default `1`), `MaxIdleConns` (default `1`), `Pragmas` (default `?_journal=WAL&_timeout=5000&_fk=true`); settable via `SQLITE_*` environment variables.
|
||||
- `New(logger logz.Logger, cfg Config) Component`: returns a pure-Go SQLite component backed by `modernc.org/sqlite`; no CGO required.
|
||||
- Lifecycle hooks: `OnInit` opens the database, sets connection limits, and enforces `PRAGMA foreign_keys = ON`; startup fails if the pragma cannot be set. `OnStart` pings with a 5-second timeout. `OnStop` closes the connection.
|
||||
- `health.Checkable` implementation: `HealthCheck` delegates to `Ping`; `Name()` returns `"sqlite"`; `Priority()` returns `health.LevelCritical`.
|
||||
- `NewUnitOfWork(logger logz.Logger, client Client) UnitOfWork`: wraps a `Client` to provide transactional `Do` semantics. When the client is the concrete `*sqliteComponent`, the write mutex is acquired for the duration of `Do` to serialise concurrent write transactions and prevent `SQLITE_BUSY`.
|
||||
- `HandleError(err error) error` (package-level function): maps SQLite extended error codes via a duck-typed `coder` interface — code `2067` (unique constraint) and `1555` (primary key constraint) → `ErrAlreadyExists`; code `787` (foreign key constraint) → `ErrInvalidInput`; `sql.ErrNoRows` → `ErrNotFound`; all others → `ErrInternal`.
|
||||
- Transaction context injection: the active `*sql.Tx` is stored under an unexported `ctxTxKey{}` context key; `GetExecutor` returns it when found, otherwise returns `*sql.DB`.
|
||||
- WAL journal mode, 5-second busy timeout, and foreign key enforcement enabled by default via the `Pragmas` DSN suffix.
|
||||
- Support for in-memory databases via `Config{Path: ":memory:"}` for test isolation.
|
||||
|
||||
### Design Notes
|
||||
|
||||
- `modernc.org/sqlite` (pure Go, no CGO) is used instead of `mattn/go-sqlite3`, enabling cross-compilation with `CGO_ENABLED=0` and no system library dependency.
|
||||
- A `sync.Mutex` (`writeMu`) on the component serialises all `UnitOfWork.Do` calls, preventing `SQLITE_BUSY` errors that arise from SQLite's single-writer constraint without requiring callers to manage locking.
|
||||
- Foreign key enforcement is applied both via the `_fk=true` DSN pragma and an explicit `PRAGMA foreign_keys = ON` statement in `OnInit`, ensuring enforcement is active regardless of driver-level pragma handling.
|
||||
|
||||
[0.9.0]: https://com.nochebuena.dev/go/sqlite/releases/tag/v0.9.0
|
||||
91
CLAUDE.md
Normal file
91
CLAUDE.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# sqlite
|
||||
|
||||
Pure-Go SQLite client with launcher lifecycle, health check, unit-of-work, and structured error mapping.
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides a `database/sql`-backed SQLite client that integrates with the launcher and health
|
||||
modules. Designed for single-process, embedded database use cases where CGO-free builds and
|
||||
cross-compilation matter. Serialises writes through a mutex to prevent `SQLITE_BUSY` errors.
|
||||
|
||||
## Tier & Dependencies
|
||||
|
||||
**Tier 3 (infrastructure)** — depends on:
|
||||
- `code.nochebuena.dev/go/health` (Tier 2)
|
||||
- `code.nochebuena.dev/go/launcher` (Tier 2)
|
||||
- `code.nochebuena.dev/go/logz` (Tier 1)
|
||||
- `code.nochebuena.dev/go/xerrors` (Tier 0)
|
||||
- `modernc.org/sqlite` (pure-Go SQLite driver, no CGO)
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- **Pure-Go driver**: `modernc.org/sqlite` is used instead of `mattn/go-sqlite3`. No CGO,
|
||||
no system library required. Cross-compilation works with `CGO_ENABLED=0`. See ADR-001.
|
||||
- **Write mutex**: `writeMu sync.Mutex` in `sqliteComponent` is acquired by `UnitOfWork.Do`
|
||||
before every write transaction. Eliminates `SQLITE_BUSY` under concurrent goroutines. See ADR-002.
|
||||
- **Foreign key enforcement**: Enabled in both the default DSN Pragmas (`_fk=true`) and via
|
||||
an explicit `PRAGMA foreign_keys = ON` in `OnInit`. Startup fails if the PRAGMA cannot be
|
||||
set. See ADR-003.
|
||||
- **Honest Tx contract**: `Tx.Commit()` and `Tx.Rollback()` accept no `ctx` argument,
|
||||
matching the `database/sql` limitation. The interface documents this explicitly.
|
||||
- **Context injection for UoW**: `GetExecutor(ctx)` checks for a `ctxTxKey{}` value in the
|
||||
context. When inside `UnitOfWork.Do`, the context carries the active transaction, so
|
||||
repositories call `client.GetExecutor(ctx)` and automatically participate in the transaction
|
||||
without knowing about it.
|
||||
- **WAL + busy timeout defaults**: `?_journal=WAL&_timeout=5000&_fk=true` are the default
|
||||
Pragmas. Callers can override via `Config.Pragmas`, but the `OnInit` FK PRAGMA always runs.
|
||||
- **Health check**: `Priority()` returns `health.LevelCritical`. A failed ping prevents the
|
||||
service from being marked healthy.
|
||||
|
||||
## Patterns
|
||||
|
||||
**Lifecycle registration:**
|
||||
```go
|
||||
db := sqlite.New(logger, cfg)
|
||||
lc.Append(db) // registers OnInit / OnStart / OnStop
|
||||
```
|
||||
|
||||
**Repository pattern using GetExecutor:**
|
||||
```go
|
||||
func (r *repo) FindByID(ctx context.Context, id int) (*Thing, error) {
|
||||
exec := r.db.GetExecutor(ctx) // returns Tx if inside UoW, pool otherwise
|
||||
row := exec.QueryRowContext(ctx, "SELECT ... WHERE id = ?", id)
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Unit of Work:**
|
||||
```go
|
||||
uow := sqlite.NewUnitOfWork(logger, db)
|
||||
err := uow.Do(ctx, func(ctx context.Context) error {
|
||||
// all calls to db.GetExecutor(ctx) return the same Tx
|
||||
return repo.Save(ctx, thing)
|
||||
})
|
||||
```
|
||||
|
||||
**Error handling:**
|
||||
```go
|
||||
if err := db.HandleError(err); err != nil {
|
||||
// err is a *xerrors.Err with code ErrNotFound, ErrAlreadyExists, ErrInvalidInput, or ErrInternal
|
||||
}
|
||||
```
|
||||
|
||||
## What to Avoid
|
||||
|
||||
- Do not set `MaxOpenConns > 1` without understanding the implications. SQLite allows only one
|
||||
writer at a time; a larger pool increases `SQLITE_BUSY` risk for callers that bypass `UnitOfWork`.
|
||||
- Do not use `Begin`/`Commit`/`Rollback` directly for concurrent writes. Use `UnitOfWork.Do`
|
||||
to get write-mutex protection.
|
||||
- Do not rely on `Tx.Commit(ctx)` — the `Tx` interface intentionally has no ctx on Commit
|
||||
and Rollback, matching `database/sql` behaviour.
|
||||
- Do not wrap `HandleError` output with additional error types that would obscure the
|
||||
`*xerrors.Err` for callers using `errors.As`.
|
||||
|
||||
## Testing Notes
|
||||
|
||||
- All tests use `Config{Path: ":memory:"}`. No filesystem or teardown needed.
|
||||
- `compliance_test.go` (package `sqlite_test`) asserts that `New(...)` satisfies the
|
||||
`Component` interface at compile time.
|
||||
- `TestUnitOfWork_WriteMutex` spawns 5 concurrent goroutines to verify serialisation.
|
||||
- `TestHandleError_ForeignKey` and `TestHandleError_UniqueConstraint` exercise real SQLite
|
||||
error code mapping; they require `_fk=true` in Pragmas (set in `newMemDB`).
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 NOCHEBUENADEV
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
49
README.md
Normal file
49
README.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# sqlite
|
||||
|
||||
Pure-Go SQLite client (`modernc.org/sqlite`, no CGO) with launcher lifecycle and health check integration.
|
||||
|
||||
## Install
|
||||
|
||||
```
|
||||
go get code.nochebuena.dev/go/sqlite
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```go
|
||||
db := sqlite.New(logger, cfg)
|
||||
lc.Append(db)
|
||||
r.Get("/health", health.NewHandler(logger, db))
|
||||
```
|
||||
|
||||
## Testing with :memory:
|
||||
|
||||
```go
|
||||
db := sqlite.New(logger, sqlite.Config{Path: ":memory:"})
|
||||
db.OnInit()
|
||||
```
|
||||
|
||||
## Unit of Work
|
||||
|
||||
```go
|
||||
uow := sqlite.NewUnitOfWork(logger, db)
|
||||
|
||||
err := uow.Do(ctx, func(ctx context.Context) error {
|
||||
exec := db.GetExecutor(ctx) // returns the active Tx
|
||||
_, err := exec.ExecContext(ctx, "INSERT INTO orders ...")
|
||||
return err
|
||||
})
|
||||
```
|
||||
|
||||
The `UnitOfWork` serialises writes via an internal mutex to prevent `SQLITE_BUSY` errors.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env var | Default | Description |
|
||||
|---|---|---|
|
||||
| `SQLITE_PATH` | required | File path or `:memory:` |
|
||||
| `SQLITE_MAX_OPEN_CONNS` | `1` | Max open connections |
|
||||
| `SQLITE_MAX_IDLE_CONNS` | `1` | Max idle connections |
|
||||
| `SQLITE_PRAGMAS` | `?_journal=WAL&_timeout=5000&_fk=true` | DSN pragmas |
|
||||
|
||||
Foreign key enforcement (`PRAGMA foreign_keys = ON`) is always enabled in `OnInit`.
|
||||
8
compliance_test.go
Normal file
8
compliance_test.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package sqlite_test
|
||||
|
||||
import (
|
||||
"code.nochebuena.dev/go/logz"
|
||||
"code.nochebuena.dev/go/sqlite"
|
||||
)
|
||||
|
||||
var _ sqlite.Component = sqlite.New(logz.New(logz.Options{}), sqlite.Config{Path: ":memory:"})
|
||||
8
doc.go
Normal file
8
doc.go
Normal file
@@ -0,0 +1,8 @@
|
||||
// Package sqlite provides a pure-Go SQLite client (via modernc.org/sqlite) with launcher
|
||||
// lifecycle and health check integration.
|
||||
//
|
||||
// All tests can use Config{Path: ":memory:"} for fully isolated, self-contained databases.
|
||||
//
|
||||
// WAL mode, foreign key enforcement, and a busy timeout are enabled by default via the
|
||||
// Pragmas config field.
|
||||
package sqlite
|
||||
43
docs/adr/ADR-001-modernc-pure-go-driver.md
Normal file
43
docs/adr/ADR-001-modernc-pure-go-driver.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# ADR-001: Pure-Go SQLite Driver via modernc.org/sqlite
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-18
|
||||
|
||||
## Context
|
||||
|
||||
SQLite requires a C library on the host system. The standard `mattn/go-sqlite3` driver wraps the
|
||||
C library via cgo. This means:
|
||||
|
||||
- CGO must be enabled at build time (`CGO_ENABLED=1`).
|
||||
- A C toolchain must be present in every build and CI environment.
|
||||
- Cross-compilation is significantly harder (requires a cross-compiling C toolchain).
|
||||
- Static binaries are complicated to produce without additional linker flags.
|
||||
|
||||
For a micro-lib that should work in minimal container environments and cross-compile without
|
||||
ceremony, this is a poor baseline.
|
||||
|
||||
## Decision
|
||||
|
||||
Use `modernc.org/sqlite` as the SQLite driver. This is a transpilation of the official SQLite
|
||||
amalgamation from C to Go, producing a pure-Go implementation with no CGO dependency. It is
|
||||
registered under the driver name `"sqlite"` and is otherwise compatible with `database/sql`.
|
||||
|
||||
The import is a blank import in `sqlite.go`:
|
||||
|
||||
```go
|
||||
import _ "modernc.org/sqlite" // register sqlite driver
|
||||
```
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- `CGO_ENABLED=0` builds work out of the box.
|
||||
- Cross-compilation requires no special toolchain setup.
|
||||
- CI environments need only the Go toolchain.
|
||||
- Minimal container images (scratch, distroless) are straightforward targets.
|
||||
|
||||
**Negative:**
|
||||
- `modernc.org/sqlite` lags slightly behind the official SQLite release cadence.
|
||||
- Transpiled code is harder to debug at the C level than `mattn/go-sqlite3`.
|
||||
- The driver name is `"sqlite"` not `"sqlite3"`, which would conflict with any project that
|
||||
also imports `mattn/go-sqlite3`.
|
||||
53
docs/adr/ADR-002-write-mutex-busy-prevention.md
Normal file
53
docs/adr/ADR-002-write-mutex-busy-prevention.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# ADR-002: Write Mutex to Prevent SQLITE_BUSY Under Concurrent Load
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-18
|
||||
|
||||
## Context
|
||||
|
||||
SQLite uses file-level locking. When multiple goroutines attempt write transactions
|
||||
concurrently, SQLite cannot acquire the write lock immediately and returns `SQLITE_BUSY`.
|
||||
Although the default Pragmas configure a 5-second busy timeout (`_timeout=5000`), this is a
|
||||
passive wait that still allows competing transactions to collide and fail under sustained
|
||||
concurrent write pressure.
|
||||
|
||||
WAL mode (`_journal=WAL`) improves read concurrency but does not eliminate write contention:
|
||||
SQLite still allows only one writer at a time.
|
||||
|
||||
## Decision
|
||||
|
||||
The `sqliteComponent` holds a `writeMu sync.Mutex` field. `NewUnitOfWork` detects when its
|
||||
`Client` argument is the concrete `*sqliteComponent` type and extracts a pointer to that mutex.
|
||||
`unitOfWork.Do` acquires the mutex before beginning a transaction and releases it after
|
||||
commit or rollback:
|
||||
|
||||
```go
|
||||
func (u *unitOfWork) Do(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||
if u.writeMu != nil {
|
||||
u.writeMu.Lock()
|
||||
defer u.writeMu.Unlock()
|
||||
}
|
||||
tx, err := u.client.Begin(ctx)
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
This serialises all write transactions at the application level, guaranteeing that only one
|
||||
writer reaches SQLite at a time and eliminating `SQLITE_BUSY` errors entirely.
|
||||
|
||||
The mutex is only applied when using `NewUnitOfWork`. Callers who manage transactions manually
|
||||
via `Begin`/`Commit`/`Rollback` are not protected and must handle contention themselves.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- `SQLITE_BUSY` is eliminated for all write workloads going through `UnitOfWork`.
|
||||
- Behaviour is deterministic and testable (see `TestUnitOfWork_WriteMutex`).
|
||||
- Reads are unaffected; the mutex only wraps writes.
|
||||
|
||||
**Negative:**
|
||||
- Write throughput is bounded to one goroutine at a time. This is acceptable for SQLite's
|
||||
typical deployment profile (embedded, single-process, modest write rates).
|
||||
- The type assertion `client.(*sqliteComponent)` couples `NewUnitOfWork` to the concrete type.
|
||||
When a mock or alternative `Client` is supplied, `writeMu` is `nil` and serialisation is
|
||||
skipped silently. This is intentional for testing flexibility.
|
||||
54
docs/adr/ADR-003-fk-enforcement-pragma.md
Normal file
54
docs/adr/ADR-003-fk-enforcement-pragma.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# ADR-003: Foreign Key Enforcement via PRAGMA and DSN
|
||||
|
||||
**Status:** Accepted
|
||||
**Date:** 2026-03-18
|
||||
|
||||
## Context
|
||||
|
||||
SQLite disables foreign key constraint enforcement by default for backwards compatibility.
|
||||
Applications that define `REFERENCES` clauses in their schema will silently insert orphaned
|
||||
rows unless they explicitly enable enforcement. This is a common source of data integrity bugs.
|
||||
|
||||
## Decision
|
||||
|
||||
Foreign key enforcement is enabled at two points:
|
||||
|
||||
1. **DSN parameter** — The default `Pragmas` config value includes `_fk=true`:
|
||||
```
|
||||
?_journal=WAL&_timeout=5000&_fk=true
|
||||
```
|
||||
This sets `PRAGMA foreign_keys = ON` for every connection opened via the DSN.
|
||||
|
||||
2. **OnInit explicit PRAGMA** — After opening the database pool, `OnInit` executes an
|
||||
additional `PRAGMA foreign_keys = ON` call:
|
||||
```go
|
||||
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
||||
_ = db.Close()
|
||||
return fmt.Errorf("sqlite: enable foreign keys: %w", err)
|
||||
}
|
||||
```
|
||||
If this call fails, `OnInit` returns an error and the pool is closed, preventing startup
|
||||
with an unsafe configuration.
|
||||
|
||||
The redundancy is deliberate: the DSN parameter may be overridden by callers who supply a
|
||||
custom `Pragmas` value, but the `OnInit` PRAGMA call always runs and fails loudly if it cannot
|
||||
enforce foreign keys.
|
||||
|
||||
`HandleError` maps `SQLITE_CONSTRAINT_FOREIGNKEY` (error code 787) to
|
||||
`xerrors.ErrInvalidInput` so that foreign key violations surface as validation errors to
|
||||
callers rather than opaque internal errors.
|
||||
|
||||
## Consequences
|
||||
|
||||
**Positive:**
|
||||
- Foreign key constraints are always active when using the default configuration.
|
||||
- Failure to enable them is a startup error, not a silent misconfiguration.
|
||||
- Violations produce a structured `xerrors.ErrInvalidInput` error.
|
||||
|
||||
**Negative:**
|
||||
- Callers who deliberately omit `_fk=true` from a custom `Pragmas` string still get the
|
||||
enforcement applied by the `OnInit` PRAGMA. There is no opt-out without modifying the source.
|
||||
- `PRAGMA foreign_keys = ON` must be set per-connection; `database/sql` connection pooling
|
||||
means this implicit approach (via DSN) can behave differently under pool pressure. The
|
||||
explicit `OnInit` PRAGMA mitigates this for the initial connection but cannot guarantee it
|
||||
for all pooled connections when `MaxOpenConns > 1`.
|
||||
38
errors.go
Normal file
38
errors.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
|
||||
"code.nochebuena.dev/go/xerrors"
|
||||
)
|
||||
|
||||
// coder is the duck-type interface for SQLite extended error codes.
|
||||
// modernc.org/sqlite errors implement this interface.
|
||||
type coder interface{ Code() int }
|
||||
|
||||
const (
|
||||
sqliteConstraintPrimaryKey = 1555
|
||||
sqliteConstraintUnique = 2067
|
||||
sqliteConstraintForeignKey = 787
|
||||
)
|
||||
|
||||
// HandleError maps SQLite and database/sql errors to xerrors types.
|
||||
func HandleError(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return xerrors.New(xerrors.ErrNotFound, "record not found").WithError(err)
|
||||
}
|
||||
var ce coder
|
||||
if errors.As(err, &ce) {
|
||||
switch ce.Code() {
|
||||
case sqliteConstraintUnique, sqliteConstraintPrimaryKey:
|
||||
return xerrors.New(xerrors.ErrAlreadyExists, "record already exists").WithError(err)
|
||||
case sqliteConstraintForeignKey:
|
||||
return xerrors.New(xerrors.ErrInvalidInput, "data integrity violation").WithError(err)
|
||||
}
|
||||
}
|
||||
return xerrors.New(xerrors.ErrInternal, "unexpected database error").WithError(err)
|
||||
}
|
||||
24
go.mod
Normal file
24
go.mod
Normal file
@@ -0,0 +1,24 @@
|
||||
module code.nochebuena.dev/go/sqlite
|
||||
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
code.nochebuena.dev/go/health v0.9.0
|
||||
code.nochebuena.dev/go/launcher v0.9.0
|
||||
code.nochebuena.dev/go/logz v0.9.0
|
||||
code.nochebuena.dev/go/xerrors v0.9.0
|
||||
modernc.org/sqlite v1.37.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
modernc.org/libc v1.65.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
55
go.sum
Normal file
55
go.sum
Normal file
@@ -0,0 +1,55 @@
|
||||
code.nochebuena.dev/go/health v0.9.0 h1:x0UKjC7CHAE3AgwyFzCyjmGJIjoLBBxeOHxXuqpbKwI=
|
||||
code.nochebuena.dev/go/health v0.9.0/go.mod h1:f3IsNtU60JSn5yXmBBh9XOvr5pRyEah5+wS4tjDQZso=
|
||||
code.nochebuena.dev/go/launcher v0.9.0 h1:dJHonA9Xm03AQKK0919FJaQn9ZKHZ+RZfB9yxjnx3TA=
|
||||
code.nochebuena.dev/go/launcher v0.9.0/go.mod h1:IBtntmbnyddukjEhxlc7Ysdzz9nZsnd9+8FzAIHt77g=
|
||||
code.nochebuena.dev/go/logz v0.9.0 h1:wfV7vtI4V/8ED7Hm31Fbql7Y5iOGrlHN4X8Z5ajTZZE=
|
||||
code.nochebuena.dev/go/logz v0.9.0/go.mod h1:qODhSbKb+tWE7rdhHLcKweiP5CgwIaWoZxadCT3bQV8=
|
||||
code.nochebuena.dev/go/xerrors v0.9.0 h1:8wrDto7e44ZW1YPOnT6JrxYXTqnvNuKpAO1/5bcT4TE=
|
||||
code.nochebuena.dev/go/xerrors v0.9.0/go.mod h1:mtXo7xscBreCB7w7smlBP5Onv8H1HVohCvF0I/VXbAY=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
||||
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
|
||||
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
|
||||
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
|
||||
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
242
sqlite.go
Normal file
242
sqlite.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite" // register sqlite driver
|
||||
|
||||
"code.nochebuena.dev/go/health"
|
||||
"code.nochebuena.dev/go/launcher"
|
||||
"code.nochebuena.dev/go/logz"
|
||||
)
|
||||
|
||||
// Executor defines operations shared by the connection and transaction.
|
||||
type Executor 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
|
||||
}
|
||||
|
||||
// Tx extends Executor with commit/rollback.
|
||||
// Honest contract: database/sql Tx does not accept ctx on Commit/Rollback.
|
||||
type Tx interface {
|
||||
Executor
|
||||
Commit() error
|
||||
Rollback() error
|
||||
}
|
||||
|
||||
// Client is the primary interface for consumers.
|
||||
type Client interface {
|
||||
GetExecutor(ctx context.Context) Executor
|
||||
Begin(ctx context.Context) (Tx, error)
|
||||
Ping(ctx context.Context) error
|
||||
HandleError(err error) error
|
||||
}
|
||||
|
||||
// Component bundles lifecycle + health + client.
|
||||
type Component interface {
|
||||
launcher.Component
|
||||
health.Checkable
|
||||
Client
|
||||
}
|
||||
|
||||
// UnitOfWork manages the transaction lifecycle via context injection.
|
||||
type UnitOfWork interface {
|
||||
Do(ctx context.Context, fn func(ctx context.Context) error) error
|
||||
}
|
||||
|
||||
// Config holds connection parameters.
|
||||
type Config struct {
|
||||
// Path is the SQLite file path. Use ":memory:" for in-memory databases.
|
||||
Path string `env:"SQLITE_PATH,required"`
|
||||
// MaxOpenConns limits concurrent connections. Default: 1.
|
||||
MaxOpenConns int `env:"SQLITE_MAX_OPEN_CONNS" envDefault:"1"`
|
||||
// MaxIdleConns is the number of idle connections kept in the pool.
|
||||
MaxIdleConns int `env:"SQLITE_MAX_IDLE_CONNS" envDefault:"1"`
|
||||
// Pragmas are appended to the DSN. Default: WAL + 5s busy timeout + FK enforcement.
|
||||
Pragmas string `env:"SQLITE_PRAGMAS" envDefault:"?_journal=WAL&_timeout=5000&_fk=true"`
|
||||
}
|
||||
|
||||
func (c Config) dsn() string {
|
||||
return c.Path + c.Pragmas
|
||||
}
|
||||
|
||||
// ctxTxKey is the context key for the active transaction.
|
||||
type ctxTxKey struct{}
|
||||
|
||||
// --- sqliteComponent ---
|
||||
|
||||
type sqliteComponent struct {
|
||||
logger logz.Logger
|
||||
cfg Config
|
||||
db *sql.DB
|
||||
mu sync.RWMutex
|
||||
writeMu sync.Mutex // serialises writes to prevent SQLITE_BUSY
|
||||
}
|
||||
|
||||
// New returns a sqlite Component. Call lc.Append(db) to manage its lifecycle.
|
||||
func New(logger logz.Logger, cfg Config) Component {
|
||||
return &sqliteComponent{logger: logger, cfg: cfg}
|
||||
}
|
||||
|
||||
func (c *sqliteComponent) OnInit() error {
|
||||
db, err := sql.Open("sqlite", c.cfg.dsn())
|
||||
if err != nil {
|
||||
return fmt.Errorf("sqlite: open: %w", err)
|
||||
}
|
||||
maxOpen := c.cfg.MaxOpenConns
|
||||
if maxOpen == 0 {
|
||||
maxOpen = 1
|
||||
}
|
||||
db.SetMaxOpenConns(maxOpen)
|
||||
db.SetMaxIdleConns(c.cfg.MaxIdleConns)
|
||||
// Enforce foreign keys per-connection (SQLite disables them by default).
|
||||
if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
|
||||
_ = db.Close()
|
||||
return fmt.Errorf("sqlite: enable foreign keys: %w", err)
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.db = db
|
||||
c.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *sqliteComponent) OnStart() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := c.Ping(ctx); err != nil {
|
||||
return fmt.Errorf("sqlite: ping failed: %w", err)
|
||||
}
|
||||
c.logger.Info("sqlite: ready", "path", c.cfg.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *sqliteComponent) OnStop() error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.db != nil {
|
||||
c.logger.Info("sqlite: closing")
|
||||
_ = c.db.Close()
|
||||
c.db = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *sqliteComponent) Ping(ctx context.Context) error {
|
||||
c.mu.RLock()
|
||||
db := c.db
|
||||
c.mu.RUnlock()
|
||||
if db == nil {
|
||||
return fmt.Errorf("sqlite: not initialized")
|
||||
}
|
||||
return db.PingContext(ctx)
|
||||
}
|
||||
|
||||
func (c *sqliteComponent) GetExecutor(ctx context.Context) Executor {
|
||||
if tx, ok := ctx.Value(ctxTxKey{}).(Executor); ok {
|
||||
return tx
|
||||
}
|
||||
c.mu.RLock()
|
||||
db := c.db
|
||||
c.mu.RUnlock()
|
||||
return db
|
||||
}
|
||||
|
||||
func (c *sqliteComponent) Begin(ctx context.Context) (Tx, error) {
|
||||
c.mu.RLock()
|
||||
db := c.db
|
||||
c.mu.RUnlock()
|
||||
if db == nil {
|
||||
return nil, fmt.Errorf("sqlite: not initialized")
|
||||
}
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &sqliteTx{Tx: tx}, nil
|
||||
}
|
||||
|
||||
func (c *sqliteComponent) ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error) {
|
||||
c.mu.RLock()
|
||||
db := c.db
|
||||
c.mu.RUnlock()
|
||||
return db.ExecContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
func (c *sqliteComponent) QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error) {
|
||||
c.mu.RLock()
|
||||
db := c.db
|
||||
c.mu.RUnlock()
|
||||
return db.QueryContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
func (c *sqliteComponent) QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row {
|
||||
c.mu.RLock()
|
||||
db := c.db
|
||||
c.mu.RUnlock()
|
||||
return db.QueryRowContext(ctx, query, args...)
|
||||
}
|
||||
|
||||
func (c *sqliteComponent) HandleError(err error) error { return HandleError(err) }
|
||||
|
||||
// health.Checkable
|
||||
func (c *sqliteComponent) HealthCheck(ctx context.Context) error { return c.Ping(ctx) }
|
||||
func (c *sqliteComponent) Name() string { return "sqlite" }
|
||||
func (c *sqliteComponent) Priority() health.Level { return health.LevelCritical }
|
||||
|
||||
// --- sqliteTx ---
|
||||
|
||||
type sqliteTx struct{ *sql.Tx }
|
||||
|
||||
func (t *sqliteTx) ExecContext(ctx context.Context, q string, args ...any) (sql.Result, error) {
|
||||
return t.Tx.ExecContext(ctx, q, args...)
|
||||
}
|
||||
func (t *sqliteTx) QueryContext(ctx context.Context, q string, args ...any) (*sql.Rows, error) {
|
||||
return t.Tx.QueryContext(ctx, q, args...)
|
||||
}
|
||||
func (t *sqliteTx) QueryRowContext(ctx context.Context, q string, args ...any) *sql.Row {
|
||||
return t.Tx.QueryRowContext(ctx, q, args...)
|
||||
}
|
||||
func (t *sqliteTx) Commit() error { return t.Tx.Commit() }
|
||||
func (t *sqliteTx) Rollback() error { return t.Tx.Rollback() }
|
||||
|
||||
// --- UnitOfWork ---
|
||||
|
||||
type unitOfWork struct {
|
||||
logger logz.Logger
|
||||
client Client
|
||||
writeMu *sync.Mutex
|
||||
}
|
||||
|
||||
// NewUnitOfWork returns a UnitOfWork backed by the given client.
|
||||
// If client is a *sqliteComponent, the write mutex is used to serialise transactions.
|
||||
func NewUnitOfWork(logger logz.Logger, client Client) UnitOfWork {
|
||||
var mu *sync.Mutex
|
||||
if sc, ok := client.(*sqliteComponent); ok {
|
||||
mu = &sc.writeMu
|
||||
}
|
||||
return &unitOfWork{logger: logger, client: client, writeMu: mu}
|
||||
}
|
||||
|
||||
func (u *unitOfWork) Do(ctx context.Context, fn func(ctx context.Context) error) error {
|
||||
if u.writeMu != nil {
|
||||
u.writeMu.Lock()
|
||||
defer u.writeMu.Unlock()
|
||||
}
|
||||
tx, err := u.client.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("sqlite: begin transaction: %w", err)
|
||||
}
|
||||
ctx = context.WithValue(ctx, ctxTxKey{}, tx)
|
||||
if err := fn(ctx); err != nil {
|
||||
if rbErr := tx.Rollback(); rbErr != nil {
|
||||
u.logger.Error("sqlite: rollback failed", rbErr)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
270
sqlite_test.go
Normal file
270
sqlite_test.go
Normal file
@@ -0,0 +1,270 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"code.nochebuena.dev/go/health"
|
||||
"code.nochebuena.dev/go/logz"
|
||||
"code.nochebuena.dev/go/xerrors"
|
||||
)
|
||||
|
||||
func newLogger() logz.Logger { return logz.New(logz.Options{}) }
|
||||
|
||||
func newMemDB(t *testing.T) Component {
|
||||
t.Helper()
|
||||
c := New(newLogger(), Config{Path: ":memory:", MaxOpenConns: 1, MaxIdleConns: 1,
|
||||
Pragmas: "?_fk=true"})
|
||||
if err := c.OnInit(); err != nil {
|
||||
t.Fatalf("OnInit: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// --- New / name / priority ---
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
if New(newLogger(), Config{Path: ":memory:"}) == nil {
|
||||
t.Fatal("New returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponent_Name(t *testing.T) {
|
||||
c := New(newLogger(), Config{Path: ":memory:"}).(health.Checkable)
|
||||
if c.Name() != "sqlite" {
|
||||
t.Errorf("want sqlite, got %s", c.Name())
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponent_Priority(t *testing.T) {
|
||||
c := New(newLogger(), Config{Path: ":memory:"}).(health.Checkable)
|
||||
if c.Priority() != health.LevelCritical {
|
||||
t.Error("Priority() != LevelCritical")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponent_OnStop_NilDB(t *testing.T) {
|
||||
c := &sqliteComponent{logger: newLogger()}
|
||||
if err := c.OnStop(); err != nil {
|
||||
t.Errorf("OnStop with nil db: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponent_FullLifecycle(t *testing.T) {
|
||||
c := New(newLogger(), Config{Path: ":memory:", Pragmas: ""})
|
||||
if err := c.OnInit(); err != nil {
|
||||
t.Fatalf("OnInit: %v", err)
|
||||
}
|
||||
if err := c.OnStart(); err != nil {
|
||||
t.Fatalf("OnStart: %v", err)
|
||||
}
|
||||
if err := c.OnStop(); err != nil {
|
||||
t.Fatalf("OnStop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Exec / Query / QueryRow ---
|
||||
|
||||
func TestComponent_Exec(t *testing.T) {
|
||||
c := newMemDB(t)
|
||||
exec := c.GetExecutor(context.Background())
|
||||
_, err := exec.ExecContext(context.Background(),
|
||||
"CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
if err != nil {
|
||||
t.Fatalf("create table: %v", err)
|
||||
}
|
||||
res, err := exec.ExecContext(context.Background(), "INSERT INTO t VALUES (1, 'hello')")
|
||||
if err != nil {
|
||||
t.Fatalf("insert: %v", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n != 1 {
|
||||
t.Errorf("want 1 row affected, got %d", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponent_Query(t *testing.T) {
|
||||
c := newMemDB(t)
|
||||
exec := c.GetExecutor(context.Background())
|
||||
_, _ = exec.ExecContext(context.Background(), "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)")
|
||||
_, _ = exec.ExecContext(context.Background(), "INSERT INTO t VALUES (1, 'hello')")
|
||||
|
||||
rows, err := exec.QueryContext(context.Background(), "SELECT name FROM t")
|
||||
if err != nil {
|
||||
t.Fatalf("query: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
t.Fatal("expected a row")
|
||||
}
|
||||
var name string
|
||||
if err := rows.Scan(&name); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
if name != "hello" {
|
||||
t.Errorf("want hello, got %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComponent_QueryRow(t *testing.T) {
|
||||
c := newMemDB(t)
|
||||
exec := c.GetExecutor(context.Background())
|
||||
_, _ = exec.ExecContext(context.Background(), "CREATE TABLE t (id INTEGER PRIMARY KEY, val TEXT)")
|
||||
_, _ = exec.ExecContext(context.Background(), "INSERT INTO t VALUES (1, 'world')")
|
||||
var val string
|
||||
if err := exec.QueryRowContext(context.Background(), "SELECT val FROM t WHERE id=1").Scan(&val); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
if val != "world" {
|
||||
t.Errorf("want world, got %s", val)
|
||||
}
|
||||
}
|
||||
|
||||
// --- HandleError ---
|
||||
|
||||
func TestHandleError_Nil(t *testing.T) {
|
||||
if err := HandleError(nil); err != nil {
|
||||
t.Errorf("want nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleError_NoRows(t *testing.T) {
|
||||
assertCode(t, HandleError(sql.ErrNoRows), xerrors.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestHandleError_Generic(t *testing.T) {
|
||||
assertCode(t, HandleError(errors.New("oops")), xerrors.ErrInternal)
|
||||
}
|
||||
|
||||
func TestHandleError_UniqueConstraint(t *testing.T) {
|
||||
c := newMemDB(t)
|
||||
exec := c.GetExecutor(context.Background())
|
||||
_, _ = exec.ExecContext(context.Background(), "CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
||||
_, _ = exec.ExecContext(context.Background(), "INSERT INTO t VALUES (1)")
|
||||
_, err := exec.ExecContext(context.Background(), "INSERT INTO t VALUES (1)")
|
||||
if err == nil {
|
||||
t.Fatal("expected unique constraint error")
|
||||
}
|
||||
mapped := HandleError(err)
|
||||
assertCode(t, mapped, xerrors.ErrAlreadyExists)
|
||||
}
|
||||
|
||||
func TestHandleError_ForeignKey(t *testing.T) {
|
||||
c := newMemDB(t)
|
||||
exec := c.GetExecutor(context.Background())
|
||||
_, _ = exec.ExecContext(context.Background(),
|
||||
"CREATE TABLE parent (id INTEGER PRIMARY KEY)")
|
||||
_, _ = exec.ExecContext(context.Background(),
|
||||
"CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent(id))")
|
||||
_, err := exec.ExecContext(context.Background(),
|
||||
"INSERT INTO child VALUES (1, 999)")
|
||||
if err == nil {
|
||||
t.Fatal("expected foreign key error")
|
||||
}
|
||||
mapped := HandleError(err)
|
||||
assertCode(t, mapped, xerrors.ErrInvalidInput)
|
||||
}
|
||||
|
||||
// --- UnitOfWork ---
|
||||
|
||||
func TestUnitOfWork_Commit(t *testing.T) {
|
||||
c := newMemDB(t)
|
||||
exec := c.GetExecutor(context.Background())
|
||||
_, _ = exec.ExecContext(context.Background(), "CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
||||
|
||||
uow := NewUnitOfWork(newLogger(), c)
|
||||
err := uow.Do(context.Background(), func(ctx context.Context) error {
|
||||
e := c.GetExecutor(ctx)
|
||||
_, err := e.ExecContext(ctx, "INSERT INTO t VALUES (42)")
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Do: %v", err)
|
||||
}
|
||||
// verify committed
|
||||
var id int
|
||||
_ = exec.QueryRowContext(context.Background(), "SELECT id FROM t WHERE id=42").Scan(&id)
|
||||
if id != 42 {
|
||||
t.Error("row not committed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnitOfWork_Rollback(t *testing.T) {
|
||||
c := newMemDB(t)
|
||||
exec := c.GetExecutor(context.Background())
|
||||
_, _ = exec.ExecContext(context.Background(), "CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
||||
|
||||
uow := NewUnitOfWork(newLogger(), c)
|
||||
_ = uow.Do(context.Background(), func(ctx context.Context) error {
|
||||
e := c.GetExecutor(ctx)
|
||||
_, _ = e.ExecContext(ctx, "INSERT INTO t VALUES (99)")
|
||||
return errors.New("abort")
|
||||
})
|
||||
var count int
|
||||
_ = exec.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM t").Scan(&count)
|
||||
if count != 0 {
|
||||
t.Error("row should have been rolled back")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnitOfWork_InjectsExecutor(t *testing.T) {
|
||||
c := newMemDB(t)
|
||||
uow := NewUnitOfWork(newLogger(), c)
|
||||
var got Executor
|
||||
_ = uow.Do(context.Background(), func(ctx context.Context) error {
|
||||
got = c.GetExecutor(ctx)
|
||||
return nil
|
||||
})
|
||||
if got == nil {
|
||||
t.Error("GetExecutor should return a Tx inside Do")
|
||||
}
|
||||
// Outside Do, should return the pool
|
||||
pool := c.GetExecutor(context.Background())
|
||||
if got == pool {
|
||||
t.Error("inside Do should be a Tx, not the pool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnitOfWork_WriteMutex(t *testing.T) {
|
||||
c := newMemDB(t)
|
||||
exec := c.GetExecutor(context.Background())
|
||||
_, _ = exec.ExecContext(context.Background(), "CREATE TABLE t (id INTEGER PRIMARY KEY)")
|
||||
|
||||
uow := NewUnitOfWork(newLogger(), c)
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 5; i++ {
|
||||
wg.Add(1)
|
||||
i := i
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_ = uow.Do(context.Background(), func(ctx context.Context) error {
|
||||
e := c.GetExecutor(ctx)
|
||||
_, err := e.ExecContext(ctx, "INSERT INTO t VALUES (?)", i)
|
||||
return err
|
||||
})
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
var count int
|
||||
_ = exec.QueryRowContext(context.Background(), "SELECT COUNT(*) FROM t").Scan(&count)
|
||||
if count != 5 {
|
||||
t.Errorf("expected 5 rows, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
func assertCode(t *testing.T, err error, want xerrors.Code) {
|
||||
t.Helper()
|
||||
var xe *xerrors.Err
|
||||
if !errors.As(err, &xe) {
|
||||
t.Fatalf("expected *xerrors.Err, got %T: %v", err, err)
|
||||
}
|
||||
if xe.Code() != want {
|
||||
t.Errorf("want %s, got %s", want, xe.Code())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user