Skip to content

Repository files navigation

nlorm

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.


Contents


How it works

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 QueryIntent JSON (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).


Install

go get github.com/coolbit/nlorm

Requires Go 1.25+. The only mandatory dependency is the Anthropic SDK (pulled in for the Claude provider); OpenAI and Ollama providers use raw HTTP.


Quick start

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).


Schema registry

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.

Defining a table

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())

Field access flags

Method Queryable (WHERE) Selectable (SELECT)
(default)
.ReadOnly()
.SelectOnly()
.NoSelect()
.ReadOnly().NoSelect()
.Sensitive() never exposed in LLM context

Field types

FieldTypeString FieldTypeInteger FieldTypeFloat FieldTypeBoolean
FieldTypeDate FieldTypeDateTime FieldTypeTime
FieldTypeEnum FieldTypeJSON FieldTypeUUID

Virtual (computed) fields

schema.Field("revenue", schema.FieldTypeFloat).
    AsVirtual("quantity * unit_price").
    Unit("USD").Build()

Aliases

schema.Field("total_amount", schema.FieldTypeFloat).
    Aliases("revenue", "sales", "gmv").Build()

DB introspection

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 / Postgres

All 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.

Supported dialects

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

Providers

All providers implement the same provider.LLMProvider interface and are interchangeable.

Ollama (local)

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.

Claude (Anthropic)

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)

OpenAI

import "github.com/coolbit/nlorm/provider/openai"

prov := openai.New(os.Getenv("OPENAI_API_KEY"), "gpt-4o")

Middleware

Providers can be wrapped with middleware. The wrappers implement LLMProvider, so they compose freely.

Retry

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.

Rate limiting

prov = middleware.WithRateLimit(prov,
    10,  // requests per second (sustained)
    20,  // burst capacity
)

Response cache

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
}

Composing middleware

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)

Multi-turn sessions

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).


Hooks

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)
        },
    },
}

Explain (dry run)

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.QueryIntent

Observability

FindWithTrace 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 returned

SQL dialects

The 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.


Security model

SQL injection is prevented at two layers:

  1. IR path — all user-supplied values become bind parameters. The IR Builder never interpolates values into the SQL string.
  2. 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() or NoSelect() cannot appear in WHERE or SELECT.
  • Sensitive() fields are never shown in LLM context.
  • MaxRows caps result size regardless of what the LLM requests.
  • RowFilter injects a permanent predicate (tenant isolation, soft-delete).

Example: e-commerce demo

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-seed

REPL 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

Project layout

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)

About

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.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages