kanna

kanna / orm

kanna-orm

v0.0.7 Go 1.26 pkg.go.dev

CI codecov Go Reference License: MIT Release English

kanna-orm は、リポジトリのインターフェースの裏側で動くデータベースのコードを生成します。インターフェースと、それが読み取るテーブルごとのスコープ型を宣言すると、kanna-orm は実装を生成し、クエリの絞り込み、並べ替え、ページング、行が参照する先の読み込みの方法ごとに、スコープ型のメソッドも生成します。SQL はリテラルとして生成コードに残り、スキーマと合わないカラムや型で止まるのはリクエストではなく、生成かコンパイルです。

kanna コードジェネレータファミリーの 1 つです。Go 1.26 以上と、pgx か database/sql 経由の PostgreSQL、または database/sql 経由の MySQL 8.4 以上が必要です。

インストール

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

スコープ型は orm.Of を保持し、存在しない行は orm.ErrNotFound になるので、生成コードは github.com/go-kanna/orm をインポートします。これは上のコマンドで依存に追加されます。このパッケージは標準ライブラリ以外には依存しません。ドライバは自分で用意します。

使い方

kanna-orm はスキーマを、マイグレーションではなくデータベースから読み取ります。データベースをマイグレーションし、describe でモジュールのルートにスナップショットを書き出します:

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

kanna-orm.snapshot.json はマイグレーションと一緒にコミットします。スキーマの変更がレビューで見えるようになり、生成はスナップショットだけを読むのでデータベースが要りません。

モデルのパッケージを指定すると、kanna-orm は、そのモデルのうち名前に対応するテーブルがあるものそれぞれについて、スコープ型を宣言します:

package scopes

//go:generate go tool kanna-orm

//kanna:scopes ../model

さらに、コードの他の部分が依存するリポジトリを宣言します:

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
}

モデルは、ドメインでの形のままです:

package model

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

go generate ./... を実行すると、スコープ型と同じディレクトリに orm_gen.go が書き出されます:

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

../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
}

// ...

呼び出し側はスコープをチェーンします:

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

スキーマと合わない宣言は、最初にそれに当たるリクエストまで持ち越さず、生成の時点で止まります:

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

リポジトリのインターフェースが参照する scopes.Order は //kanna:scopes が生成するまで存在しないので、初回の実行前にはコンパイルが通りません。それでも実行は止まりません。kanna-orm はこのディレクティブから型を判断するので、go generate が 2 つのパッケージをどちらの順で処理しても構いません。コードが生成前のメソッドを呼び出していても構いません。その実行で生成されるメソッドがないと述べるだけのエラーでは、実行は止まりません。

スナップショット

describe はデータベースに接続し、テーブルごとに、カラム(型、NULL になりうるか、identity、serial、AUTO_INCREMENT のカラムのようにデータベースが自分で値を割り当てるか)、主キー、ユニークキー、外部キーを記録します。さらに、引数のパッケージと、引数が指すディレクトリのうちまだパッケージがないもの(初回の生成前のリポジトリの出力先など)にある手書きのクエリとスコープをそれぞれプリペアし、パラメータと結果カラムの型を記録します。describe が実行するものは、何も書き込みません。

データベースは -database-url で指定したもの、指定がなければ $DATABASE_URL です:

  • postgres://user:password@host:5432/database は pgx 経由の PostgreSQL です。テーブルはカレントスキーマのもので、カレントスキーマは ?search_path= で選びます。
  • mysql://user:password@host:3306/database は go-sql-driver/mysql 経由の MySQL で、? の後にこのドライバのオプションを指定できます。テーブルは URL で指定したデータベースのものです。

型はデータベース自身が答えたものなので、kanna-orm にはデータベースと食い違う独自の SQL パーサがありません。MySQL はプリペアした文の結果の情報を返さないので、MySQL では describe は文を SELECT t.* FROM (...) AS t LIMIT 0 で包んでカラムを読み取ります。この文は行を返しません。

スナップショットは生成に必要なものだけを記録します。そのため、describe -check で分かるのは、コードが現在のスキーマから生成されたことであり、2 つのスキーマが同じであることではありません:

記録するもの 記録しないもの
カラムの型(宣言した長さや精度を含む): varchar(100), numeric(10,2) デフォルト値の内容(デフォルト値があることだけを記録する)
カラムが NULL になりうるか、デフォルト値を持つか、データベースが値を割り当てるか CHECK 制約
主キー、ユニークキー、外部キー 部分インデックスの条件
手書きのクエリごとのパラメータと結果カラムの型 トリガー、照合順序、コメント

記録されないものだけを変更するマイグレーションでは、スナップショットは変わりません。それらをレビューしたり、2 つのデータベースを比較したりするには、マイグレーションのツールを使ってください。

スナップショットは 1 行に 1 カラムの JSON で、順序は固定です。そのため、マイグレーションは変更した行として差分に現れます:

     "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"}
       ],

モジュールのスナップショットは、ルートにある kanna-orm.snapshot.json の 1 つです。生成はパッケージから親ディレクトリをたどってこれを見つけます。2 つのデータベースを使うモジュールは、それぞれを別のファイルに書き出し、両方のコマンドで -snapshot を使ってそのファイルを指定します。

CI では、マイグレーションしたばかりのデータベースを -check 付きで describe します。スナップショットが古い場合は失敗します:

$ 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

スコープ型

スコープ型は、フィールドが非公開の orm.Of[M] だけの構造体です。M はテーブルの行を読み込むモデルです。これにより、スコープを絞り込めるのはそのメソッドだけになります。kanna-orm が読み書きするテーブルにはそれぞれスコープ型が 1 つあり、各モデルは 1 つのテーブルに属します。テーブルとモデルを結び付けるのはスコープ型だけです。

//kanna:scopes の後にモジュールのパッケージを、インポートパスか、../model のようなファイルからの相対パスで指定すると、それらのパッケージの構造体のうち、スナップショットに対応する名前のテーブルがあるものそれぞれについて、orm_gen.go にスコープ型を宣言します。対応するテーブルの名前は、構造体の名前をスネークケースにして最後の単語を複数形にしたもの(OrderLine なら order_lines、Category なら categories、Person なら people)で、それがなければスネークケースのままの名前です。値オブジェクトのように対応するテーブルがない構造体にはスコープ型が宣言されず、中間テーブルのように対応する構造体がないテーブルにも宣言されません。自分のメソッドは、他の型と同じように、パッケージの別のファイルでこの型に書きます。

それ以外の名前のテーブルや独自の名前のスコープ型は、手で宣言します。手で宣言したものは、そのモデルやテーブルについて //kanna:scopes が暗黙に宣言するものより優先されます:

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

リレーションがたどり着くモデルのうち、どのスコープ型にも指定されていないものがあると、生成が止まります:

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

kanna-orm は、スコープ型ごとに次のものを生成します:

  • //kanna:scopes が暗黙に宣言する場合は、型そのもの。
  • テーブルにちなんだ名前の、空のスコープを返す関数。orders なら Orders() です。その名前が型自身の名前と同じ場合や、他で使われている場合は、型を手で宣言し、//kanna:scope table=person func=People のように func= で別の名前を選んでください。
  • カラム、リレーション、手書きのスコープごとのメソッド。
  • Limit、Offset、Or、Not、After、Before、Cursor、AfterCursor。

どのメソッドも新しいスコープを返し、レシーバは変更しません。そのため、途中まで組み立てたスコープを共有できます。base.Limit(10) と base.Limit(20) は互いの limit に影響しません。ゼロ値の Order{} も空のスコープです。

カラムとフィールド

フィールドは、フィールド名をスネークケースにした名前のカラムを保持します。CustomerID は customer_id を、PlacedAt は placed_at を保持します。この変換は機械的なので、それ以外の名前のカラムを保持するフィールドは、タグでそのことを示します:

タグ 意味
(なし) フィールド名をスネークケースにした名前のカラム
orm:"note" カラム note
orm:"-" カラムではない。kanna-orm はこのフィールドを読み書きしない
orm:"fk=order_id" リレーションで、候補が複数あるときにたどる外部キー
orm:"fk=order_lines.order_id" 同上。カラムだけでは特定できない場合のために、テーブルも指定する

カラムのないフィールド、nil になれないフィールドが保持する NULL になりうるカラム、そして insert されるテーブルで、データベースが値を必須とするのにモデルにフィールドがないカラムは、いずれも生成を止めます。それ以外の場合、モデルにフィールドがないカラムには何もしません。

フィールドの型は、ドライバがカラムの型を読み込める型でなければなりません。bigint には整数型、text には string、timestamptz や datetime には time.Time、bytea や blob には []byte、uuid には Go 1.27 の uuid.UUID か string、PostgreSQL の配列にはそれらのスライスです(MySQL に配列はありません)。Scan メソッドと Value メソッドを持つ型は自分で変換するので、どのカラムにも使えます。enum や json のように kanna-orm が知らないデータベースの型については、フィールドの型をそのまま信用します。

カラムから読み込むフィールドは、カラムが持ちうるすべての値も保持できなければなりません。フィールドの範囲を超える値を持つ行はスキャンに失敗し、float8 や double から読み込んだ float32 は黙って精度を失います。そのため、整数フィールドの幅はカラム以上にします。bigint には int64、integer には int32 です。符号付きのカラムには符号付きの型を、符号なしのカラムには符号なしの型か少し幅の広い型を使います。bigint unsigned には uint64、int unsigned には int64 です。int と uint は、amd64 と同じく 64 ビット幅として扱います。書き込みには、このような幅は必要ありません。データベースは、カラムの範囲を超える値を別の値にして保存せず、拒否するからです。そのため、integer に対する int のようにカラムより幅の広いフィールドでも問題なく、パラメータの幅も問いません。numeric の範囲は記録されないので、どの数値型でも読み込めます。

カラムのメソッド

メソッドの名前はフィールドから決まるので、Memo が保持するカラム note からは MemoContains ができます。各カラムには、その型で使えるメソッドが生成されます:

カラム メソッド(フィールド Status の場合)
すべて StatusEq, StatusNe, StatusIn, StatusNotIn, StatusAsc, StatusDesc
順序のある型(数値、時刻、文字列) 加えて StatusLt, StatusLte, StatusGt, StatusGte, StatusBetween
文字列 加えて StatusLike、% と _ をエスケープする StatusHasPrefix と StatusContains、PostgreSQL では StatusILike
NULL になりうる 加えて StatusIsNull, StatusIsNotNull

メソッドはフィールドの型を受け取ります。StatusEq は model.OrderStatus を受け取り、値オブジェクトは、database/sql が送るのと同じように Value メソッドを通してデータベースに渡ります。パターンで照合するメソッドは string を受け取ります。パターンはその型の値でなくてよいからです。[]byte 以外のスライスで保持されるカラム、つまり配列には、In も NotIn もありません。NULL になりうるカラムの比較メソッドは、ポインタではなく値を受け取ります。何にもマッチしない = NULL を書けないようにするためです。そう書いて表したかったことは StatusIsNull で表せます。

独自のスコープ

名前を付けたスコープは、生成されたメソッドを使ってスコープ型に自分で書くメソッドです。生成されたメソッドと同じようにチェーンできます:

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

検索では、条件を 1 つずつ加えてスコープを絞り込みます:

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)

kanna-orm が生成するメソッドと同じ名前のメソッドを自分で書いている場合は、上書きせずに生成を止めます。

Or と Not

Or は、渡したスコープのいずれかにマッチする行にマッチします。各スコープ自身の条件は、すべて同時に成り立つ必要があります:

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

Not は、渡したスコープにマッチしない行にマッチします。Or や Not に渡すスコープは条件だけを持ちます。並び順、limit、読み込むリレーションを持つスコープを渡すと、クエリはエラーを返します。どの選択肢も 1 つのカラムの値である場合は、StatusIn のほうが同じことを簡潔に表せます。

リレーション

別のモデル、そのポインタ、またはそのスライスを保持するフィールドはリレーションです。kanna-orm はスナップショットの外部キーからリレーションの種類を判断するので、他に何も書く必要はありません:

フィールド 外部キー 種類
Lines []OrderLine orders への order_lines.order_id has many
Customer *Customer customers への orders.customer_id belongs to
Profile *Profile users への profiles.user_id(ユニークキー) has one
Tags []Tag user_tags のような、両方への外部キーを持つテーブルを経由 many to many

使える外部キーが複数ある場合は、生成が止まり、タグを求められます:

../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"

両方への外部キーを持つテーブルは、関連先のテーブルの外部キーと同じように数えられます。refunds が orders と order_lines を参照している場合、Lines は注文の明細とも返金された明細とも解釈できるので、やはりタグが求められます。ここでの order_id のようにカラムだけでは特定できない場合は、タグでテーブルも orm:"fk=order_lines.order_id" のように指定します。

リレーションは、スコープが With メソッドで要求したときにだけ読み込まれます。With メソッドは関連する行のスコープを受け取り、その条件、並び順、さらに読み込むリレーションを指定できます。

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

PostgreSQL では、リレーションのクエリは、行がいくつあってもリレーションごとに 1 回です。上の呼び出しは、注文、顧客、注文の明細、商品に 1 回ずつ、計 4 回のクエリを実行します。中間テーブルを経由するリレーションは 2 回で、中間テーブルのキーの組に 1 回、関連する行に 1 回です。関連する行は、すでに読み込んだ行のキーを使い、同じキーは 1 度だけ指定して読み込みます。PostgreSQL では = ANY($1) を、MySQL では IN (?, ...) を使い、MySQL ではキーを 1000 個ずつ、それぞれ別のクエリで送ります。1 つの行に関連する行はすべて 1 つのクエリで読み込まれ、渡したスコープの順序で並びます。

  • 読み込まれたスライスは決して nil になりません。関連する行がなければ空になります。nil のスライスは読み込まれていません。
  • ポインタは、読み込まれていないときも関連する行がないときも nil です。この 2 つを区別するには、モデルに kanna-orm の型を持ち込むことになります。
  • 関連する行に対する limit や offset は、行ごとではなく関連する行全体を数えてしまうので、limit や offset を持つスコープを With メソッドに渡すと、クエリはエラーを返します。行ごとに先頭の数件を読み込むには、クエリを手で書いてください。
  • リレーションは読み込むだけで、決して書き込みません。Insert と Update が書き込むのはその行自身のカラムで、読み戻した行もそれらのカラムだけを保持します。
  • 複数カラムの外部キーによるリレーションは、それらのカラムを行として扱って読み込みます。PostgreSQL では (a, b) IN (SELECT * FROM unnest($1, $2))、MySQL では (a, b) IN ((?, ?), ...) です。

ページング

Limit と Offset は SQL と同じようにページングします。行が増減してもずれないページにするには、ページの最後の行を After に渡します。スコープの順序でその続きから読み込みます:

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
  • 順序が主キーで終わっていない場合は、After と Before が主キーを追加するので、順序の上で等しい行にも 1 つの順序が付きます。主キーがないと、等しい行の途中で終わるページでは、その一部が黙って重複したり抜けたりします。
  • 順序のすべてのカラムが同じ方向なら、条件は上の例のように行の比較になります。そうでない場合と MySQL では、a < $1 OR (a = $1 AND b < $2) のように展開して書かれます。
  • Before は逆方向に進み、渡した行が先頭にあるページの 1 つ前のページに移動します。リポジトリはそのページを末尾から読み込み、行をスコープの順序に戻して返します。
  • NULL になりうるカラムで並べると、行の続きを読み込むクエリはエラーを返し、Cursor も同様にエラーを返します。データベースによって NULL の並ぶ位置が異なるからです。

位置をクライアントに渡すには、Cursor で文字列にエンコードし、AfterCursor でスコープにデコードします:

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

カーソルは、作成時の順序と、その順序における行の値を JSON として保持します。空のカーソルは先頭を表し、別の順序で作成したカーソルはエラーになります。各値はその型を通してデコードされるので、値オブジェクトはルールをチェックします。クライアントがカーソルを改変しても、変わるのはページの開始位置だけです。

リポジトリ

//kanna:repository を付けたインターフェースは、データベースより上のコードがデータベースに対して何をしてよいかを宣言します。そのパッケージで kanna-orm -destination <dir> を実行すると、そのディレクトリの orm_gen.go に実装が書き出されます。インターフェースにちなんだ名前の構造体、そのコンストラクタ、実行の土台となる DBTX(sqlc と同じ名前)です。

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

トランザクションは、kanna-orm がない場合と同じように開始してコミットします。コンストラクタに pgx.Tx か *sql.Tx を渡してください。

メソッド

メソッドの動作は、名前の先頭の動詞で決まります。動詞の後ろは自由に決められます。残りはパラメータで決まります。モデル型のパラメータは書き込む行、スコープは読み込み方を表し、それ以外のパラメータは同じ名前のカラムを指して、そのカラムの値がパラメータと等しい行が対象になります。

動詞 宣言 動作
Get Get(ctx, id model.OrderID, s ...scopes.Order) (model.Order, error) パラメータで指定したキーの行を読み込む。なければ orm.ErrNotFound を返す
First First(ctx, s ...scopes.Order) (model.Order, error) スコープにマッチする最初の行を読み込む。なければ orm.ErrNotFound を返す
List List(ctx, s ...scopes.Order) ([]model.Order, error) スコープにマッチするすべての行を読み込む
Count Count(ctx, s ...scopes.Order) (int64, error) スコープにマッチする行を数える
Exists Exists(ctx, s ...scopes.Order) (bool, error) マッチする行があるかどうかを返す
Insert Insert(ctx, o model.Order) (model.Order, error)、または error のみを返す形 行を 1 つ、または複数書き込む
Update Update(ctx, o model.Order) error キー以外のすべてのカラムを書き込む。そのキーの行がなければ orm.ErrNotFound を返す
Upsert Upsert(ctx, o model.Order) error 行を insert するか、同じキー、または別のユニークキーを持つ行を update する
Delete Delete(ctx, id model.OrderID) error、または DeleteStale(ctx, s ...scopes.Order) (int64, error) そのキーの行、またはスコープにマッチする行を削除する
// 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)
  • 対象のテーブルは、スコープ型がメソッドのモデルを保持するテーブルです。そのため、1 つのインターフェースで集約のすべてのテーブルを読み書きできます。
  • 複数のスコープは、チェーンした場合と同じくすべてが同時に適用されます。
  • Get は、パラメータが主キーかユニークキーのカラムをすべて与えていない限り、生成を止めます。
  • Count、Exists、スコープを渡す Delete は、スコープの条件だけを使います。並び順、limit、読み込むリレーションを持つスコープを渡すと、それらを無視せずにエラーを返します。
  • スコープを渡しても条件がない Delete は、すべての行を削除せずにエラーを返します。
  • error だけを返す Delete は、Update と同じく、1 行も削除しなかったときに orm.ErrNotFound を返します。(int64, error) を返す Delete は削除した行数を返します。0 行の場合も同じです。
  • メソッドと同じ名前の手書きのクエリがあれば、動詞の代わりにそれが使われます。動詞も手書きのクエリもないメソッドは生成を止めます。
  • 生成された構造体のメソッドを出力先の別のファイルに自分で書いた場合、そのメソッドには手を付けず、kanna-orm はインターフェースの残りを生成します。

メソッドのモデルは、戻り値、書き込む行、スコープから決まり、それらがなければ model.OrderID のようにモデルと一緒に宣言された型のキーから決まります。uuid.UUID や int64 のような他の型のキーしか受け取らないメソッドからはモデルが決まらず、インターフェースが 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= は、モデルが決まらないメソッドのテーブルであり、それ以外には適用されません。自分のモデルが決まるメソッドはそのテーブルを読み書きするので、集約のインターフェースは引き続き集約の各テーブルに届きます。

orm.ErrNotFound は、見つからなかったものの情報でラップされて返るので、errors.Is で判定し、リポジトリを呼び出す箇所でアプリケーションのエラーに変換してください。

uuid をキーにする

Go 1.27 以降では、uuid カラムは標準ライブラリの uuid.UUID で保持します。pgx と database/sql はこの型をそのまま読み書きします。それより前のバージョンでは、string か、Scan メソッドと Value メソッドを持つ独自の型で保持します。uuid をキーにするテーブルの宣言は、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 はモデルと一緒に宣言された型ではないので、これしか受け取らないメソッドからはテーブルが決まりません。Delete のテーブルは table= から決まり、Get のテーブルは戻り値から決まります。
  • uuid には比較できる順序がないので、そのカラムには Eq、Ne、In、NotIn、Asc、Desc は生成されますが、Lt も Between も生成されません。それでもページは uuid を基準に行の続きから読み込めて、カーソルは uuid をテキストとして保持します。
  • デフォルトが uuidv7() のカラムは、データベースが値を割り当てるカラムではありません。insert はモデルの ID を、ゼロの UUID であってもそのまま書き込みます。uuid.NewV7() などを使い、Go の側で各行に ID を与えてください。
  • MySQL に uuid 型はなく、kanna-orm は binary(16) や char(36) に対する uuid.UUID を受け付けません。[]byte、string、Scan メソッドと Value メソッドを持つ独自の型のいずれかで保持してください。

go-kanna/showcase は、to-do を uuid.UUID をキーにして管理する to-do サービスです。

書き込み

行は、モデルが保持するとおりに書き込まれます。データベースが自分で値を割り当てるカラム(identity、serial、AUTO_INCREMENT のカラム)だけは書き込みから除かれ、読み戻されます。ゼロ値はゼロ値として書き込まれ、カラムのデフォルト値の意味に解釈されることは決してありません。デフォルトが true のカラムに対する false は false のままです。created_at と updated_at はドメインで設定するか、モデルから外してデータベースのデフォルト値に任せてください。

モデルを返す Insert は、データベースが保存した行を読み戻します。error だけを返す Insert は書き込むだけです。MySQL では、データベースが主キーを割り当てる場合は LAST_INSERT_ID() で、そうでなければ主キーで読み戻します。MySQL が数えるのは Update が変更した行で、見つけた行ではありません。そのため、1 行も変更されなかった場合、kanna-orm は行が存在するかを確認してから orm.ErrNotFound を返します。

Upsert

Upsert は、主キーで既存の行と突き合わせます。別のユニークキーで突き合わせるには、//kanna:conflict でそのカラムを指定し、keep= で変更しないカラムを指定します:

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

カラムはテーブルの主キーかユニークキーと完全に一致しなければならず、データベースが値を割り当てるカラムは含められません。insert はそのカラムの値を渡さないので、行を突き合わせる材料にならないからです。突き合わせた行では、そのキーのカラム、主キー、keep= で指定したカラムを除くすべてのカラムを更新します。更新するカラムが残らない場合は、キーに自分自身を設定するので、行はやはり読み戻されます。MySQL はどのユニークキーの競合でも更新するので、指定したキー以外のユニークキーを持つテーブルには警告が出ます:

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

複数行の書き込み

スライスを渡した Insert や Upsert は、その中のすべての行を書き込みます:

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[])
  • PostgreSQL では、戻り値が ([]model.OrderLine, error) なら RETURNING で行を読み戻します。
  • MySQL では、行を VALUES (?, ?, ?), (?, ?, ?), ... で送り、1 つの文に入るプレースホルダの数を超える場合は複数の文に分割します。すべて成功するかすべて失敗するかにしたい場合は、コンストラクタにトランザクションを渡してください。
  • MySQL には RETURNING がないので、MySQL では error だけを返す形以外は生成を止めます。
  • PostgreSQL は、2 つの行が同じキーを持つ upsert を拒否します。
  • unnest は配列の配列を平坦にするので、PostgreSQL では配列カラムを持つテーブルは生成を止めます。行を 1 つずつ書き込んでください。

手書きの SQL

メソッドで表現できないものは .sql ファイルに書きます。クエリは同じ名前のリポジトリのメソッドになり、そのメソッドが実装される場所、つまり -destination で指定したパッケージに書きます。SQL は実装の詳細であり、インターフェースの詳細ではないからです。スコープは指定したスコープ型のメソッドになり、そのスコープ型と同じ場所に書きます。マイグレーションのように -- query: の行も -- scope: の行もない .sql ファイルは、kanna-orm の対象外です。

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)

生成コードはそれぞれをリテラルとして保持し、パラメータはデータベースに合わせて番号付けされます:

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`
	// ...
}
  • パラメータは、:since のように : の後に名前を続けたものです。別の :、英字、数字、_、] の直後の : はパラメータを開始しないので、::int のようなキャストや arr[1:n] のようなスライスはそのまま残ります。文字列、引用符付きの名前、コメント、ドル引用の本体の中も同様です。describe はこの結果の SQL をプリペアするので、読み違いがあればそこで分かります。
  • クエリのパラメータの型はメソッドから決まり、結果カラムは同じ名前のフィールド(タグも含む)に入ります。どちらも describe が記録した内容と照らしてチェックされます。戻り値が []T ならすべての行、T なら 1 行または orm.ErrNotFound、(int64, error) なら影響を受けた行数を返し、error だけなら何も返しません。
  • フィールドは名前で結果カラムを受け取るので、結果カラムにはそれぞれ固有の名前が必要です。MySQL では、カラム名が重複するクエリは情報を取得することすらできません。SELECT o.id, c.id AS customer_id のようにエイリアスを付けてください。
  • NULL になりうる結果カラムには、nil になれるフィールドが必要です。データベースがカラムは NULL になりうると答えた場合、nil になれないフィールドは生成を止めます。データベースが判断できない場合は、フィールドの型をそのまま信用します。MySQL はすべてのカラムについて答えます。PostgreSQL が答えるのはテーブルから直接読み取るカラムだけで、その答えはテーブルの宣言どおりであり、外部結合によって NULL になる場合でも変わりません。LEFT JOIN customers c の c.email のようなカラムは、ポインタで保持してください。kanna-orm 自身のクエリは、結合ではなく専用のクエリでリレーションを読み込むので、これは手書きのクエリだけの問題です。
  • スコープには宣言されたメソッドがないので、シグネチャを -- scope: の行に書きます。その行のすぐ上のコメント行は、メソッドのドキュメントになります。MySQL は文のパラメータの型を教えませんが、この方法ならその必要がありません。
  • 文は、その -- query: や -- scope: の行から、次の文のコメントまたはその行までです。文の終わりにある空行とコメント行は文に含まれず、文の一番最後に付いたコメントも含まれません。そのため、スコープはコメントで終えられます。
  • MySQL では、2 回使うパラメータは ? ごとに 1 回ずつ渡します。

ドライバと方言

スナップショットはどのデータベースから作られたかを知っているので、選ぶのはドライバだけで、-driver で指定します:

データベース -driver 生成コードが使うもの
PostgreSQL pgx(デフォルト) pgx.Rows, $1, = ANY($1), RETURNING
PostgreSQL database/sql(pgx/stdlib 経由) *sql.Rows, $1, = ANY($1), RETURNING
MySQL 8.4 以上 database/sql(デフォルト、go-sql-driver/mysql 経由) *sql.Rows, ?, IN (?, ...), LAST_INSERT_ID()

コードはそれぞれ異なり、どれもデータベースを抽象化の裏に隠しません。PostgreSQL と MySQL の違いは、コードを生成する時点で解決されます。

PostgreSQL MySQL
引用符付きの名前 "x" `x`
パラメータ $1(必要な箇所で再利用) ?(使うたびに 1 つ)
キーのリスト = ANY($1) IN (?, ...)(長い場合は分割)
書き込みの読み戻し RETURNING LAST_INSERT_ID() とキーによる SELECT
Upsert 指定したキーに対する ON CONFLICT (...) 任意のユニークキーに対する ON DUPLICATE KEY UPDATE
複数行 unnest(1 つの文) VALUES(長い場合は分割)
キーによるページング 行の比較(順序の方向が混在する場合を除く) 展開して書く

値オブジェクトは Value メソッドと Scan メソッドを通してデータベースとやり取りし、他の型はドライバが変換するとおりにやり取りします。NULL になりうるカラムはポインタで保持します。database/sql 経由の場合、PostgreSQL の配列をスライスに読み込めるのは Go 1.27 以降で、そこでは pgx/stdlib が各カラムを自分でスキャンします。

kanna の他のツールとの連携

  • kanna-value: 値オブジェクトは Scan を通して読み込まれ、Scan がルールをチェックします。そのため、データベースにある値をルールが拒否すると、カラムの名前を示すエラーになります。カーソルも同じようにデコードされます。
  • kanna-di: NewOrderRepository は普通のコンストラクタなので、他のものと同じようにコンテナで提供できます。
  • kanna-fixture: フィクスチャも普通のモデルなので、Insert でテストのデータベースに入れられます。
  • kanna-mapper: 行はモデルに直接読み込まれるので、マッピングする行の型はありません。モデルからワイヤ型(protobuf などの通信用の型)へのマッピングは、これまでどおり行ってください。

kanna-orm がしないこと

  • リレーションの書き込み。行を参照先と一緒に保存するときの順序は、トランザクションの中で自分で組み立てます。
  • 変更の追跡、アイデンティティマップの保持、遅延読み込み。
  • マイグレーションの作成や適用。goose などのツールを使い、その結果を describe してください。
  • スコープの中でのクエリの結合、グループ化、ネスト。そのようなクエリは手で書いてください。
  • created_at と updated_at の設定、ゼロ値をカラムのデフォルト値とみなすこと。
  • NULL になりうるカラムでのページング。
  • pgx 経由での複数行の COPY。
  • MariaDB や SQLite のサポート、1 つの生成パッケージを PostgreSQL と MySQL の両方で動かすこと。

フラグ

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

生成:

フラグ 意味
-destination <dir> パッケージの //kanna:repository インターフェースの実装を dir に書き出す。インターフェースがある場合は必須
-driver <name> pgx または database/sql(デフォルト: PostgreSQL では pgx、MySQL では database/sql)
-snapshot <file> 読み込むスナップショット(デフォルト: モジュールのルートの kanna-orm.snapshot.json)
-tags <list> カンマ区切りのビルドタグ
-check 書き出す代わりに、出力が最新であることを検証する。不要な出力が残っていないことも含む
-v 書き出した、削除した、変更しなかったファイルを一覧表示する
-version バージョンを表示する

describe:

フラグ 意味
-database-url <url> 読み取るデータベース(デフォルト: $DATABASE_URL)
-snapshot <file> 書き出すスナップショット(デフォルト: モジュールのルートの kanna-orm.snapshot.json)
-tags <list> カンマ区切りのビルドタグ
-check 書き出す代わりに、スナップショットがデータベースに対して最新であることを検証する
-v スナップショットを書き出したか、変更しなかったかを表示する

パッケージのデフォルトはカレントディレクトリのパッケージなので、//go:generate go tool kanna-orm に引数は不要です。他の kanna ジェネレータと同じく、KANNA_CHECK=1 go generate ./... は書き出す代わりにチェックします。成功した実行は、警告以外何も出力しません。//kanna:scope の型も //kanna:repository のインターフェースも見つからなかった実行は、そのことを知らせます。宣言がすべてなくなったパッケージの orm_gen.go は削除され、インターフェースがすべてなくなった出力先の orm_gen.go も削除されます。ただし、実行時に読み込んだパッケージのコードがその宣言をまだ呼び出している場合は削除されません。同じ名前でも生成コードのヘッダがないファイルには決して触れません。削除を行う go generate は open ...: no such file or directory で終わります。これは go generate 自身のエラーで、ジェネレータを実行する前にパッケージのファイルを列挙しているためです。ファイルは意図どおり削除されています(golang/go#36068)。

サンプル

example は、顧客、商品、注文と、注文の明細、出荷、タグを扱う注文サービスです。pgx 経由の PostgreSQL 向けに example/postgres に、database/sql 経由の MySQL 向けに example/mysql に、それぞれ生成しています。1 つのモジュールではテーブルにスコープ型が 1 つなので、それぞれが独立したモジュールです。Go と SQL で書いた独自のスコープ、カーソルによるキーセットページング、各種のリレーション、自然キーによる upsert、複数行の一括書き込み、手書きのクエリが含まれます。make example は make db-up のデータベースをマイグレーションして内容を読み取り、サンプルを再生成して、それぞれに対して実行します。CI も同じことを行い、出力が変わる場合は失敗します。

開発

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 は、kanna-orm を pgx 単体、sqlc、GORM、bun、ent と比較して計測します。結果と計測方法は、その README にあります。

ライセンス

MIT