GolangのORM、Xormの使い方ざっくりまとめ

最近、GolangのORMとしてXormを使っています。GoのORMといえばGormが主流で、調べてもXormに関する記事が少なかったので、備忘録としてまとめてみました。

自分が業務で使用している機能のみをまとめたので、さらに詳しく知りたい場合は公式ドキュメントを参照ください。

環境

  • Go : 1.13.1
  • Xorm : 0.7.9
  • PostgreSQL : 11.6

使用するテーブル

create table users(
id BIGSERIAL,
first_name VARCHAR(20) NOT NULL,
last_name VARCHAR(20) NOT NULL,
first_name_kana VARCHAR(20),
last_name_kana VARCHAR(20),
version BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
PRIMARY KEY (id)
);
create table friends(
id BIGSERIAL,
user_id BIGINT NOT NULL,
friend_id BIGINT NOT NULL,
version BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP,
PRIMARY KEY (id),
FOREIGN KEY (user_id) REFERENCES users (id),
FOREIGN KEY (friend_id) REFERENCES users (id)
);
view raw go-xorm-table hosted with ❤ by GitHub

ユーザー情報を保存するusersテーブルと、ユーザー同士のフレンド関係を登録するfiriendsテーブルを作成しました 。

接続

package main
import (
"log"
"github.com/go-xorm/xorm"
_ "github.com/lib/pq"
"xorm.io/core"
)
func main() {
engine, err := xorm.NewEngine("postgres", "postgres://user:password@db/database?sslmode=disable")
if err != nil {
log.Println(err)
return
}
defer engine.Close()
engine.ShowSQL(true)
engine.SetMapper(core.GonicMapper{})
}
view raw go-xorm-main hosted with ❤ by GitHub

engine.ShowSQL(true) を設定すると、実行されたSQLを標準出力に出すことができます。engine.SetMapper(core.GonicMapper{}) は、後述するフィールド名とカラム名のマッピングに影響します。

Create

type user struct {
ID uint64 `xorm:"autoincr"`
FirstName string
LastName string
FirstNameKana string
LastNameKana string
Version uint64 `xorm:"version"`
CreatedAt time.Time `xorm:"created"`
UpdatedAt time.Time `xorm:"updated"`
DeletedAt time.Time `xorm:"deleted"`
}
func (user) TableName() string {
return "users"
}
func create(engine *xorm.Engine) {
u := user{
FirstName: "太郎",
LastName: "テスト",
}
affected, err := engine.Insert(&u)
if err != nil {
log.Println(err)
return
}
if affected == 0 {
log.Println("No Affected")
return
}
}
view raw go-xorm-create hosted with ❤ by GitHub
INSERT INTO "users"(  
    "first_name",  
    "last_name",  
    "first_name_kana",  
    "last_name_kana",  
    "version",  
    "created_at",  
    "updated_at"  
)  
VALUES(  
    $1,  
    $2,  
    $3,  
    $4,  
    $5,  
    $6,  
    $7  
) RETURNING "id"

構造体のフィールド名をスネークケースに変換したものがカラム名に対応しています(FirstName -> first_name)。通常はフィールド名をID とするとi_d というカラムにマッピングしてしまいますが、engine.SetMapper(core.GonicMapper{}) を指定するとid にマッピングできます。フィールド名をId にするとGonicMapperは不要になりますが、Go のlintで警告が出てしまいます。

Home Page |

`xorm:"first_name"`のようにタグでカラム名を指定することもできますが、全てのフィールドに指定するのは面倒なのでフィールド名を合わせた方が良いと思います。

versionカラムは楽観的ロックのために使用します。

排他制御(楽観ロック・悲観ロック)の基礎 - Qiita
排他制御とは 共有資源(データやファイル)に対して複数のアクセスが見込まれる場合に、同時アクセスにより不整合が発生することを防ぐため、あるトランザクションが共有資源(データやファイル)にアクセスしている時は他トランザクションから...

xormではDBにversionカラムを設定し、フィールドに `xorm:"version"` を指定するとupdate時に自動で判定してくれます。

大抵のテーブルでidカラムは自動採番だと思います。構造体のIDフィールドのタグに `xorm:"autoincr"` をつけると、insert対象のカラムからidが外され、SQLにRETURNING "id" が追加され、insert後のインスタンスにinsert結果のidが自動で設定されます。自動採番されたidを別のテーブルに登録する際に使えます。

type friend struct {
ID uint64 `xorm:"autoincr"`
UserID uint64
FriendID uint64
Version uint64 `xorm:"version"`
CreatedAt time.Time `xorm:"created"`
UpdatedAt time.Time `xorm:"updated"`
DeletedAt time.Time `xorm:"deleted"`
}
func (friend) TableName() string {
return "friends"
}
{
u2 := user{
FirstName: "太郎2",
LastName: "テスト",
}
affected, err = engine.Insert(&u2)
if err != nil {
log.Println(err)
return
}
if affected == 0 {
log.Println("No Affected")
return
}
f := friend{
UserID: u.ID,
FriendID: u2.ID,
}
affected, err = engine.Insert(&f)
if err != nil {
log.Println(err)
return
}
if affected == 0 {
log.Println("No Affected")
return
}
}

insertでスライスを渡せば、一括登録もできます。

us := []user{
{
FirstName: "太郎3",
LastName: "テスト",
},
{
FirstName: "太郎4",
LastName: "テスト",
},
}
affected, err = engine.Insert(&us)
if err != nil {
log.Println(err)
return
}
if affected != 2 {
log.Println("Unexpected Affected")
return
}
view raw go-xorm-create-bulk hosted with ❤ by GitHub
INSERT INTO "users"(  
    "first_name",  
    "last_name",  
    "first_name_kana",  
    "last_name_kana",  
    "version",  
    "created_at",  
    "updated_at"  
)  
VALUES(  
    $1,  
    $2,  
    $3,  
    $4,  
    $5,  
    $6,  
    $7  
),  
(  
    $8,  
    $9,  
    $10,  
    $11,  
    $12,  
    $13,  
    $14  
)

一括の場合はidがインスタンスに自動でセットされないので、必要であれば検索するか、単一のinsertを使用することになります。

Read

func read(engine *xorm.Engine) {
t := time.Date(2020, 1, 1, 0, 0, 0, 0, time.Local)
// 1件取得
u := user{}
has, err := engine.
Where("id = ?", id).
Where("last_name LIKE ?", "テスト").
In("last_name", "テスト", "テスト2").
In("last_name", []string{"テスト", "テスト2"}).
Where("first_name = ? OR first_name = ?", "太郎", "太郎2").
Where("created_at >= ?", t).
Get(&u)
if err != nil {
log.Println(err)
return
}
if !has {
log.Println("Not Found")
return
}
// 複数取得
us := []user{}
err = engine.
Limit(10, 10).
Asc("id", "first_name").
Desc("last_name").
Find(&us)
if err != nil {
log.Println(err)
return
}
}
view raw go-xorm-read hosted with ❤ by GitHub

Get で1件のみ、Find で複数検索です。

Get

SELECT  
    "id",  
    "first_name",  
    "last_name",  
    "first_name_kana",  
    "last_name_kana",  
    "version",  
    "created_at",  
    "updated_at",  
    "deleted_at"  
FROM  
    "users"  
WHERE  
    (  
        id = $1  
    )  
AND (last_name LIKE $2)  
AND "last_name" IN($3, $4)  
AND "last_name" IN($5, $6)  
AND (  
        first_name = $7  
    OR  first_name = $8  
    )  
AND (  
        created_at >= $9  
    )  
AND (  
        "deleted_at" IS NULL  
    OR  "deleted_at" = $10  
    )  
LIMIT 1

Find

SELECT  
    "id",  
    "first_name",  
    "last_name",  
    "first_name_kana",  
    "last_name_kana",  
    "version",  
    "created_at",  
    "updated_at",  
    "deleted_at"  
FROM  
    "users"  
WHERE  
    (  
        "deleted_at" IS NULL  
    OR  "deleted_at" = $1  
    )  
ORDER BY  
    "id" ASC,  
    "first_name" ASC,  
    "last_name" DESC  
LIMIT 10 OFFSET 10

構造体のフィールドに `xorm:"deleted"` があれば、where句に自動で

("deleted_at" IS NULL OR "deleted_at" = '0001-01-01 00:00:00')

が追加されます。deleted_atが(NULL or ゼロ値)であることを確認しています。

Unscoped() でdeleted_atを無視できます。

us := []user{}
err = engine.
Unscoped().
Find(&us)
if err != nil {
log.Println(err)
return
}
SELECT  
    "id",  
    "first_name",  
    "last_name",  
    "first_name_kana",  
    "last_name_kana",  
    "version",  
    "created_at",  
    "updated_at",  
    "deleted_at"  
FROM  
    "users"

countもできます。

u := user{}
count, err := engine.Count(&u2)
if err != nil {
log.Println(err)
return
}
view raw go-xorm-read-count hosted with ❤ by GitHub
SELECT  
    count(*)  
FROM  
    "users"  
WHERE  
    (  
        "deleted_at" IS NULL  
    OR  "deleted_at" = $1  
    )

FindAndCount() でFindとCountを同時に実行できます。

us := []user{}
count, err = engine.
Limit(10, 10).
Asc("id", "first_name").
Desc("last_name").
FindAndCount(&us3)
if err != nil {
log.Println(err)
return
}
SELECT  
    "id",  
    "first_name",  
    "last_name",  
    "first_name_kana",  
    "last_name_kana",  
    "version",  
    "created_at",  
    "updated_at",  
    "deleted_at"  
FROM  
    "users"  
WHERE  
    (  
        "deleted_at" IS NULL  
    OR  "deleted_at" = $1  
    )  
ORDER BY  
    "id" ASC,  
    "first_name" ASC,  
    "last_name" DESC  
LIMIT 10 OFFSET 10

SELECT  
    count(*)  
FROM  
    "users"  
WHERE  
    (  
        "deleted_at" IS NULL  
    OR  "deleted_at" = $1  
    )  
AND (  
        "deleted_at" IS NULL  
    OR  "deleted_at" = $2  
    )

2つのSQLが実行されます。limitやoffsetを設定していてもcountでは無視されます。countのdeleted_atの部分が重複しているのはバグな気がしますが、結果には影響ないので多分大丈夫です。

joinした結果を取得したい場合、新たに構造体を定義します。

type joinedUser struct {
User user `xorm:"extends"`
Friend friend `xorm:"extends"`
}
func (joinedUser) TableName() string {
return "friends"
}
{
jus := []joinedUser{}
err = engine.
Join("INNER", "users", "users.id = friends.friend_id").
Find(&jus)
if err != nil {
log.Println(err)
return
}
}
view raw go-xorm-read-join hosted with ❤ by GitHub
SELECT  
    *  
FROM  
    "friends"  
    INNER JOIN  
        users  
    ON  users.id = friends.friend_id  
WHERE  
    (  
        "friends"."deleted_at" IS NULL  
    OR  "friends"."deleted_at" = $1  
    )

フィールドに`xorm:"extends"` を設定します。from句のdeleted_atは自動で確認してくれますが、join先のdeleted_atは確認してくれないので、自分で追加する必要があります。

サブクエリを設定する場合は"xorm.io/builder" を使います。

subQuery := builder.
Select("id").
From("users").
Where(builder.Eq{"last_name": "テスト"}).
Where(builder.Like{"last_name", "テスト"}).
Where(builder.In("last_name", "テスト", "テスト2")).
Where(builder.In("last_name", []string{"テスト", "テスト2"})).
Where(builder.Eq{"first_name": "太郎2"}.
Or(builder.Eq{"first_name": "太郎3"}).
Or(builder.Eq{"first_name": "太郎4"}),
).
Where(builder.Gte{"created_at": t}).
Where(builder.IsNull{"deleted_at"})
fs := []friend{}
err = engine.
In("friend_id", subQuery).
Limit(10, 10).
Asc("id", "friend_id").
Desc("user_id").
Find(&fs)
if err != nil {
log.Println(err)
return
}
SELECT  
    "id",  
    "user_id",  
    "friend_id",  
    "version",  
    "created_at",  
    "updated_at",  
    "deleted_at"  
FROM  
    "friends"  
WHERE  
    "friend_id" IN(  
        SELECT  
            id  
        FROM  
            users  
        WHERE  
            last_name = $1  
        AND last_name LIKE $2  
        AND last_name IN($3, $4)  
        AND last_name IN($5, $6)  
        AND (  
                first_name = $7  
            OR  first_name = $8  
            OR  first_name = $9  
            )  
        AND created_at >= $10  
        AND deleted_at IS NULL  
    )  
AND (  
        "deleted_at" IS NULL  
    OR  "deleted_at" = $11  
    )  
ORDER BY  
    "id" ASC,  
    "friend_id" ASC,  
    "user_id" DESC  
LIMIT 10 OFFSET 10

サブクエリはjoin句にも設定できます。

jus := []joinedUser{}
err = engine.
Join("INNER", subQuery, "users.id = friends.friend_id").
Find(&jus)
if err != nil {
log.Println(err)
return
}
SELECT  
    *  
FROM  
    "friends"  
    INNER JOIN  
        (  
            SELECT  
                id  
            FROM  
                users  
            WHERE  
                last_name = $1  
            AND last_name LIKE $2  
            AND last_name IN($3, $4)  
            AND last_name IN($5, $6)  
            AND (  
                    first_name = $7  
                OR  first_name = $8  
                OR  first_name = $9  
                )  
            AND created_at >= $10  
            AND deleted_at IS NULL  
        ) users  
    ON  users.id = friends.friend_id  
WHERE  
    (  
        "friends"."deleted_at" IS NULL  
    OR  "friends"."deleted_at" = $11  
    )

集約してcountする場合も構造体を定義します。

type countPerUserID struct {
Count uint
UserID uint64
}
func (countPerUserID) TableName() string {
return "friends"
}
{
cpuis := []countPerUserID{}
err = engine.
Select("count(*) as count, user_id").
Where("deleted_at IS NULL").
GroupBy("user_id").
Find(&cpuis)
if err != nil {
log.Println(err)
return
}
}
SELECT  
    count(*) as count,  
    user_id  
FROM  
    "friends"  
WHERE  
    (deleted_at IS NULL)  
GROUP BY  
    user_id

欲しいカラムが1つだけの場合はプリミティブ型をそのまま使うこともできます。

var friendID uint64
has, err = engine.
Table("friends").
Where("user_id = ?", id).
Where("deleted_at IS NULL").
Desc("created_at").
Cols("friend_id").
Get(&friendID)
if err != nil {
log.Println(err)
return
}
if !has {
log.Println("Not Found")
return
}
SELECT  
    "friend_id"  
FROM  
    "friends"  
WHERE  
    (  
        user_id = $1  
    )  
AND (deleted_at IS NULL)  
ORDER BY  
    "created_at" DESC  
LIMIT 1

Update

u := user{}
has, err := engine.
Where("id = ?", id).
Get(&u)
if err != nil {
log.Println(err)
return
}
if !has {
log.Println("Not Found")
return
}
u.FirstName = "太郎更新"
u.LastName = "テスト更新"
u.FirstNameKana = "タロウコウシン"
affected, err = engine.
Where("id = ?", id).
Update(&u)
if err != nil {
log.Println(err)
return
}
if affected == 0 {
log.Println("Not Found")
return
}
view raw go-xorm-update hosted with ❤ by GitHub
UPDATE  
    "users"  
SET  
    "first_name" = $1,  
    "last_name" = $2,  
    "first_name_kana" = $3,  
    "updated_at" = $4,  
    "version" = "version" + 1  
WHERE  
    (  
        id = $5  
    )  
AND (  
        "deleted_at" IS NULL  
    OR  "deleted_at" = $6  
    )  
AND "version" = $7

version を自動で確認・更新してくれます。更新後のインスタンスはu.Version とu.UpdatedAt が更新されています。

DBのversion が変わっていた場合は更新対象が0件(affected == 0)になります。

u.Version = 9999
affected, err = engine.
Where("id = ?", id).
Update(&u)
if err != nil {
log.Println(err)
return
}
if affected == 0 {
log.Println("Optimistic Lock")
}
view raw go-xorm-update-lock hosted with ❤ by GitHub

楽観的ロックを使用せず、version がない場合は普通に更新されます。

type userForNoLock struct {
ID uint64 `xorm:"autoincr"`
FirstName string
LastName string
FirstNameKana string
LastNameKana string
CreatedAt time.Time `xorm:"created"`
UpdatedAt time.Time `xorm:"updated"`
DeletedAt time.Time `xorm:"deleted"`
}
func (userForNoLock) TableName() string {
return "users"
}
{
ufnl := userForNoLock{
FirstName: "太郎更新",
FirstNameKana: "タロウコウシン",
}
affected, err := engine.
Where("id = ?", id).
Update(&ufnl)
if err != nil {
log.Println(err)
return
}
if affected == 0 {
log.Println("Not Found")
return
}
}
UPDATE  
    "users"  
SET  
    "first_name" = $1,  
    "first_name_kana" = $2,  
    "updated_at" = $3  
WHERE  
    (  
        id = $4  
    )  
AND (  
        "deleted_at" IS NULL  
    OR  "deleted_at" = $5  
    )

インスタンスのフィールドがゼロ値の場合は、更新対象のカラムに含まれません。

u.FirstName = ""
u.LastName = ""
u.FirstNameKana = ""
affected, err = engine.
Where("id = ?", id).
Update(&u)
if err != nil {
log.Println(err)
return
}
if affected == 0 {
log.Println("Not Found")
return
}
view raw go-xorm-update-zero hosted with ❤ by GitHub
UPDATE  
    "users"  
SET  
    "updated_at" = $1,  
    "version" = "version" + 1  
WHERE  
    (  
        id = $2  
    )  
AND (  
        "deleted_at" IS NULL  
    OR  "deleted_at" = $3  
    )  
AND "version" = $4

Cols で指定すると、インスタンスのフィールドがゼロ値でも更新できます。ただし、指定したカラムとupdated_at,versionしか更新されなくなります。

u.FirstName = ""
u.LastName = ""
u.FirstNameKana = "更新されない"
affected, err = engine.
Cols("first_name").
Where("id = ?", id).
Update(&u)
if err != nil {
log.Println(err)
return
}
if affected == 0 {
log.Println("Not Found")
return
}
UPDATE  
    "users"  
SET  
    "first_name" = $1,  
    "updated_at" = $2,  
    "version" = "version" + 1  
WHERE  
    (  
        id = $3  
    )  
AND (  
        "deleted_at" IS NULL  
    OR  "deleted_at" = $4  
    )  
AND "version" = $5

AllCols() で全カラムを更新できます。

u.FirstName = ""
u.LastName = ""
u.FirstNameKana = ""
affected, err = engine.
AllCols().
Where("id = ?", id).
Update(&u)
if err != nil {
log.Println(err)
return
}
UPDATE  
    "users"  
SET  
    "first_name" = $1,  
    "last_name" = $2,  
    "first_name_kana" = $3,  
    "last_name_kana" = $4,  
    "updated_at" = $5,  
    "version" = "version" + 1  
WHERE  
    (  
        id = $6  
    )  
AND (  
        "deleted_at" IS NULL  
    OR  "deleted_at" = $7  
    )  
AND "version" = $8

構造体のフィールドをポインタで定義すると (ゼロ値 = nil) となるため、空文字や0のポインタをセットすれば更新できるようになります。

type userForEnableEmpty struct {
ID uint64 `xorm:"autoincr"`
FirstName *string
LastName *string
FirstNameKana *string
LastNameKana *string
Version uint64 `xorm:"version"`
CreatedAt time.Time `xorm:"created"`
UpdatedAt time.Time `xorm:"updated"`
DeletedAt time.Time `xorm:"deleted"`
}
func (userForEnableEmpty) TableName() string {
return "users"
}
{
ufee := userForEnableEmpty{}
has, err = engine.
Where("id = ?", id).
Get(&ufee)
if err != nil {
log.Println(err)
return
}
if !has {
log.Println("Not Found")
return
}
ufee.FirstName = &u.FirstName
ufee.LastName = &u.LastName
ufee.FirstNameKana = &u.FirstNameKana
affected, err = engine.
Where("id = ?", id).
Update(&ufee)
if err != nil {
log.Println(err)
return
}
if affected == 0 {
log.Println("Not Found")
return
}
}
UPDATE  
    "users"  
SET  
    "first_name" = $1,  
    "last_name" = $2,  
    "first_name_kana" = $3,  
    "last_name_kana" = $4,  
    "updated_at" = $5,  
    "version" = "version" + 1  
WHERE  
    (  
        id = $6  
    )  
AND (  
        "deleted_at" IS NULL  
    OR  "deleted_at" = $7  
    )  
AND "version" = $8

ポインタとCols() を組み合わせるとNULLで更新できます。

ufee.FirstName = nil
ufee.LastName = nil
ufee.FirstNameKana = nil
affected, err = engine.
Cols("first_name_kana").
Where("id = ?", id).
Update(&ufee)
if err != nil {
log.Println(err)
return
}
if affected == 0 {
log.Println("Not Found")
return
}
UPDATE  
    "users"  
SET  
    "first_name_kana" = $1,  
    "updated_at" = $2,  
    "version" = "version" + 1  
WHERE  
    (  
        id = $3  
    )  
AND (  
        "deleted_at" IS NULL  
    OR  "deleted_at" = $4  
    )  
AND "version" = $5

Cols()とNullable()を組み合わせてもNULL更新できます。

u.FirstNameKana = ""
affected, err = engine.
Cols("first_name_kana").
Nullable("first_name_kana").
Where("id = ?", id).
Update(&u)
if err != nil {
log.Println(err)
return
}
UPDATE  
    "users"  
SET  
    "first_name_kana" = $1,  
    "updated_at" = $2,  
    "version" = "version" + 1  
WHERE  
    (  
        id = $3  
    )  
AND (  
        "deleted_at" IS NULL  
    OR  "deleted_at" = $4  
    )  
AND "version" = $5

Delete

func delete(engine *xorm.Engine) {
u := user{}
affected, err := engine.
Where("id = ?", id).
Delete(&u)
if err != nil {
log.Println(err)
return
}
if affected == 0 {
log.Println("Not Found")
return
}
}
view raw go-xorm-delete hosted with ❤ by GitHub
UPDATE  
    "users"  
SET  
    "deleted_at" = $1  
WHERE  
    (  
        id = $2  
    )  
AND (  
        "deleted_at" IS NULL  
    OR  "deleted_at" = $3  
    )

構造体に`xorm:"deleted"` があれば論理削除、なければ物理削除になります。フィールドのタグで判定しているので、DeletedAt というフィールドがあっても、タグがなければ物理削除です。

type friendForPhysicalDelete struct {
DeletedAt time.Time
}
func (friendForPhysicalDelete) TableName() string {
return "friends"
}
{
f := friendForPhysicalDelete{}
affected, err = engine.
Where("user_id = ? OR friend_id = ?", id, id).
Delete(&f)
if err != nil {
log.Println(err)
return
}
if affected == 0 {
log.Println("Not Found")
return
}
}
DELETE  
FROM  
    "friends"  
WHERE  
    (  
        user_id = $1  
    OR  friend_id = $2  
    )

Unscoped() でも物理削除できます。

affected, err = engine.
Unscoped().
Where("id = ?", id).
Delete(&u)
if err != nil {
log.Println(err)
return
}
if affected == 0 {
log.Println("Not Found")
return
}
DELETE  
FROM  
    "users"  
WHERE  
    (  
        id = $1  
    )  
AND "deleted_at" = $2

その他

Exec()でCRUD以外も実行できます。

_, err := engine.Exec("CREATE ROLE role_name LOGIN PASSWORD 'password'")
if err != nil {
log.Println(err)
return
}
_, err = engine.Exec("DROP ROLE role_name")
if err != nil {
log.Println(err)
return
}
view raw go-xorm-other hosted with ❤ by GitHub
CREATE ROLE role_name LOGIN PASSWORD 'password'

DROP ROLE role_name

以上です。他にいい感じの機能ががあれば追記するかもです。

参考文献