kanna

kanna / orm

kanna-orm

v0.0.7 Go 1.26 pkg.go.dev

CI codecov Go Reference License: MIT Release 日本語

kanna-orm writes the database code behind your repository interfaces. You declare the interface, and a scope type for each table it reads; kanna-orm writes the implementation, and a method on the scope type for each way a query can be narrowed, ordered, paged, or made to load what a row refers to. The SQL stays in the generated code as literals, and a column or a type that does not match the schema stops generation, or compilation, rather than a request.

Part of the kanna family of code generators. Requires Go 1.26, and PostgreSQL through pgx or database/sql, or MySQL 8.4 or later through database/sql.

Install

go get -tool github.com/go-kanna/orm/cmd/kanna-orm

The generated code imports github.com/go-kanna/orm, which the line above already requires: a scope type holds an orm.Of, and a row that is not there is orm.ErrNotFound. It depends on nothing outside the standard library; the driver is yours.

Use

kanna-orm reads the schema from a database, not from your migrations. Migrate one, and describe it into a snapshot at the root of your module:

go tool kanna-orm describe -database-url "$DATABASE_URL" ./...

Commit kanna-orm.snapshot.json along with the migration: the change to the schema shows in review, and generation reads the snapshot alone, so it needs no database.

Name the package of your models, and kanna-orm declares a scope type for each of them that a table is named after:

package scopes

//go:generate go tool kanna-orm

//kanna:scopes ../model

and the repository the rest of your code depends on:

package repository

//go:generate go tool kanna-orm -destination ../postgres

//kanna:repository
type OrderRepository interface {
	Get(ctx context.Context, id model.OrderID, s ...scopes.Order) (model.Order, error)
	List(ctx context.Context, s ...scopes.Order) ([]model.Order, error)
	Insert(ctx context.Context, o model.Order) (model.Order, error)
	Delete(ctx context.Context, id model.OrderID) error
}

With the model as your domain has it:

package model

type Order struct {
	ID         OrderID
	CustomerID CustomerID
	Status     OrderStatus
	PlacedAt   time.Time
	Memo       *string `orm:"note"`
	Lines      []OrderLine
}

go generate ./... writes orm_gen.go next to the scope types:

// Code generated by kanna-orm. DO NOT EDIT.

// Orders returns a scope over all the rows of orders.
func Orders() Order { return Order{} }

// StatusNe narrows to the rows whose status is not v.
func (s Order) StatusNe(v model.OrderStatus) Order {
	return Order{s.q.Where(`status <> `, v)}
}

// PlacedAtDesc orders the rows by placed_at, latest first.
func (s Order) PlacedAtDesc() Order {
	return Order{s.q.OrderBy(`placed_at`, true)}
}

// WithLines loads Lines, from order_lines by order_id.
func (s Order) WithLines(sub ...OrderLine) Order {
	// ...
}

// ...a method for each column and relation, and Limit, Offset, Or, Not, After, Before, Cursor, and AfterCursor.

and the implementation into ../postgres:

// Code generated by kanna-orm. DO NOT EDIT.

// OrderRepository implements repository.OrderRepository on PostgreSQL through pgx.
type OrderRepository struct{ db DBTX }

func NewOrderRepository(db DBTX) OrderRepository { return OrderRepository{db: db} }

func (r OrderRepository) Insert(ctx context.Context, o model.Order) (model.Order, error) {
	const q = `INSERT INTO orders (id, customer_id, status, placed_at, note)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, customer_id, status, placed_at, note`
	var row model.Order
	err := r.db.QueryRow(ctx, q, o.ID, o.CustomerID, o.Status, o.PlacedAt, o.Memo).
		Scan(&row.ID, &row.CustomerID, &row.Status, &row.PlacedAt, &row.Memo)
	if err != nil {
		return model.Order{}, fmt.Errorf("insert into orders: %w", err)
	}
	return row, nil
}

// ...

Callers chain the scope:

orders, err := repo.List(ctx, scopes.Orders().
	StatusNe(model.OrderCanceled).
	PlacedAtGte(lastMonth).
	WithLines().
	PlacedAtDesc().
	Limit(20))

A declaration that does not match the schema stops generation, rather than the first request that meets it:

$ go generate ./...
./scopes.go:6:1: error: Order: the snapshot has no table order
  did you mean orders?
../model/order.go:12:1: error: Order.Memo: orders.note may be NULL, which a string cannot hold
  declare it as *string
./order_repository.go:12:2: error: ListByCustomer: orders has no column for the parameter custmerID
  did you mean customerID (customer_id)?
./order_repository.go:14:2: error: GetByStatus: status is not a unique key of orders, so Get cannot return one row
  use List, or add a unique index on orders (status)

The repository interface names scopes.Order, which does not exist until //kanna:scopes generates it, so the code does not compile before the first run. That does not stop the run: kanna-orm knows the type from that directive, and go generate can reach the two packages in either order. Code may also call a method that is generated before it is: errors saying only that a method the run generates is missing do not stop it.

The snapshot

describe connects to the database and records, for each table, its columns, with their types, whether they may be NULL, and whether the database assigns them itself, as an identity, serial, or AUTO_INCREMENT column does; its primary key; its unique keys; and its foreign keys. It also prepares each hand-written query and scope in the packages it is given, and in the directories they name that hold no package yet, as the destination of the repositories does before their first generation, and records the types of their parameters and of their result columns. Nothing it runs writes.

The database is the one -database-url names, or else $DATABASE_URL:

  • postgres://user:password@host:5432/database is PostgreSQL, through pgx. The tables are those of the current schema, which ?search_path= chooses.
  • mysql://user:password@host:3306/database is MySQL, through go-sql-driver/mysql, which takes the options after ?. The tables are those of the database the URL names.

The types are what the database itself answers, so kanna-orm has no SQL parser of its own to disagree with it. MySQL does not describe the result of a statement it prepares, so there, describe reads the columns of the statement wrapped as SELECT t.* FROM (...) AS t LIMIT 0, which returns no rows.

The snapshot records what generation needs, and no more, so describe -check tells that the code was generated from the schema there is, not that two schemas are the same:

Recorded Not recorded
a column's type, with the length or precision it declares: varchar(100), numeric(10,2) what a default is, only that there is one
whether a column may be NULL, has a default, or is assigned by the database CHECK constraints
primary keys, unique keys, and foreign keys the conditions of partial indexes
the types of each hand-written query's parameters and result columns triggers, collations, and comments

A migration changing only what is not recorded leaves the snapshot as it was. To review those, or to compare two databases, use the tools of your migrations.

The snapshot is JSON with one column to a line, in a fixed order, so that a migration shows as the lines it changes:

     "orders": {
       "columns": [
         {"name": "id", "type": "uuid", "not_null": true},
         {"name": "customer_id", "type": "uuid", "not_null": true},
         {"name": "status", "type": "text", "not_null": true},
-        {"name": "placed_at", "type": "timestamptz", "not_null": true}
+        {"name": "placed_at", "type": "timestamptz", "not_null": true},
+        {"name": "note", "type": "text"}
       ],

A module has one snapshot, kanna-orm.snapshot.json at its root, which generation finds by walking up from the package. A module using two databases describes each into a file of its own, and names it with -snapshot on both commands.

In CI, describe a freshly migrated database with -check, which fails when the snapshot is out of date:

$ go tool kanna-orm describe -check ./...
./kanna-orm.snapshot.json: error: out of date with the database
  orders.note (text) is in the database but not in the snapshot
  run go tool kanna-orm describe to update it

Scope types

A scope type is a struct whose only field is an unexported orm.Of[M], M being the model the table's rows are read into, so that nothing but its methods narrows it. Each table kanna-orm reads or writes has one, and each model belongs to one table: the scope type is the only place where the two are tied together.

//kanna:scopes, followed by packages of the module, by their import paths or by paths relative to the file, as ../model, declares one, in orm_gen.go, for each struct of those packages that a table of the snapshot is named after: the struct's name in snake case with its last word made plural, as order_lines for OrderLine, categories for Category, and people for Person, or else as it is. A struct no table is named after, such as a value type, gets none, and neither does a table no struct is named after, such as one between two others. Methods of your own are written on the type as on any other, in another file of the package.

A table named otherwise, or a scope type of a name of your own, is declared by hand, and wins over the one //kanna:scopes would imply of its model or its table:

//kanna:scope table=people
type Person struct{ q orm.Of[model.Person] }

A model that a relation reaches but no scope type names stops generation:

../model/order_line.go:9:1: error: OrderLine.Product: no scope type is declared for model.Product
  declare one where products are queried: //kanna:scope table=products

For each scope type, kanna-orm writes:

  • the type, when //kanna:scopes implies it;
  • a function returning the empty scope, named after the table: Orders() for orders. When the name is the type's own, or is taken otherwise, declare the type by hand and choose another with func=, as in //kanna:scope table=person func=People;
  • a method for each column, each relation, and each hand-written scope;
  • Limit, Offset, Or, Not, After, Before, Cursor, and AfterCursor.

Every method returns a new scope and leaves its receiver as it was, so a scope built halfway can be shared: base.Limit(10) and base.Limit(20) do not see each other's limit. The zero value, Order{}, is the empty scope too.

Columns and fields

A field holds the column named as the field is, in snake case: CustomerID holds customer_id, and PlacedAt holds placed_at. The conversion is mechanical, so a field named otherwise says so with a tag:

Tag Meaning
(none) the column named as the field is, in snake case
orm:"note" the column note
orm:"-" not a column: kanna-orm neither reads nor writes the field
orm:"fk=order_id" on a relation, the foreign key to follow when more than one could
orm:"fk=order_lines.order_id" the same, naming its table too, for when the column alone does not tell

A field with no column, a column that may be NULL held by a field that cannot be nil, and, on a table something inserts into, a column the database requires a value for and the model has no field for, all stop generation. A column the model has no field for is otherwise left alone.

A field's type must be one the driver reads the column's type into: an integer type for bigint, a string for text, time.Time for timestamptz or datetime, []byte for bytea or blob, uuid.UUID of Go 1.27 or a string for uuid, and a slice of those for an array of PostgreSQL, which MySQL has none of. A type with Scan and Value methods converts itself, and fits any column, and a type of the database kanna-orm does not know, such as an enum or json, is taken at the field's word.

A field read from a column must also hold every value the column can: a row holding a value beyond the field fails to scan, and a float32 read from a float8 or double loses precision without a word. So an integer field is at least as wide as the column, int64 for bigint and int32 for integer, signed for a signed column, and unsigned or a bit wider for an unsigned one: uint64 for bigint unsigned, int64 for int unsigned. int and uint are taken to be 64 bits wide, as on amd64. Writing needs no such width, as the database rejects a value beyond its column rather than store another, so a field wider than its column, as an int for an integer, is fine, and so is a parameter of any width. The range of numeric is not recorded, so any number reads it.

Methods for columns

The methods are named after the field, so the column note, held by Memo, gives MemoContains. Each column gets the methods its type allows:

Column Methods, for a field Status
any StatusEq, StatusNe, StatusIn, StatusNotIn, StatusAsc, StatusDesc
of an ordered type: number, time, string also StatusLt, StatusLte, StatusGt, StatusGte, StatusBetween
of a string also StatusLike; StatusHasPrefix and StatusContains, which escape % and _; StatusILike on PostgreSQL
that may be NULL also StatusIsNull, StatusIsNotNull

A method takes the field's type: StatusEq takes a model.OrderStatus, and a value type goes to the database through its Value method, as database/sql would send it. The methods matching a pattern take a string, which no value of the type need be, and a column held in a slice other than []byte, an array, has no In or NotIn. The comparisons of a column that may be NULL take the value, not a pointer, so that = NULL, which matches nothing, cannot be written; StatusIsNull says what it would have meant.

Your own scopes

A scope you name is a method you write on the scope type, out of the generated ones. It chains as they do:

// Open narrows to the orders that are not canceled.
func (s Order) Open() Order {
	return s.StatusNe(model.OrderCanceled)
}

// Recent orders by placed_at, latest first, and keeps the first n.
func (s Order) Recent(n int) Order {
	return s.PlacedAtDesc().Limit(n)
}

A search narrows a scope one condition at a time:

s := scopes.Orders().Open().Recent(in.PageSize)
if in.CustomerID != nil {
	s = s.CustomerIDEq(*in.CustomerID)
}
if in.Keyword != "" {
	s = s.MemoContains(in.Keyword)
}
orders, err := repo.List(ctx, s)

A method of yours named as one kanna-orm would generate stops generation instead of being overwritten.

Or and Not

Or matches the rows that any of its scopes match, each scope's own conditions holding together:

// WHERE ... AND ((status = $1) OR (placed_at < $2))
s := scopes.Orders().Or(
	scopes.Orders().StatusEq(model.OrderPending),
	scopes.Orders().PlacedAtLt(cutoff),
)

Not matches the rows its scope does not. A scope given to Or or Not holds conditions only: one with an order, a limit, or a relation to load makes the query return an error. When every alternative is a value of one column, StatusIn says the same more plainly.

Relations

A field holding another model, a pointer to one, or a slice of them is a relation. kanna-orm tells its kind from the snapshot's foreign keys, so nothing more is written for it:

Field Foreign key Kind
Lines []OrderLine order_lines.order_id to orders has many
Customer *Customer orders.customer_id to customers belongs to
Profile *Profile profiles.user_id to users, a unique key has one
Tags []Tag through a table with foreign keys to both, such as user_tags many to many

When more than one foreign key could serve, generation stops and asks for a tag:

../model/order.go:16:1: error: Order.Lines: order_lines has two foreign keys to orders (order_id, replaced_order_id)
  order_lines.order_id to orders
  order_lines.replaced_order_id to orders
  tag the field with the one to follow: orm:"fk=order_id"

A table with foreign keys to both counts as much as a foreign key of the related table: when refunds refers to orders and to order_lines, Lines could be the lines of an order or those refunded, and asks for a tag too. Where the column alone does not tell, as order_id does not here, the tag names its table as well: orm:"fk=order_lines.order_id".

A relation is loaded only when the scope asks for it with its With method, which takes scopes of the related rows: their conditions, their order, and relations of their own to load.

o, err := repo.Get(ctx, id, scopes.Orders().
	WithCustomer().
	WithLines(scopes.OrderLines().
		QuantityGt(0).
		SKUAsc().
		WithProduct()))

On PostgreSQL, each relation costs one query, however many rows there are: the call above runs four, one each for orders, customers, order lines, and products. A relation through a table between costs two, one for the pairs of keys in that table and one for the related rows. The related rows are read by the keys of the rows already read, each key once, with = ANY($1) on PostgreSQL and IN (?, ...) on MySQL, where the keys go a thousand at a time, each thousand in a query of its own: the rows related to one row come in one query, in the order of the scope given for them.

  • A slice that was loaded is never nil: it is empty when nothing is related. A nil slice was not loaded.
  • A pointer is nil both when it was not loaded and when nothing is related. Telling the two apart would take a type of kanna-orm's in your model.
  • A limit or an offset on the related rows would count all of them, not those of each row, so a scope given to a With method that holds one makes the query return an error. To read the first few of each, write the query by hand.
  • Relations are read, never written: Insert and Update write the row's own columns, and a row read back holds them alone.
  • A relation by a foreign key of more than one column is read by its columns as a row: (a, b) IN (SELECT * FROM unnest($1, $2)) on PostgreSQL, and (a, b) IN ((?, ?), ...) on MySQL.

Paging

Limit and Offset page as SQL does. For pages that stay put while rows come and go, give After the last row of a page, and it continues from there in the scope's order:

s := scopes.Orders().Open().PlacedAtDesc().Limit(20)

page, err := repo.List(ctx, s)
// ...
next, err := repo.List(ctx, s.After(page[len(page)-1]))
// WHERE status <> $1 AND (placed_at, id) < ($2, $3)
// ORDER BY placed_at DESC, id DESC LIMIT 20
  • When the order does not end in the primary key, After and Before add it, so that rows equal in the order still come in one. Without it, a page that ends among equal rows repeats or skips some of them, without a word.
  • When every column of the order goes the same way, the condition compares rows, as above. When they do not, and on MySQL, it is spelled out: a < $1 OR (a = $1 AND b < $2).
  • Before goes the other way, to the page before the one a row is the first of. The repository reads that page from its end and hands its rows back in the scope's order.
  • An order by a column that may be NULL makes a query continuing from a row return an error, and so Cursor, since the databases put NULL in different places.

To hand the position to a client, Cursor encodes it as a string, and AfterCursor decodes it into the scope:

s, err := scopes.Orders().Open().PlacedAtDesc().AfterCursor(req.PageToken)
if err != nil {
	return nil, err
}
page, err := h.orders.List(ctx, s.Limit(20))
// ...
res.NextPageToken, err = s.Cursor(page[len(page)-1])

A cursor holds the order it was made in and the row's values in that order, as JSON; an empty one is the start, and one made in another order is an error. Each value is decoded through its type, so a value type checks its rule. A cursor a client has altered changes where the page starts, and nothing else.

Repositories

An interface marked //kanna:repository declares what the code above the database may do with it. Its package runs kanna-orm -destination <dir>, which writes the implementation into orm_gen.go there: a struct named after the interface, its constructor, and the DBTX it runs on, named as sqlc names it.

// DBTX is what the repositories run on: *pgxpool.Pool, *pgx.Conn, or pgx.Tx.
type DBTX interface {
	Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
	Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
	QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}

func NewOrderRepository(db DBTX) OrderRepository

A transaction is begun and committed as it would be without kanna-orm: give the constructor the pgx.Tx, or the *sql.Tx.

Methods

What a method does is told by the verb its name starts with; what follows the verb is yours to choose. Its parameters tell the rest: a parameter of a model type is a row to write, the scopes say how to read, and any other parameter is the column of its name, which the rows must equal.

Verb Declared as Does
Get Get(ctx, id model.OrderID, s ...scopes.Order) (model.Order, error) reads the row whose key the parameters give, or returns orm.ErrNotFound
First First(ctx, s ...scopes.Order) (model.Order, error) reads the first row the scopes match, or returns orm.ErrNotFound
List List(ctx, s ...scopes.Order) ([]model.Order, error) reads every row the scopes match
Count Count(ctx, s ...scopes.Order) (int64, error) counts the rows the scopes match
Exists Exists(ctx, s ...scopes.Order) (bool, error) reports whether any row matches
Insert Insert(ctx, o model.Order) (model.Order, error), or with error alone writes a row, or many
Update Update(ctx, o model.Order) error writes every column but the key, or returns orm.ErrNotFound when no row has the key
Upsert Upsert(ctx, o model.Order) error inserts, or updates the row with the same key, or another unique key
Delete Delete(ctx, id model.OrderID) error, or DeleteStale(ctx, s ...scopes.Order) (int64, error) deletes the row with the key, or the rows the scopes match
// customerID is customer_id: the orders of one customer.
ListByCustomer(ctx context.Context, customerID model.CustomerID, s ...scopes.Order) ([]model.Order, error)

// number is a unique key of orders, so Get reads one row by it.
GetByNumber(ctx context.Context, number model.OrderNumber) (model.Order, error)

// The key of order_lines is (order_id, sku), so Get takes both.
GetLine(ctx context.Context, orderID model.OrderID, sku model.SKU) (model.OrderLine, error)
  • The table is the one whose scope type holds the method's model, so one interface may read and write every table of an aggregate.
  • Several scopes hold together, as if chained.
  • Get stops generation unless its parameters give the primary key or a unique key, every column of it.
  • Count, Exists, and a Delete given scopes use their conditions only; a scope with an order, a limit, or a relation to load makes them return an error rather than ignore it.
  • A Delete given scopes but no condition returns an error rather than delete every row.
  • A Delete returning error alone returns orm.ErrNotFound when it deletes no row, as Update does; one returning (int64, error) returns the number of rows it deleted, none among them.
  • A hand-written query of the method's name takes the place of its verb; a method with neither stops generation.
  • A method you write yourself on the generated struct, in another file of the destination, is left to you; kanna-orm writes the rest of the interface.

A method tells its model by its result, its row, or its scopes, or else by a key of a type declared with the models, as model.OrderID is. One that takes nothing but keys of another type, such as uuid.UUID or int64, tells none, and stops generation unless the interface names the table with table=:

//kanna:repository table=todos
type TodoRepository interface {
	Get(ctx context.Context, id uuid.UUID) (model.Todo, error) // todos, by its result
	Delete(ctx context.Context, id uuid.UUID) error            // todos, by table=
}

table= is the table of the methods that tell none, and of no other: a method that tells its own model reads or writes its table, so an interface of an aggregate still reaches each of its tables.

orm.ErrNotFound comes wrapped with what was not found, so test for it with errors.Is, and turn it into your application's error where you call the repository.

Keyed by a uuid

On Go 1.27 or later, a uuid column is held by the uuid.UUID of the standard library, which pgx and database/sql read and write as it is; before, by a string, or by a type of your own with Scan and Value methods. A table keyed by one is declared as any other, but for table=:

type Todo struct {
	ID    uuid.UUID
	Title string
}

//kanna:repository table=todos
type TodoRepository interface {
	Get(ctx context.Context, id uuid.UUID) (model.Todo, error)
	List(ctx context.Context, s ...scopes.Todo) ([]model.Todo, error)
	Upsert(ctx context.Context, t model.Todo) error
	Delete(ctx context.Context, id uuid.UUID) error
}
  • uuid.UUID is declared with no model, so a method that takes nothing but one tells no table: Delete takes its table from table=, while Get tells its own by its result.
  • A uuid has no order to compare by, so its column gets Eq, Ne, In, NotIn, Asc, and Desc, but neither Lt nor Between. A page still continues from a row by it, and a cursor holds it as its text.
  • A column defaulting to uuidv7() is not one the database assigns: an insert writes the model's ID, the zero UUID included. Give each row its ID in Go, as with uuid.NewV7().
  • MySQL has no uuid type, and kanna-orm does not take uuid.UUID for a binary(16) or a char(36): hold one in a []byte, a string, or a type of your own with Scan and Value methods.

go-kanna/showcase is a to-do service whose to-dos are keyed by uuid.UUID.

Writing

A row is written as the model holds it. Only the columns the database assigns itself, identity, serial, and AUTO_INCREMENT columns, are left out and read back. A zero value is written as a zero value, never taken to mean the column's default: a false for a column defaulting to true stays false. Set created_at and updated_at in your domain, or leave them out of the model for the database's defaults to fill.

An Insert returning the model reads the row back as the database stored it; one returning error alone writes and is done. MySQL reads it back by LAST_INSERT_ID() when the database assigns its primary key, and by its primary key otherwise. MySQL counts the rows an Update changes, not those it finds, so when it changes none, kanna-orm asks whether the row is there before returning orm.ErrNotFound.

Upserting

Upsert meets an existing row by its primary key. To meet it by another unique key, name its columns with //kanna:conflict, and with keep= the columns to leave as they were:

//kanna:repository
type UserRepository interface {
	// UpsertByEmail meets the user of a tenant by their email address.
	//kanna:conflict tenant_id,email keep=created_at
	UpsertByEmail(ctx context.Context, u model.User) (model.User, error)
}
-- PostgreSQL
INSERT INTO users (tenant_id, email, name, created_at)
VALUES ($1, $2, $3, $4)
ON CONFLICT (tenant_id, email) DO UPDATE SET name = EXCLUDED.name
RETURNING id, tenant_id, email, name, created_at

-- MySQL, which then reads back by LAST_INSERT_ID() the row inserted or updated
INSERT INTO users (tenant_id, email, name, created_at)
VALUES (?, ?, ?, ?) AS new
ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id), name = new.name

The columns must be the primary key or a unique key of the table, exactly, and none the database assigns, as an insert never gives those to meet a row by. The row met gets every column but those of the key, the primary key, and those keep= names; with none left, the key is set to itself, so that the row is still read back. MySQL updates on a conflict over any unique key, so a table with unique keys besides the one named gets a warning:

./user_repository.go:14:2: warning: UpsertByEmail: on MySQL, a conflict on (tenant_id, login) also updates the row
  users has unique keys other than (tenant_id, email)

Writing many rows

An Insert or an Upsert given a slice writes every row in it:

InsertLines(ctx context.Context, lines []model.OrderLine) error
-- PostgreSQL: one statement, however many rows
INSERT INTO order_lines (order_id, sku, quantity)
SELECT * FROM unnest($1::uuid[], $2::text[], $3::int[])
  • On PostgreSQL, a result of ([]model.OrderLine, error) reads the rows back with RETURNING.
  • On MySQL, the rows go in VALUES (?, ?, ?), (?, ?, ?), ..., split into several statements when they need more placeholders than one can take. Give the constructor a transaction to have them stand or fall together.
  • MySQL has no RETURNING, so there a result other than error alone stops generation.
  • PostgreSQL rejects an upsert in which two rows have the same key.
  • unnest flattens an array of arrays, so on PostgreSQL a table with an array column stops generation; write its rows one by one.

Hand-written SQL

What the methods cannot say goes into a .sql file. A query becomes the repository method of its name, and is written where that method is implemented, in the package -destination names: the SQL is a detail of the implementation, not of the interface. A scope becomes a method of the scope type it names, and is written next to it. A .sql file with neither a -- query: nor a -- scope: line, such as a migration, is none of kanna-orm's.

repository/repository.go   //kanna:repository interfaces, MonthCounts among their methods
postgres/queries.sql       -- query: MonthCounts
scopes/order_scopes.sql    -- scope: Order.PlacedInMonth(at time.Time)
-- query: MonthCounts
SELECT date_trunc('month', placed_at) AS month, count(*) AS count
FROM orders
WHERE placed_at >= :since
GROUP BY 1
ORDER BY 1;
MonthCounts(ctx context.Context, since time.Time) ([]MonthCount, error)

type MonthCount struct {
	Month time.Time
	Count int64
}
-- PlacedInMonth narrows to the orders placed in the month of at.
-- scope: Order.PlacedInMonth(at time.Time)
date_trunc('month', placed_at) = date_trunc('month', :at)

The generated code holds each as a literal, its parameters numbered for the database:

func (r OrderRepository) MonthCounts(ctx context.Context, since time.Time) ([]MonthCount, error) {
	const q = `SELECT date_trunc('month', placed_at) AS month, count(*) AS count
FROM orders
WHERE placed_at >= $1
GROUP BY 1
ORDER BY 1`
	// ...
}
  • A parameter is : followed by a name, as :since is. A : after another :, a letter, a digit, _, or ] starts none, so a cast such as ::int and a slice such as arr[1:n] are left alone, as is anything in a string, a quoted name, a comment, or a dollar-quoted body. describe prepares what results, so a misreading shows there.
  • A query's parameters take their types from the method, and its result columns go to the fields of their names, tags included; both are checked against what describe recorded. A result of []T is every row, T one row or orm.ErrNotFound, (int64, error) the number of rows affected, and error alone nothing.
  • Each result column needs a name of its own, since the fields take them by name; MySQL cannot even describe a query whose columns share a name. Give them aliases, as in SELECT o.id, c.id AS customer_id.
  • A result column that may be NULL needs a field that can be nil. A field that cannot be nil stops generation where the database says its column may be NULL; where the database cannot tell, the field's type is taken at its word. MySQL tells for every column. PostgreSQL tells only for a column read straight from a table, and as the table declares it, even through an outer join that leaves it NULL: hold such a column, as c.email from LEFT JOIN customers c, in a pointer. kanna-orm's own queries load relations with queries of their own, not joins, so this is a matter for hand-written queries only.
  • A scope has no method declared for it, so its signature is written on its line, and the lines of comment right above that line are its method's doc. MySQL does not tell the types of a statement's parameters, and this way it need not.
  • A statement runs from its line to the next statement's comment or line. The blank lines and the lines of comment it ends in are not part of it, nor is a comment at its very end, so that a scope can end in one.
  • On MySQL, a parameter used twice is passed twice, once for each ?.

Drivers and dialects

The snapshot knows which database it came from, so only the driver is chosen, with -driver:

Database -driver The generated code uses
PostgreSQL pgx, the default pgx.Rows, $1, = ANY($1), RETURNING
PostgreSQL database/sql, through pgx/stdlib *sql.Rows, $1, = ANY($1), RETURNING
MySQL 8.4 or later database/sql, the default, through go-sql-driver/mysql *sql.Rows, ?, IN (?, ...), LAST_INSERT_ID()

The code differs for each, and none of it hides the database behind an abstraction: where PostgreSQL and MySQL differ, the difference is settled when the code is generated.

PostgreSQL MySQL
A name, quoted "x" `x`
Parameters $1, used again where needed ?, one for each use
A list of keys = ANY($1) IN (?, ...), split when long
Reading a write back RETURNING LAST_INSERT_ID() and a SELECT by the key
Upsert ON CONFLICT (...) on the key named ON DUPLICATE KEY UPDATE on any unique key
Many rows unnest, in one statement VALUES, split when long
Paging by key a row comparison, unless the order mixes directions spelled out

A value type goes to and from the database through its Value and Scan methods; any other type, as the driver converts it. A column that may be NULL is held by a pointer. Through database/sql, an array of PostgreSQL is read into a slice on Go 1.27 or later, where pgx/stdlib scans each column itself.

With the rest of kanna

  • kanna-value: a value type is read through Scan, which checks its rule, so a value the database holds but the rule rejects is an error naming its column. A cursor is decoded the same way.
  • kanna-di: NewOrderRepository is an ordinary constructor, which a container can provide like any other.
  • kanna-fixture: a fixture is a model like any other, so Insert puts it into a test's database.
  • kanna-mapper: rows are read into your models directly, so there is no row type to map; map the models to the wire as before.

What kanna-orm does not do

  • Write relations. Saving a row along with what it refers to is yours to sequence, in a transaction.
  • Track changes, keep an identity map, or load lazily.
  • Create or apply migrations. Use goose or any other tool, and describe the result.
  • Join, group, or nest a query in a scope. Write those queries by hand.
  • Set created_at and updated_at, or take a zero value for the column's default.
  • Page by a column that may be NULL.
  • COPY many rows through pgx.
  • Support MariaDB or SQLite, or run one generated package on both databases.

Flags

kanna-orm [flags] [packages]
kanna-orm describe [flags] [packages]

Generating:

Flag Meaning
-destination <dir> write the implementations of the package's //kanna:repository interfaces into dir; required if it has any
-driver <name> pgx or database/sql (default: pgx on PostgreSQL, database/sql on MySQL)
-snapshot <file> the snapshot to read (default: kanna-orm.snapshot.json at the root of the module)
-tags <list> comma-separated build tags
-check verify the output is up to date instead of writing it, including that no output is left over
-v list the files written, removed, or left unchanged
-version print the version

Describing:

Flag Meaning
-database-url <url> the database to describe (default: $DATABASE_URL)
-snapshot <file> the snapshot to write (default: kanna-orm.snapshot.json at the root of the module)
-tags <list> comma-separated build tags
-check verify the snapshot is up to date with the database instead of writing it
-v say whether the snapshot was written or left unchanged

The packages default to the one in the current directory, so //go:generate go tool kanna-orm needs no argument. As with every kanna generator, KANNA_CHECK=1 go generate ./... checks instead of writing. A run that succeeds prints nothing but its warnings; one that finds neither a //kanna:scope type nor a //kanna:repository interface says so. A package whose declarations are all gone has its orm_gen.go removed, and so has the destination of interfaces that are all gone, unless code in the packages the run loads still calls what it declares; a file of that name without the generated-code header is never touched. The go generate that removes one ends with open ...: no such file or directory, from go generate itself, which lists a package's files before it runs the generators; the file is gone as it should be (golang/go#36068).

Example

example is an order service with customers, products, orders, and their lines, shipments, and tags, generated once for PostgreSQL through pgx, in example/postgres, and once for MySQL through database/sql, in example/mysql: each a module of its own, as a table has one scope type in a module. It has scopes of its own, in Go and in SQL, keyset paging with cursors, each kind of relation, an upsert by a natural key, many rows written at once, and queries written by hand. make example migrates the databases of make db-up, describes them, regenerates the example, and runs it against each; CI does the same, and fails if the output would change.

Development

make test       # unit tests
make db-up      # start the PostgreSQL and the MySQL of compose.yaml, on ports 15432 and 13306
make test-db    # unit tests, and the tests against those databases
make lint       # golangci-lint
make example    # migrate and describe the databases of db-up, regenerate the example, and run it
make bench      # run the benchmarks in a container next to the PostgreSQL of db-up

bench/postgres measures kanna-orm against pgx alone, sqlc, GORM, bun, and ent; its README has the results, and how they were taken.

License

MIT