Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Most libraries that call themselves *lite* got there by leaving features out. Li

- **🧩 Two paradigms, one core — no library lock-in.** The `query` builder (typed, low-magic) and the `orm` (declarative, tag-driven) share one `Session`, one dialect, one scanner, and one set of normalized errors. A row fetched via `orm` feeds `query` on the *same* transaction. Most stacks make you pick a camp; LiteORM lets each part of your app pick the right tool.

- **⚡ Modern baseline, lean core.** Generics-first: typed result rows and typed `Col[V]` predicates, with no reflection on the hot path. The core module pulls in **no** database drivers — each backend (`dialect/sqlite`, `dialect/postgres`, `dialect/mysql`, `dialect/mssql`) is its own module, so your build only carries the driver you use. SQLite is pure Go: cross-compile with plain `go build`, ship static distroless/alpine binaries, no C toolchain. `iter.Seq2` streaming, `log/slog` logging, and `gopls modernize` enforced in CI keep it from drifting backward.
- **⚡ Modern baseline, lean core.** Generics-first: typed result rows and typed `Col[V]` predicates, with no reflection on the hot path. The core module pulls in **no** database drivers — each backend (`dialect/sqlite`, `dialect/postgres`, `dialect/mysql`, `dialect/mssql`) is its own module, so your build only carries the driver you use. SQLite is pure Go: cross-compile with plain `go build`, ship static distroless/alpine binaries, no C toolchain — and open it **[encrypted at rest](docs/guides/encryption.md)** with a key, transparent page-level encryption (Adiantum) without a CGo sqlcipher build. `iter.Seq2` streaming, `log/slog` logging, and `gopls modernize` enforced in CI keep it from drifting backward.

- **🤖 Built for agents — AI-native two ways.** The embedded [**studio**](docs/guides/studio.md) turns plain English into SQL through one server-side `WithAI` hook to any model, so your API key never reaches the browser — no other Go ORM ships this. *Separately*, the repo ships [**Agent Skills**](skills/) and an [`AGENTS.md`](AGENTS.md) so an AI coding assistant writes correct LiteORM without guessing. AI for the people *using* your database and the people *building* against it.

Expand All @@ -65,7 +65,8 @@ Most libraries that call themselves *lite* got there by leaving features out. Li
- **[Migrations](docs/guides/migrations.md)** — additive `AutoMigrate` plus a two-track model: destructive changes become a *reviewable* migration you apply through a thin runner that reads golang-migrate / goose / plain SQL files.
- **[Normalized errors](docs/guides/errors.md)** — `ErrUniqueViolation`, `ErrForeignKey`, `ErrNotNull`, `ErrCheck`, `ErrNoRows`, `ErrDeadlock`, `ErrSerialization` — the same on SQLite, Postgres, MySQL, and SQL Server.
- **[Code generation](docs/guides/codegen.md)** — typed `Column[V]` constants for compile-time column safety, models from a live DB, SQL→typed-Go from annotated queries, a sqlc process plugin, and a gorm→LiteORM tag porter.
- **[SQLite vector / full-text / hybrid search](docs/guides/sqlite-search.md)** — typed vector (sqlite-vec) and FTS5 search, plus reciprocal-rank-fusion **hybrid** search, keyed by your model's primary key; encryption at rest.
- **[SQLite vector / full-text / hybrid search](docs/guides/sqlite-search.md)** — typed vector (sqlite-vec) and FTS5 search, plus reciprocal-rank-fusion **hybrid** search, keyed by your model's primary key.
- **[At-rest encryption](docs/guides/encryption.md)** — open an encrypted SQLite database with a 32-byte key: transparent, page-level encryption (Adiantum), **pure Go — no CGo sqlcipher**. The on-disk file is ciphertext; the `query` builder, `orm`, and migrations all work unchanged above it.
- **[SQLite changesets](docs/guides/sqlite-changeset.md)** — capture, apply, invert, and concat changesets for audit logs, one-way replication, and undo.
- **[Postgres extras](docs/guides/postgres.md)** — LISTEN/NOTIFY and typed JSONB (`->`, `->>`, `@>`) and array (`@>`, `&&`, `= ANY`) operators.

Expand All @@ -78,6 +79,7 @@ Read top-down: who LiteORM is, what it's built for, then the capability depth be
| Core runtime model | generics, no reflection | reflection | reflection | generated code | generated code |
| Explicit query builder **and** declarative ORM in one lib | ✓ | ✗ (ORM) | ✓ builder (light ORM) | ✗ (SQL→Go) | ✗ (generated ORM) |
| CGo-free SQLite (pure Go, no C toolchain) | ✓ | driver of choice | driver of choice | driver of choice | driver of choice |
| At-rest encrypted SQLite, **pure Go** (no CGo sqlcipher) | ✓ | ✗ | ✗ | ✗ | ✗ |
| Ships an **embedded database studio** (admin GUI) | ✓ | ✗ | ✗ | ✗ | ✗ |
| Studio with **built-in AI** (NL→SQL, English filters, result charts) | ✓ | ✗ | ✗ | ✗ | ✗ |
| Ships AI Agent Skills + task-oriented docs | ✓ | ✗ | ✗ | ✗ | ✗ |
Expand Down Expand Up @@ -149,7 +151,7 @@ Full walkthrough in **[Getting started](docs/getting-started.md)**.

## Examples

Runnable, smoke-tested programs under [`examples/`](examples/): `blog` (a small end-to-end blog engine — models, associations, nested eager loading, an aggregate query, and a transaction), `query` and `orm` feature showcases, `logging` (statement tracing), `search` (vector + full-text + hybrid), `queries` (SQL→Go codegen), `gormport` (the gorm porter), and `codegen`. `just example <name>` runs one; `just examples` runs them all.
Runnable, smoke-tested programs under [`examples/`](examples/): `blog` (a small end-to-end blog engine — models, associations, nested eager loading, an aggregate query, and a transaction), `query` and `orm` feature showcases, `logging` (statement tracing), `search` (vector + full-text + hybrid), `encryption` (at-rest encrypted SQLite), `queries` (SQL→Go codegen), `gormport` (the gorm porter), and `codegen`. `just example <name>` runs one; `just examples` runs them all.

## Development & contributing

Expand Down
4 changes: 1 addition & 3 deletions cmd/sqlc-gen-liteorm/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ func emit(pkg string, queries []*plugin.Query) (string, error) {
needQuery, needErrors, needTime := false, false, false

for _, q := range queries {
cmd := gen.QueryCmd(normCmd(q.GetCmd()))
cmd := gen.QueryCmd(strings.TrimPrefix(q.GetCmd(), ":")) // sqlc cmds are ":one"/":many"/…

// Result type: a generated row struct for multi-column reads, a scalar
// for one. A zero-column :one/:many is degenerate — treat it as :exec.
Expand Down Expand Up @@ -125,8 +125,6 @@ func emitRowStruct(b *strings.Builder, name string, cols []*plugin.Column, needT
b.WriteString("}\n\n")
}

func normCmd(cmd string) string { return strings.TrimPrefix(cmd, ":") }

func argName(p *plugin.Parameter, i int) string {
if c := p.GetColumn(); c != nil && c.GetName() != "" {
return lowerCamel(c.GetName())
Expand Down
6 changes: 3 additions & 3 deletions conformance/orm_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,8 @@ type fkChild struct {

func (fkChild) TableName() string { return "fk_children" }

func (a *Account) BeforeCreate(_ context.Context, op *orm.Op[Account]) error {
op.Model.Note = "hooked"
func (a *Account) BeforeCreate(_ context.Context, ev *orm.Event[Account]) error {
ev.Model.Note = "hooked"
return nil
}

Expand Down Expand Up @@ -820,7 +820,7 @@ func ormScenarios(t *testing.T, db *liteorm.DB) {
}
// Idempotent: a second migrate of the same model must not error (no duplicate).
if err := orm.AutoMigrate[widgetIndexed](ctx, db); err != nil {
t.Errorf("re-migrate should be a no-op, got %v", err)
t.Errorf("re-migrate should be a no-ev, got %v", err)
}
})

Expand Down
8 changes: 4 additions & 4 deletions conformance/ormsuite/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ type Hooked struct {
func (Hooked) TableName() string { return "hooked" }

// BeforeCreate derives a slug; returning an error from a hook aborts the write.
func (h *Hooked) BeforeCreate(_ context.Context, op *orm.Op[Hooked]) error {
if op.Model.Name == "boom" {
func (h *Hooked) BeforeCreate(_ context.Context, ev *orm.Event[Hooked]) error {
if ev.Model.Name == "boom" {
return errors.New("hook abort")
}
if op.Model.Slug == "" {
op.Model.Slug = "slug-" + op.Model.Name
if ev.Model.Slug == "" {
ev.Model.Slug = "slug-" + ev.Model.Name
}
return nil
}
Expand Down
6 changes: 3 additions & 3 deletions conformance/ormsuite/multitenant_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ type Doc struct {
func (Doc) TableName() string { return "docs" }

// BeforeCreate stamps the tenant from context when the caller left it unset.
func (d *Doc) BeforeCreate(ctx context.Context, op *orm.Op[Doc]) error {
if op.Model.TenantID == 0 {
func (d *Doc) BeforeCreate(ctx context.Context, ev *orm.Event[Doc]) error {
if ev.Model.TenantID == 0 {
if tid, ok := tenantFrom(ctx); ok {
op.Model.TenantID = tid
ev.Model.TenantID = tid
}
}
return nil
Expand Down
75 changes: 75 additions & 0 deletions dialect/dialect.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,81 @@ type ForeignKeyIntrospector interface {
ForeignKeysQuery() string
}

// SearchKind distinguishes the sidecar kinds a [SearchProvisioner] builds.
type SearchKind int

const (
// SearchFullText is a full-text sidecar (e.g. SQLite FTS5).
SearchFullText SearchKind = iota
// SearchVector is a nearest-neighbour vector sidecar (e.g. sqlite-vec vec0).
SearchVector
)

// SearchSpec is a dialect-neutral description of one search sidecar, resolved
// from a model's declared index (columns already mapped to their SQL names). It
// is the hand-off type between the orm front-end and a [SearchProvisioner]
// backend, so the dialect package needs no dependency on orm.
type SearchSpec struct {
Kind SearchKind
Name string // sidecar table name
Table string // base table the sidecar mirrors
Columns []string // indexed columns (text columns for full-text; the embedding column for vector)
PKColumn string // base-table primary-key column the sidecar is keyed by
PKType string // canonical Go type of the PK ("int64", "string", …)
Sync string // resolved sync strategy: "triggers" or "hooks"

// Full-text options.
Tokenizer string
Prefix []int
Detail string // "full" | "column" | "none"
Content string // "external" (default) | "intable" | "contentless"

// Vector options.
Dim int
Metric string // distance token: "l2" | "cosine" | "l1" | "hamming"
Encoding string // "float32" (default) | "int8" | "bit"
}

// RowidKeyed reports whether the sidecar is keyed by the table's implicit integer
// rowid — true for any integer primary key (int, int32, uint, …, not just int64),
// since SQLite aliases an integer PK to the rowid. A non-integer PK (e.g. string)
// is keyed by an explicit key column instead.
func (s SearchSpec) RowidKeyed() bool {
switch s.PKType {
case "int", "int8", "int16", "int32", "int64",
"uint", "uint8", "uint16", "uint32", "uint64":
return true
}
return false
}

// SearchProvisioner is an optional capability: a backend that supports search
// sidecars (full-text / vector) returns the DDL to create or drop one. The
// methods only build SQL — the caller (orm's AutoMigrate) executes it — so the
// dialect stays free of any session or orm dependency. Creation DDL must be
// idempotent (IF NOT EXISTS); drop DDL must tolerate a missing sidecar.
type SearchProvisioner interface {
ProvisionSearchSQL(spec SearchSpec) ([]string, error)
DropSearchSQL(spec SearchSpec) ([]string, error)
}

// SearchStmt is one SQL statement plus its bind arguments, produced by a
// [SearchRowSyncer] to maintain a sidecar for a single row.
type SearchStmt struct {
SQL string
Args []any
}

// SearchRowSyncer is an optional capability for keeping a hook-synced sidecar in
// step from the ORM write path — used when a sidecar is not trigger-synced (e.g.
// a vector whose embedding is sidecar-only, not a stored base column). value
// carries the indexed payload for the row: a vector embedding as []float32, or a
// pre-encoded []byte.
type SearchRowSyncer interface {
UpsertSearchRowSQL(spec SearchSpec, key any, value any) ([]SearchStmt, error)
DeleteSearchRowSQL(spec SearchSpec, key any) ([]SearchStmt, error)
}

// Dialect is the contract each backend implements. AppendPlaceholder is an
// explicit, position-aware bind-var writer (sqlite/mysql -> "?", pg -> "$n",
// mssql -> "@pN"), so a generics-first builder emits the right placeholder
Expand Down
49 changes: 49 additions & 0 deletions dialect/sqlite/plural_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package sqlite_test

import (
"context"
"path/filepath"
"testing"

"liteorm.org/dialect/sqlite"
"liteorm.org/orm"
"liteorm.org/query"
)

type plainWidget struct {
ID int64
Name string
}

// TestPluralTableNames_OrmQueryAgree locks the fix that the plural-table-name
// setting applies to BOTH front-ends: orm.AutoMigrate creates the pluralized
// table and query.Select must read from the same one (not the singular).
func TestPluralTableNames_OrmQueryAgree(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(filepath.Join(t.TempDir(), "plural.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })

orm.UsePluralTableNames(true)
t.Cleanup(func() { orm.UsePluralTableNames(false) })

// orm side: provisions "plain_widgets".
if err := orm.AutoMigrate[plainWidget](ctx, db); err != nil {
t.Fatalf("AutoMigrate: %v", err)
}
if err := orm.NewRepo[plainWidget](db).Create(ctx, &plainWidget{Name: "gear"}); err != nil {
t.Fatalf("create: %v", err)
}

// query side: if it targeted the singular "plain_widget" it would error with
// "no such table"; agreement means it reads the row orm wrote.
got, err := query.Select[plainWidget](db).All(ctx)
if err != nil {
t.Fatalf("query.Select under plural naming: %v (orm and query disagree on the table name)", err)
}
if len(got) != 1 || got[0].Name != "gear" {
t.Fatalf("query.Select returned %v, want one widget 'gear'", got)
}
}
23 changes: 23 additions & 0 deletions dialect/sqlite/regexp.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package sqlite

import "gosqlite.org/ext/regexp"

// WhereRegex returns a query-builder WHERE fragment (and its bind args) that
// matches column against an RE2 regular expression. When pattern is left-anchored
// it prepends a GLOB prefix so SQLite can range-scan an index on column and run
// REGEXP only on the survivors; an unanchored pattern falls back to a plain
// REGEXP filter. Pass the result straight to the query builder:
//
// frag, args := sqlite.WhereRegex("title", `^Intro to .* with Go$`)
// rows, err := query.Select[Doc](db).Where(frag, args...).All(ctx)
//
// The REGEXP operator must be registered on the connection — blank-import
// gosqlite.org/ext/regexp/auto (gosqlite registers it globally, so it then works
// through liteorm with no further wiring).
func WhereRegex(column, pattern string) (sql string, args []any) {
prefix := regexp.GlobPrefix(pattern)
if prefix == "" || prefix == "*" { // unanchored or unusable: plain REGEXP
return column + " REGEXP ?", []any{pattern}
}
return column + " GLOB ? AND " + column + " REGEXP ?", []any{prefix, pattern}
}
95 changes: 95 additions & 0 deletions dialect/sqlite/regexp_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package sqlite_test

import (
"context"
"path/filepath"
"strings"
"testing"

_ "gosqlite.org/ext/regexp/auto" // global: registers the RE2 REGEXP operator on every connection
"liteorm.org/dialect/sqlite"
"liteorm.org/query"
)

type account struct {
ID int64
Email string
}

func (account) TableName() string { return "accounts" }

// gosqlite registers scalar functions globally (via a connect hook), so a
// function like REGEXP flows through liteorm with no liteorm-specific glue — this
// is what makes gosqlite's gorm-specific regexp wiring unnecessary here.
func TestRegexp_WorksInQueryPredicate(t *testing.T) {
ctx := context.Background()
db, err := sqlite.Open(filepath.Join(t.TempDir(), "rx.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })

if _, err := db.ExecContext(ctx, `CREATE TABLE accounts (id INTEGER PRIMARY KEY, email TEXT)`); err != nil {
t.Fatal(err)
}
for _, e := range []string{"a@example.com", "b@example.org", "c@example.com"} {
if _, err := db.ExecContext(ctx, `INSERT INTO accounts(email) VALUES (?)`, e); err != nil {
t.Fatal(err)
}
}

got, err := query.Select[account](db).
Where("email REGEXP ?", `^.*@example\.com$`).
All(ctx)
if err != nil {
t.Fatalf("REGEXP query: %v", err)
}
if len(got) != 2 {
t.Fatalf("REGEXP matched %d rows, want 2 (the .com addresses)", len(got))
}
for _, a := range got {
if a.Email[len(a.Email)-4:] != ".com" {
t.Errorf("unexpected match %q", a.Email)
}
}
}

func TestWhereRegex_GlobOptimization(t *testing.T) {
// Anchored pattern -> GLOB prefix + REGEXP residual (index-friendly).
frag, args := sqlite.WhereRegex("email", `^a@`)
if !strings.Contains(frag, "GLOB") || !strings.Contains(frag, "REGEXP") {
t.Errorf("anchored pattern should emit GLOB+REGEXP, got %q", frag)
}
if len(args) != 2 {
t.Errorf("anchored pattern should bind prefix+pattern, got %v", args)
}
// Unanchored -> plain REGEXP, single bind.
frag, args = sqlite.WhereRegex("email", `example`)
if strings.Contains(frag, "GLOB") || len(args) != 1 {
t.Errorf("unanchored pattern should be a plain REGEXP, got %q %v", frag, args)
}

// End-to-end: the optimized clause selects the same rows as plain REGEXP.
ctx := context.Background()
db, err := sqlite.Open(filepath.Join(t.TempDir(), "rx2.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if _, err := db.ExecContext(ctx, `CREATE TABLE accounts (id INTEGER PRIMARY KEY, email TEXT)`); err != nil {
t.Fatal(err)
}
for _, e := range []string{"a@example.com", "ab@example.com", "b@example.com"} {
if _, err := db.ExecContext(ctx, `INSERT INTO accounts(email) VALUES (?)`, e); err != nil {
t.Fatal(err)
}
}
frag, args = sqlite.WhereRegex("email", `^a@`)
got, err := query.Select[account](db).Where(frag, args...).All(ctx)
if err != nil {
t.Fatalf("WhereRegex query: %v", err)
}
if len(got) != 1 || got[0].Email != "a@example.com" {
t.Errorf("WhereRegex `^a@` matched %d rows, want only a@example.com", len(got))
}
}
Loading