simplesql

module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 2, 2026 License: MIT

README

simplesql

CI

simplesql is a CLI code generator that turns Go repository interfaces with SQL comments into fully working database/sql implementations. The CLI entrypoint lives in cmd/simplesql.

You write the interface and the queries. simplesql writes the boilerplate.

Installation

go install github.com/index0h/simplesql/cmd/simplesql@latest
# or
go get -tool github.com/index0h/simplesql/cmd/simplesql

Quick start

1. Define your interface with SQL comments

// internal/repo/user_repository.go
package repo

import "context"

type User struct {
    ID      int    `db:"id"`
    Name    string `db:"name"`
    Enabled bool   `db:"enabled"`
}

type UserRepository interface {
    // @sql SELECT * FROM users WHERE id = :id
    FindById(ctx context.Context, id int) (*User, error)

    // @sql SELECT * FROM users
    // WHERE 1=1
    // {{if enabled != nil}}AND enabled = :enabled{{end}}
    FindAll(ctx context.Context, enabled *bool) ([]*User, error)

    // @sql INSERT INTO users (name, enabled) VALUES (:name, :enabled)
    Create(ctx context.Context, name string, enabled bool) (int, error)

    // @sql DELETE FROM users WHERE id = :id
    DeleteById(ctx context.Context, id int) (int, error)
}

2. Create a config file

# simplesql.yaml
dialect: mysql
repositories:
  internal/repo/UserRepository: internal/repo/user_repository_gen.go

3. Run the generator

simplesql simplesql.yaml

4. Use the generated code

db, _ := sql.Open("mysql", dsn)
cm := querier.NewConnectionManager(db)
repo := repo.NewUserRepository(cm)

user, err := repo.FindById(ctx, 42)
users, err := repo.FindAll(ctx, nil)
id, err := repo.Create(ctx, "Alice", true)

The same cm also drives transactions — see Transactions below.

SQL template syntax

The @sql tag

Every method's query starts at an @sql marker inside its doc comment. Everything before @sql is a plain comment and is ignored by the generator; everything from @sql onward, to the end of the comment block, is the query template. Methods with no @sql tag are skipped — useful for interface methods that aren't backed by a generated query.

// FindById looks up a user by primary key.
// @sql SELECT * FROM users WHERE id = :id
FindById(ctx context.Context, id int) (*User, error)

@sql doesn't have to start the comment or be on its own line — it can follow prose on the same line, or sit on its own line before the query begins on the next.

Bound parameters

Named parameters (:name) are rewritten to the dialect's placeholder at generation time — no runtime parsing.

MySQL / SQLite:  :id  →  ?
Postgres:        :id  →  $1, $2, …
Direct substitution

{{paramName}} injects a parameter directly into the query string. The parameter must be string or *string. Use this for column names, table names, or other structural parts of the query that cannot be bound.

// @sql SELECT {{col}} FROM users
SelectColumn(col string) ([]*User, error)
Conditional blocks

{{if <expr>}}...{{else if <expr>}}...{{else}}...{{end}} generates a Go if statement in the produced code. Any valid Go boolean expression works.

// @sql SELECT * FROM users WHERE 1=1
// {{if name != ""}}AND name = :name{{end}}
// {{if enabled != nil}}AND enabled = :enabled{{end}}
FindAll(name string, enabled *bool) ([]*User, error)

Return type conventions

Method signature Query Behavior
(*T, error) SELECT returns nil, nil when no row is found
([]*T, error) or ([]T, error) SELECT returns empty slice when no rows found
(int, error) INSERT returns last insert ID
(int, error) UPDATE / DELETE returns rows affected
(error) any executes and returns error
() any fire and forget

A slice return can be either a slice of pointers or a slice of values — pick whichever fits; both are scanned the same way:

// @sql SELECT * FROM users
FindAllPointers(ctx context.Context) ([]*User, error)

// @sql SELECT * FROM users
FindAllValues(ctx context.Context) ([]User, error)

Any other signature (a bare, non-pointer single return like (User, error) — unlike slices, there's no value counterpart for the single-row case, since there'd be no sensible zero value for "no row found"; a second return value that isn't error; a param or return type simplesql can't express, like interface{}) is rejected at generation time with a clear error, rather than generating code that silently doesn't match the interface.

On Postgres, (int, error) on an INSERT works by appending RETURNING id to the query and reading it back with QueryRowContext + Scan — Postgres has no LastInsertId() equivalent. This assumes the table's primary key column is named id.

Context support

If the first parameter is context.Context, it is forwarded to QueryContext/ExecContext. Otherwise context.Background() is used.

// WITH context
FindById(ctx context.Context, id int) (*User, error)

// WITHOUT context — context.Background() is used internally
FindById(id int) (*User, error)

Result scanning

Rows are scanned by matching SQL column names to struct fields using the db tag.

type User struct {
    ID      int    `db:"id"`
    Name    string `db:"name"`
    Enabled bool   `db:"enabled"`
}

For a join, the result struct can embed each entity directly instead of redeclaring every column flat — embedded struct fields are flattened recursively when matching columns:

type Balance struct {
    UserID int `db:"user_id"`
    Amount int `db:"amount"`
}

type UserBalance struct {
    User
    Balance
}

// @sql SELECT u.id, u.name, u.enabled, b.user_id, b.amount FROM users u
// INNER JOIN balances b ON b.user_id = u.id
// WHERE u.id = :id
FindWithBalance(ctx context.Context, id int) (*UserBalance, error)

Config reference

dialect: mysql          # mysql | postgres | sqlite — anything else is a config error

repositories:
  # <relative-dir>/<InterfaceName>: <output-file>
  internal/repo/UserRepository: internal/repo/user_repository_gen.go
  internal/repo/OrderRepository: internal/repo/order_repository_gen.go

  # Multiple interfaces can share one output file
  internal/repo/TagRepository: internal/repo/misc_gen.go
  internal/repo/AuditRepository: internal/repo/misc_gen.go

Paths are relative to the config file's directory.

To customise the generated struct name add a struct_name field:

repositories:
  internal/repo/UserRepository:
    output: internal/repo/user_repository_gen.go
    struct_name: userRepo

Error handling

All errors returned from the database are wrapped with errors.WithStack from github.com/cockroachdb/errors, preserving full stack traces.

Transactions

The generated constructor takes a *querier.ConnectionManager, not a *sql.DB/*sql.Tx directly. Every generated method calls cm.DB(ctx) to get its querier.Querier, which resolves to whichever transaction (if any) ctx carries — so the same repository instance transparently works both inside and outside a transaction, with no need to construct a second instance around a *sql.Tx.

Transactions are started through cm.StartTransaction, which commits if the callback returns nil and rolls back (returning the combined error) otherwise:

cm := querier.NewConnectionManager(db)
repo := repo.NewUserRepository(cm)

err := cm.StartTransaction(ctx, func(ctx context.Context) error {
    id, err := repo.Create(ctx, "Alice", true)
    if err != nil {
        return err
    }
    _, err = repo.DeleteById(ctx, id)
    return err // nil commits, non-nil rolls back
})

StartTransaction is reentrant: if ctx already carries a transaction (e.g. an outer StartTransaction call), a nested call runs its callback directly against that same transaction instead of opening a second one, so helper functions that themselves wrap calls in StartTransaction compose safely whether or not they're already inside one.

If the callback panics, StartTransaction rolls back the transaction before repropagating the panic — a panicking callback never leaves the transaction open.

Testing

go test ./...              # unit tests
go test -race ./...        # unit tests with the race detector

examples/{mysql,postgres,sqlite}/ is a fuller worked reference: the same repository interface generated for all three dialects, exercising every return kind, bound params, direct substitution, conditionals, transactions, and joins scanned into an embedded-struct result. Its tests run against real databases and are gated behind a functional build tag, so they're excluded from the commands above:

docker compose up -d --wait      # starts MySQL + Postgres (SQLite needs no container)
go test -tags functional ./examples/...
docker compose down

Directories

Path Synopsis
cmd
simplesql command
examples

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL