nlorm = natural language ORM — a Go library that lets you query a SQL database using natural language. It translates plain text into parameterized SQL via an LLM backend, validates the result against your schema, and executes it safely.
users, err := nlorm.Find[User](db, "find all users from Shanghai, ordered by signup date")Personal project — proof-of-concept quality, not production-hardened.
- How it works
- Install
- Quick start
- Schema registry
- DB introspection
- Providers
- Middleware
- Multi-turn sessions
- Hooks
- Explain (dry run)
- Observability
- SQL dialects
- Security model
- Example: e-commerce demo
Natural language
│
▼
Translator ──► (LLM)
│ ╲
│ ╲ self-repair loop (up to N attempts)
│ ╱
│◄─────────────
│
┌────┴────────────────────────────────┐
│ mode = "ir" mode = "sql" │
│ QueryIntent JSON raw SQL string │
│ │ │ │
│ IR Builder SQL Guard │
│ (validates & (blocks writes) │
│ builds SQL) │ │
└────┬────────────────────┘ │
│ │
▼
Executor → database/sql → results
Dual path:
- IR path — LLM produces a structured
QueryIntentJSON (table, filters, joins, order, limit). The IR Builder validates every field name and enum value against the schema registry, then renders parameterized SQL. All values become bind parameters; no string interpolation. - SQL path — for queries the LLM decides are too complex for the IR (aggregations, subqueries). Output passes through a tokenizer-based SQL Guard that blocks any mutation statement before execution.
Self-repair loop: if the LLM output fails schema validation, the error is fed back as a follow-up message and the LLM retries (default up to 3 attempts).
go get github.com/coolbit/nlormRequires Go 1.25+. The only mandatory dependency is the Anthropic SDK (pulled in for the Claude provider); OpenAI and Ollama providers use raw HTTP.
package main
import (
"database/sql"
"fmt"
"log"
"github.com/coolbit/nlorm"
"github.com/coolbit/nlorm/provider/ollama"
"github.com/coolbit/nlorm/schema"
_ "modernc.org/sqlite"
)
type User struct {
ID int `db:"id"`
Name string `db:"name"`
City string `db:"city"`
}
func main() {
// 1. Register your schema
reg := schema.NewRegistry()
reg.Register(schema.Table("users").
Describe("Application users").
WithFields(
schema.Field("id", schema.FieldTypeInteger).Build(),
schema.Field("name", schema.FieldTypeString).Build(),
schema.Field("city", schema.FieldTypeString).Build(),
).
MaxRows(100).
Build())
// 2. Open your database
sqlDB, _ := sql.Open("sqlite", "app.db")
// 3. Choose a provider (Ollama, Claude, or OpenAI)
prov := ollama.New("qwen2.5:7b")
// 4. Create the nlorm handle
db := nlorm.New(sqlDB, prov, reg)
// 5. Query
users, err := nlorm.Find[User](db, "find all users from Shanghai")
if err != nil {
log.Fatal(err)
}
for _, u := range users {
fmt.Println(u.Name, u.City)
}
}Find[T] maps rows to T using db: struct tags (falling back to json: tags, then snake_case field names).
The registry is the single source of truth for what the LLM is allowed to ask about. It controls which tables and fields are visible, queryable, and selectable.
reg := schema.NewRegistry()
reg.Register(schema.Table("orders").
Label("Orders").
Describe("Customer purchase records").
WithFields(
schema.Field("id", schema.FieldTypeInteger).Build(),
schema.Field("status", schema.FieldTypeEnum).
WithEnum(
schema.EnumValue{Value: "pending", Label: "Pending"},
schema.EnumValue{Value: "paid", Label: "Paid"},
schema.EnumValue{Value: "shipped", Label: "Shipped"},
schema.EnumValue{Value: "delivered", Label: "Delivered"},
schema.EnumValue{Value: "cancelled", Label: "Cancelled"},
).Build(),
schema.Field("total_amount", schema.FieldTypeFloat).
Unit("USD").
Describe("Sum of all line items").Build(),
schema.Field("internal_note", schema.FieldTypeString).
ReadOnly().NoSelect().Build(), // not queryable, not selectable
schema.Field("created_at", schema.FieldTypeDateTime).Build(),
).
WithRelation(schema.Relation{
Type: schema.BelongsTo,
Table: "users",
ForeignKey: "user_id",
ReferenceKey: "id",
}).
MaxRows(500). // hard cap on rows returned
RowFilter("deleted_at IS NULL"). // always ANDed in (soft-delete, tenant isolation)
Build())| Method | Queryable (WHERE) | Selectable (SELECT) |
|---|---|---|
| (default) | ✓ | ✓ |
.ReadOnly() |
✗ | ✓ |
.SelectOnly() |
✗ | ✓ |
.NoSelect() |
✓ | ✗ |
.ReadOnly().NoSelect() |
✗ | ✗ |
.Sensitive() |
— | never exposed in LLM context |
FieldTypeString FieldTypeInteger FieldTypeFloat FieldTypeBoolean
FieldTypeDate FieldTypeDateTime FieldTypeTime
FieldTypeEnum FieldTypeJSON FieldTypeUUID
schema.Field("revenue", schema.FieldTypeFloat).
AsVirtual("quantity * unit_price").
Unit("USD").Build()schema.Field("total_amount", schema.FieldTypeFloat).
Aliases("revenue", "sales", "gmv").Build()Instead of hand-writing the registry, let nlorm read the live schema from the database:
reg, err := schema.IntrospectDB(ctx, db, schema.SQLite) // or MySQL / PostgresAll tables and columns are populated automatically. Every field defaults to Queryable=true and Selectable=true. Use Override to patch the result without rewriting the whole schema:
reg.Override("users", func(t *schema.TableOverride) {
t.Label("Users").Describe("Registered users").MaxRows(500)
t.Field("password_hash").ReadOnly().NoSelect()
t.Field("email").Sensitive()
t.Field("status").WithEnum(
schema.EnumValue{Value: "active", Label: "Active"},
schema.EnumValue{Value: "inactive", Label: "Inactive"},
)
})Override returns an error if the table is not registered. Unknown field names in Field(...) are silently ignored (no-op), so the call is safe even if the column was renamed.
| Constant | Database | Enum support |
|---|---|---|
schema.SQLite |
SQLite 3 | — (TEXT columns only) |
schema.MySQL |
MySQL 8+ | ENUM(...) column types |
schema.Postgres |
PostgreSQL 12+ | user-defined ENUM types |
All providers implement the same provider.LLMProvider interface and are interchangeable.
import "github.com/coolbit/nlorm/provider/ollama"
prov := ollama.New("qwen2.5:7b") // default: http://localhost:11434
prov := ollama.New("llama3.2").WithBaseURL("http://192.168.1.10:11434")Recommended models (good at structured JSON output): qwen2.5:7b, llama3.2, mistral.
import (
"github.com/coolbit/nlorm/provider/claude"
"github.com/anthropics/anthropic-sdk-go"
)
// Reads ANTHROPIC_API_KEY from environment
prov := claude.New(anthropic.ModelClaude3_5SonnetLatest)import "github.com/coolbit/nlorm/provider/openai"
prov := openai.New(os.Getenv("OPENAI_API_KEY"), "gpt-4o")Providers can be wrapped with middleware. The wrappers implement LLMProvider, so they compose freely.
import "github.com/coolbit/nlorm/provider/middleware"
prov = middleware.WithRetry(prov, middleware.RetryConfig{
MaxAttempts: 3,
BaseDelay: 500 * time.Millisecond,
MaxDelay: 10 * time.Second,
Jitter: 0.2,
})
// or use the default config:
prov = middleware.WithRetry(prov, middleware.DefaultRetryConfig)Retries on transient network errors and HTTP 429/500/502/503.
prov = middleware.WithRateLimit(prov,
10, // requests per second (sustained)
20, // burst capacity
)import "github.com/coolbit/nlorm/cache"
c := cache.NewMemoryCache(1000) // 1 000-entry LRU; 0 = unlimited
prov = middleware.WithCache(prov, c, 5*time.Minute)Cache key is SHA-256 of the full request (system prompt + messages + schema). Identical natural-language queries on identical schemas hit the cache without calling the LLM.
Implement cache.Cache to plug in Redis or any other backend:
type Cache interface {
Get(ctx context.Context, key string) (value string, found bool, err error)
Set(ctx context.Context, key string, value string, ttl time.Duration) error
Delete(ctx context.Context, key string) error
}prov := ollama.New("qwen2.5:7b")
prov = middleware.WithRetry(prov, middleware.DefaultRetryConfig)
prov = middleware.WithRateLimit(prov, 5, 10)
prov = middleware.WithCache(prov, cache.NewMemoryCache(500), time.Hour)
db := nlorm.New(sqlDB, prov, reg)A Session accumulates query intent across turns. Each follow-up sends only an IntentDelta to the LLM (~80% fewer tokens than re-sending the full query).
sess := db.NewSession()
ctx := context.Background()
// Turn 1 — full translation
rows, trace, err := sess.Find(ctx, "show paid orders, newest first, limit 20")
// Turn 2 — delta only ("add filter: amount > 5000")
rows, trace, err = sess.Find(ctx, "only orders over $5 000")
// Turn 3 — delta only ("change limit to 5")
rows, trace, err = sess.Find(ctx, "just the top 5")Session.Find returns []map[string]any. Use nlorm.Find[T] for typed results from stateless one-shot queries.
Sessions auto-compress history when it exceeds 10 turns (keeps the 5 most recent in full, summarises the rest).
Optional callbacks at four points in the pipeline, set per DB instance in Options:
db := nlorm.New(sqlDB, prov, reg, nlorm.Options{
Hooks: nlorm.Hooks{
// Rewrite or reject the query before it reaches the LLM.
BeforeTranslate: func(ctx context.Context, nl string) (string, error) {
return strings.ToLower(nl), nil // normalise
},
// Inspect (or abort) the translation result before SQL is built.
AfterTranslate: func(ctx context.Context, r *translator.TranslationResult) error {
log.Printf("mode=%s table=%s", r.Mode, r.IR.From)
return nil
},
// Rewrite or reject the SQL before execution (e.g. inject tenant ID).
BeforeExecute: func(ctx context.Context, sql string, params []any) (string, []any, error) {
return sql, params, nil
},
// Observe execution outcome (rowCount is -1 on error).
AfterExecute: func(ctx context.Context, sql string, params []any, rowCount int, err error) {
metrics.Record("nlorm.query", rowCount, err)
},
},
})An OnRepair callback in TranslatorConfig fires whenever the self-repair loop retries:
nlorm.Options{
TranslatorConfig: translator.Config{
MaxRepairAttempts: 2,
OnRepair: func(ctx context.Context, attempt int, hints []translator.RepairHint) {
log.Printf("repair attempt %d: %+v", attempt, hints)
},
},
}Translate a query and inspect the SQL it would produce, without executing it:
exp, err := nlorm.Explain(db, "find all paid orders from last week")
fmt.Println(exp.Mode) // "ir" or "sql"
fmt.Println(exp.SQL) // SELECT ...
fmt.Println(exp.Params) // [paid ...]
fmt.Println(exp.IR) // *translator.QueryIntentFindWithTrace returns a QueryTrace alongside results:
users, trace, err := nlorm.FindWithTrace[User](db, "active users in Beijing")
fmt.Println(trace.Mode) // "ir"
fmt.Println(trace.FinalSQL) // SELECT ...
fmt.Println(trace.Params) // [active Beijing]
fmt.Println(trace.Latency.TranslationMs) // LLM call duration
fmt.Println(trace.Latency.BuildMs) // SQL build duration
fmt.Println(trace.Latency.ExecutionMs) // DB round-trip duration
fmt.Println(trace.Tokens.InputTokens) // tokens consumed
fmt.Println(trace.RowCount) // rows returnedThe IR Builder defaults to MySQL syntax. Use builder.NewWithDialect for other databases:
import "github.com/coolbit/nlorm/builder"
b := builder.NewWithDialect(reg, builder.PostgreSQL)
b := builder.NewWithDialect(reg, builder.SQLite)| Dialect | Identifier quoting | Placeholders |
|---|---|---|
builder.MySQL |
`name` |
? |
builder.PostgreSQL |
"name" |
$1, $2, … |
builder.SQLite |
"name" |
? |
Implement builder.Dialect to add a custom dialect.
SQL injection is prevented at two layers:
- IR path — all user-supplied values become bind parameters. The IR Builder never interpolates values into the SQL string.
- SQL path — a tokenizer-based guard rejects any statement containing mutation keywords (
INSERT,UPDATE,DELETE,DROP,CREATE,ALTER,TRUNCATE,GRANT,REVOKE,EXEC,CALL,UNION,INTERSECT,EXCEPT). Keywords inside string literals and comments are ignored.
Schema-level access control is enforced before SQL is generated:
- Fields marked
ReadOnly()orNoSelect()cannot appear in WHERE or SELECT. Sensitive()fields are never shown in LLM context.MaxRowscaps result size regardless of what the LLM requests.RowFilterinjects a permanent predicate (tenant isolation, soft-delete).
examples/ecommerce is a self-contained module that seeds a 200 000-row SQLite database (6 tables: categories, products, users, orders, order_items, reviews) and demonstrates both scripted queries and an interactive REPL.
cd examples/ecommerce
# First run seeds the DB (~2s), then runs scripted demo queries
go run .
# Interactive REPL
go run . -repl
# Options
go run . -repl -model llama3.2
go run . -repl -db ~/myshop.db
go run . -reset # drop and re-seedREPL commands:
| Input | Action |
|---|---|
| (natural language) | Translate and execute |
/new |
Clear session context, start fresh |
/explain <query> |
Show SQL without executing |
/stat |
Table row counts |
/help |
Command list |
/quit or Ctrl+D |
Exit |
github.com/coolbit/nlorm
├── nlorm.go Public API: New, Find, FindWithTrace, Explain, Session
├── schema/
│ ├── registry.go TableMeta, FieldMeta, DefaultRegistry
│ ├── field.go Fluent field builder
│ ├── table.go Fluent table builder
│ ├── introspect.go IntrospectDB (SQLite / MySQL / Postgres)
│ ├── override.go Override API for post-introspection patching
│ └── reflect.go FromStruct helper
├── translator/ LLM translation, QueryIntent IR, IntentDelta, self-repair
├── builder/ IR → SQL, SQL Guard, Dialect abstraction
├── provider/
│ ├── provider.go LLMProvider interface
│ ├── claude/ Anthropic Claude (SDK)
│ ├── openai/ OpenAI (raw HTTP)
│ ├── ollama/ Ollama local inference (raw HTTP)
│ └── middleware/ Retry, RateLimit, Cache
├── cache/ Cache interface + MemoryCache
├── executor/ database/sql wrapper, MapRows[T]
├── session/ Session, Manager, turn compression
├── docker/ Docker Compose + Dockerfile for integration tests
├── scripts/ test-integration.sh one-click test runner
└── examples/
└── ecommerce/ Standalone demo (own go.mod)