SQLite
SQLite driver with pure-Go implementation via modernc.org/sqlite.
The SQLite driver provides a pure-Go SQLite implementation for Grove, built on modernc.org/sqlite. No CGo or C compiler is required. WAL mode and foreign keys are enabled automatically on every connection.
Installation
go get github.com/xraph/grove
go get modernc.org/sqliteConnection
import (
"context"
"log"
"github.com/xraph/grove"
"github.com/xraph/grove/driver"
"github.com/xraph/grove/drivers/sqlitedriver"
)
// 1. Create and open the driver
sdb := sqlitedriver.New()
if err := sdb.Open(ctx, "file:mydb.sqlite"); err != nil {
log.Fatal(err)
}
// 2. Pass the connected driver to Grove
db, err := grove.Open(sdb)
if err != nil {
log.Fatal(err)
}
defer db.Close()Open automatically runs PRAGMA journal_mode=WAL and PRAGMA foreign_keys=ON, so you do not need to set these yourself.
DSN Examples
| DSN | Description |
|---|---|
"file:mydb.sqlite" | File-based database |
":memory:" | In-memory database (ideal for tests) |
"file:test.db?mode=ro" | Read-only file |
Connection Pool Options
sdb := sqlitedriver.New()
sdb.Open(ctx, "file:mydb.sqlite",
driver.WithPoolSize(1), // SQLite is single-writer; 1 is typical
)
db, err := grove.Open(sdb)Unwrap Pattern
Use sqlitedriver.Unwrap to access the underlying *SqliteDB from a *grove.DB handle. This gives you direct access to the SQLite-specific query builders:
sdb := sqlitedriver.Unwrap(db) // returns *sqlitedriver.SqliteDB
var users []User
sdb.NewSelect(&users).
Where("email LIKE ?", "%@example.com").
Scan(ctx)Native Syntax
Placeholders
SQLite uses ? for parameterized queries:
sdb.NewSelect(&users).
Where("email LIKE ?", "%@example.com").
Where("active = ?", true).
Scan(ctx)Quoting
Identifiers are quoted with double quotes following the SQL standard:
SELECT "id", "email" FROM "users" WHERE "active" = ?Type Mapping
| Go Type | SQLite Type |
|---|---|
bool | INTEGER (0/1) |
int, int8, int16, int32, int64 | INTEGER |
float32, float64 | REAL |
string | TEXT |
time.Time, *time.Time | TEXT (RFC 3339) |
[]byte | BLOB (stored as X'hex') |
map[string]any | TEXT (JSON) |
Query Builders
SELECT
sdb := sqlitedriver.Unwrap(db)
// Basic select with conditions
var users []User
sdb.NewSelect(&users).
Where("role = ?", "admin").
Where("active = ?", true).
OrderExpr("created_at DESC").
Limit(20).
Offset(0).
Scan(ctx)
// Select specific columns
sdb.NewSelect(&users).
Column("id", "name", "email").
Where("department = ?", "engineering").
Scan(ctx)
// Column expressions
sdb.NewSelect(&users).
ColumnExpr("COUNT(*) AS count").
ColumnExpr("department").
GroupExpr("department").
Having("COUNT(*) > ?", 5).
Scan(ctx)
// Joins
sdb.NewSelect(&users).
Join("JOIN", `"orders" AS "o"`, `"o"."user_id" = "users"."id"`).
Where(`"o"."total" > ?`, 100).
Scan(ctx)
// Left join
sdb.NewSelect(&users).
Join("LEFT JOIN", `"profiles" AS "p"`, `"p"."user_id" = "users"."id"`).
Scan(ctx)
// OR conditions
sdb.NewSelect(&users).
Where("role = ?", "admin").
WhereOr("role = ?", "superadmin").
Scan(ctx)
// Select by primary key
user := User{ID: 42}
sdb.NewSelect(&user).WherePK().Scan(ctx)
// Count rows
count, err := sdb.NewSelect(&users).
Where("active = ?", true).
Count(ctx)
// Eager-load relations
sdb.NewSelect(&users).
Relation("Orders").
Scan(ctx)
// Custom FROM expression (views, subqueries)
sdb.NewSelect(&users).
TableExpr("active_users_view AS u").
Scan(ctx)Soft Delete Filtering
Models with a soft_delete field automatically exclude soft-deleted rows from SELECT queries. Use WithDeleted() to include them:
// Only non-deleted users (default)
sdb.NewSelect(&users).Scan(ctx)
// Include soft-deleted users
sdb.NewSelect(&users).WithDeleted().Scan(ctx)INSERT
sdb := sqlitedriver.Unwrap(db)
// Insert a single row
user := User{Name: "Alice", Email: "alice@example.com"}
_, err := sdb.NewInsert(&user).Exec(ctx)
// Insert specific columns
_, err := sdb.NewInsert(&user).
Column("name", "email").
Exec(ctx)
// Bulk insert from a slice
users := []User{
{Name: "Alice", Email: "alice@example.com"},
{Name: "Bob", Email: "bob@example.com"},
}
_, err := sdb.NewInsert(&users).Exec(ctx)ON CONFLICT (Upsert)
sdb.NewInsert(&user).
OnConflict("(email) DO UPDATE").
Set("name = EXCLUDED.name").
Set("updated_at = DATETIME('now')").
Exec(ctx)
// ON CONFLICT DO NOTHING
sdb.NewInsert(&user).
OnConflict("(email) DO NOTHING").
Exec(ctx)UPDATE
sdb := sqlitedriver.Unwrap(db)
// Update all fields from model
user.Name = "Alice Smith"
_, err := sdb.NewUpdate(&user).WherePK().Exec(ctx)
// Update specific columns only
_, err := sdb.NewUpdate(&user).
Column("name", "email").
WherePK().
Exec(ctx)
// Raw SET expressions
_, err := sdb.NewUpdate(&user).
Set("login_count = login_count + ?", 1).
Where("id = ?", 42).
Exec(ctx)
// Skip zero-value fields
_, err := sdb.NewUpdate(&user).
OmitZero().
WherePK().
Exec(ctx)DELETE
sdb := sqlitedriver.Unwrap(db)
// Delete by primary key
user := User{ID: 42}
_, err := sdb.NewDelete(&user).WherePK().Exec(ctx)
// Delete with conditions
_, err := sdb.NewDelete(&user).
Where("active = ?", false).
Where("last_login < ?", cutoffDate).
Exec(ctx)When the model has a soft_delete field, NewDelete generates an UPDATE ... SET deleted_at = DATETIME('now') instead of a real DELETE. Use ForceDelete() to perform a hard delete:
// Soft delete (sets deleted_at timestamp)
sdb.NewDelete(&user).WherePK().Exec(ctx)
// Hard delete (actual DELETE FROM)
sdb.NewDelete(&user).WherePK().ForceDelete().Exec(ctx)RETURNING
SQLite 3.35+ supports RETURNING on INSERT, UPDATE, and DELETE. All three query builders expose .Returning() and .Scan():
// INSERT ... RETURNING
var id int64
sdb.NewInsert(&user).
Returning("id").
Scan(ctx, &id)
// UPDATE ... RETURNING
var updated User
sdb.NewUpdate(&user).
Set("name = ?", "New Name").
Where("id = ?", 42).
Returning("*").
Scan(ctx, &updated.ID, &updated.Name, &updated.Email)
// DELETE ... RETURNING
var deletedID int64
sdb.NewDelete(&user).
Where("id = ?", 42).
ForceDelete().
Returning("id").
Scan(ctx, &deletedID)Transactions
BeginTxQuery returns a *SqliteTx that exposes all query builders scoped to the transaction:
sdb := sqlitedriver.Unwrap(db)
tx, err := sdb.BeginTxQuery(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
_, err = tx.NewInsert(&order).Exec(ctx)
if err != nil {
return err
}
_, err = tx.NewUpdate(&inventory).
Set("quantity = quantity - ?", order.Quantity).
Where("product_id = ?", order.ProductID).
Exec(ctx)
if err != nil {
return err
}
return tx.Commit()All query types are available on SqliteTx: NewSelect, NewInsert, NewUpdate, NewDelete, and NewRaw.
CreateTable
sdb := sqlitedriver.Unwrap(db)
// Create table from model
_, err := sdb.NewCreateTable((*User)(nil)).
IfNotExists().
Exec(ctx)
// With foreign keys
_, err := sdb.NewCreateTable((*Order)(nil)).
IfNotExists().
WithForeignKey("(user_id) REFERENCES users(id) ON DELETE CASCADE").
Exec(ctx)
// Temporary table
_, err := sdb.NewCreateTable((*TempResult)(nil)).
Temp().
Exec(ctx)
// Drop table
_, err := sdb.NewDropTable((*User)(nil)).
IfExists().
Exec(ctx)Raw Queries
Use NewRaw for queries that do not fit the query builders:
sdb := sqlitedriver.Unwrap(db)
// Execute DDL or DML
_, err := sdb.NewRaw("PRAGMA table_info(?)", "users").Exec(ctx)
// Scan into structs
var users []User
sdb.NewRaw("SELECT * FROM users WHERE role = ?", "admin").Scan(ctx, &users)
// Scan into scalars
var count int64
sdb.NewRaw("SELECT COUNT(*) FROM users").Scan(ctx, &count)Migrations
The sqlitemigrate package provides a migration executor that uses table-based cooperative locking (SQLite does not support advisory locks). Migration state is stored in the grove_migrations table and locks are managed via the grove_migration_locks table.
import (
"github.com/xraph/grove/migrate"
"github.com/xraph/grove/drivers/sqlitedriver"
"github.com/xraph/grove/drivers/sqlitedriver/sqlitemigrate"
)
// Define a migration group
var Migrations = migrate.NewGroup("app")
func init() {
Migrations.MustRegister(
&migrate.Migration{
Name: "create_users",
Version: "20250101120000",
Up: func(ctx context.Context, exec migrate.Executor) error {
_, err := exec.Exec(ctx, `CREATE TABLE IF NOT EXISTS "users" (
"id" INTEGER PRIMARY KEY AUTOINCREMENT,
"name" TEXT NOT NULL,
"email" TEXT NOT NULL UNIQUE,
"created_at" TEXT NOT NULL DEFAULT (DATETIME('now')),
"deleted_at" TEXT
)`)
return err
},
Down: func(ctx context.Context, exec migrate.Executor) error {
_, err := exec.Exec(ctx, `DROP TABLE IF EXISTS "users"`)
return err
},
},
)
}
// Run migrations
sdb := sqlitedriver.New()
sdb.Open(ctx, "file:mydb.sqlite")
executor := sqlitemigrate.New(sdb)
orchestrator := migrate.NewOrchestrator(executor, Migrations)
result, err := orchestrator.Migrate(ctx)
if err != nil {
log.Fatal(err)
}
log.Printf("Applied %d migrations", len(result.Applied))
// Rollback the last migration
result, err = orchestrator.Rollback(ctx)
// Check migration status
statuses, err := orchestrator.Status(ctx)Testing with In-Memory Databases
SQLite's :memory: DSN creates an ephemeral database that is discarded when the connection closes. This makes it ideal for fast, isolated unit tests:
func setupTestDB(t *testing.T) *sqlitedriver.SqliteDB {
t.Helper()
sdb := sqlitedriver.New()
if err := sdb.Open(context.Background(), ":memory:"); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { sdb.Close() })
// Create tables
_, err := sdb.NewCreateTable((*User)(nil)).IfNotExists().Exec(context.Background())
if err != nil {
t.Fatal(err)
}
return sdb
}
func TestUserInsert(t *testing.T) {
sdb := setupTestDB(t)
ctx := context.Background()
user := User{Name: "Alice", Email: "alice@test.com"}
_, err := sdb.NewInsert(&user).Exec(ctx)
if err != nil {
t.Fatal(err)
}
var found User
err = sdb.NewSelect(&found).Where("email = ?", "alice@test.com").Scan(ctx)
if err != nil {
t.Fatal(err)
}
if found.Name != "Alice" {
t.Fatalf("expected Alice, got %s", found.Name)
}
}Hooks
Attach a hook engine to intercept queries and mutations:
sdb := sqlitedriver.New()
sdb.Open(ctx, "file:mydb.sqlite")
sdb.SetHooks(engine) // *hook.Engine or nil to disableSee the Hooks documentation for details on registering pre/post query and mutation hooks.
SQLite-Specific Notes
- Single writer: SQLite allows only one writer at a time. WAL mode (enabled automatically) improves read concurrency but does not remove the single-writer constraint.
- No row locking:
FOR UPDATE/FOR SHAREare not supported. The entire database is locked during writes. - No DISTINCT ON: Use standard
DISTINCTorGROUP BYinstead. - No CASCADE on DROP TABLE: Foreign key
ON DELETE CASCADEworks on row deletes, butDROP TABLE ... CASCADEis not supported. Drop dependent tables first. - AUTOINCREMENT: Use
INTEGER PRIMARY KEY AUTOINCREMENTfor auto-incrementing IDs. The schema taggrove:",autoincrement"handles this automatically. - Time storage: Times are stored as RFC 3339
TEXTstrings. UseDATETIME('now')for server-side timestamps.