Go

Go言語 – XORM

こんにちは、イサムです!

本記事では、Go言語のORMであるXORMについてまとめていきます。
Go言語でDBを操作していきます。
今回は、ソースコードが長いので理解しにくいと思います、申し訳ありません。

対象読者
  • Go言語のORMのXORMついて知りたい!
  • Go言語でDBを操作したい!
この記事でやること
  • Go言語のORMのXORMついてのまとめ

はじめに

一般的にリレーショナルデータベース(RDB: Relational DataBase)内のレコードを扱う際には、DBのための専用言語であるSQL(Structured Query Language)を使用しますが、DBはデータを効率的に管理、検索することに特化した構造となっているため、アプリケーションを実装する際によく使用されるオブジェクト指向の構造とは大きな差があります。
そこで、ORM(Object Relational Mapping)は、このRDBとオブジェクト指向の二つの世界の構造を自動でマッピングしてくれます。
機能としては、下記になります。

  • オブジェクト(Goでは構造体)とDBから取得したレコードの関連付け
  • SQL文の組み立て

ORMを使用することで、Goの型とDBのテーブルを透過的に扱うことができるため、生のSQL文を使用せずに関数を使ってSELECTやINSERTなどのSQLクエリを組み立てる機能を持つライブラリもあります。
ライブラリの種類としては、GORM、XORM、SQLBoiler、gorpなどがありますが、今回はXORMについてみていきたいと思います。

用語

簡単にですが、DBを扱う際に使用する用語を下記にまとめます。

データベース(DB: DataBase)

決まった形式で整理されたデータの集まりのことで、データを1か所に集めることで、いつでもすぐに利用できたり、大量のデータを安全に管理できます。
DBの種類としては下記のようなものがあります。

  • RDBMS(Relational DataBase Management System)
  • 階層型データベース
  • オブジェクト指向データベース
  • XMLデータベース
  • NoSQLデータベース

今回は、2次元表で管理するRDBMSを使用します。

SQL(Structured Query Language)

RDBがデータ操作できるために備えている言語。
データの定義、操作、制御などができます。

クエリ

DBに対してデータの検索などの処理を行うように要求する命令文。

サブクエリ

1つのクエリの中で別のSELECT(検索と取得)クエリのことで、取得したデータはメインクエリで使用できます。

CRUD

CRUDは、Create(追加)、Read(検索)、Update(更新)、Delete(削除)のイニシャルを並べた用語になり、DBではよく使用されます。

エンジン

DBを操作(追加/検索/更新/削除)するための基礎となるソフトウェア。

ドライバー

アプリケーションがDBとやり取りするためAPIが実装されたソフトウェア。

コネクション

SQLを実行するためのアプリケーションとDBの通信経路。

コネクションプール

アプリケーションがDBへアクセスする際、アクセス要求の度に接続や切断を繰り返すのではなく、一度生成した接続窓口を維持し続けて再利用することになります。

セッション

2つ以上の通信機器が接続を確立してから切断するまでの一連の通信。

トランザクション

データの検索や更新等を含む一連の処理の単位。

コミット

トランザクション処理を確定させてDBに結果を反映させること。

ロールバック

データ更新などトランザクション処理中に障害が発生した時に、その前の状態に戻すこと。

XORM使い方

ディレクトリ構成

今回XORMを使用できる環境のディレクトリの構造は下記になり、ドメイン駆動とレイヤードアーキテクチャのオニオンアーキテクチャを採用しています。
(ドメイン駆動とレイヤードアーキテクチャについては、今後別の記事にまとめます。)

tree api/
├── app ※アプリケーション層
│ └── server
│     └── handler ※ハンドラ層(リクエストの処理の責務/サービス層に依存)
│         └── request ※リクエストのパラメータの定義
│             └── departments.go
│             └── users.go
│         └── response ※レスポンスの定義
│             └── departments.go
│             └── users.go
│         └── departments.go
│         └── error.go
│         └── handler.go
│         └── users.go
│         └── validator.go ※リクエストのバリデーション
│     └── server.go ※リクエスト処理サーバー
├── cmd ※アプリケーションのコマンド
│ └── main.go
├── config ※アプリケーションの設定処理関連
│ └── config.go
├── db ※DB関連
│ └── .env
│ └── conf.d
│     └── my.conf
│ └── init.d
│     └── 000001_init_scheme.up.sql ※ 今回使用するDBデータ定義
│     └── 000002_init_data.up.sql
├── domain ※ドメイン層
│ └── repo ※リポジトリ(データ処理のインターフェイス定義/サービス層とインフラ層を結合)
│     └── model ※業務ドメイン知識の定義
│         └── departments.go
│         └── users.go
│     └── departments.go
│     └── repo.go
│     └── users.go
│ └── service ※サービス層(業務ロジック処理の責務/インフラ層に依存)
│     └── departments.go
│     └── error.go
│     └── service.go
│     └── users.go
├── infra ※インフラ層(データ処理の責務)
│ └── departments.go
│ └── error.go
│ └── infra.go
│ └── users.go
├── .env ※環境変数定義
├── Dockerfile.yml
├── docker-compose.yml
├── Makefile

インストール

下記コマンドでxormのパッケージをインストールします。

go install xorm.io/xorm

// DBとやり取りするためにドライバが必要(今回はMySQL)
go install github.com/go-sql-driver/mysql

// サブクエリを使用する場合に使用
go install xorm.io/builder

XORMタグについて

DBアクセス時に使用する構造体のタグに、xormタグを付与することができます。
タグはいくつか種類がありそれぞれのタグで機能が異なります。

タグルール
column_name対応するDBのカラム名を指定し、自動で判断
(DBカラム: FirstName -> xormタグ: first_name)
pk指定のカラムをプライマリキー制約として自動で判断
null | not null指定のカラムをnullまたはnot null制約として自動で判断
extends結合したテーブルのデータをマッピングする構造体のフィールドには、結合する各テーブルのデータの構造体を定義し、その構造体にこのタグを付与することにより、結合したテーブルのデータをマッピング
created新規レコード追加(Insert)時に現在時刻を自動登録
updated新規レコード追加(Insert)/レコード更新(Update)時に現在時刻を自動登録
deletedレコード削除(Delete)時に現在時刻を自動登録
このタグを付与すれば、論理削除になり、データ自体は削除されない
(Unscopedメソッドを使用した場合、物理削除される)
defaultデフォルト値を設定
comment指定したカラムのコメント(説明)を設定
(MySQLのみの機能)

deletedタグの論理削除と物理削除についてですが、
論理削除は、deleted_atなどのカラムを用意し、そのカラムがnull以外のデータがあることで「削除されているレコード」とみなし、実際にレコードは削除しません。
物理削除は、レコードをDB上から完全に削除してしまうことになります。
論理削除の場合は、削除実行後もデータは存在するため復元が可能ですが、データ量が多い場合DB容量を圧迫してしまったり、データの検索等で毎回削除されているかの確認の処理が必要になります。物理削除は、論理削除の反対の結果になるため、システムの仕様によってどちらかを選択する必要があります。
また、deletedタグを使用すると、自動的にwhere句にdeleted_atカラムがNULLまたはゼロ値かという条件が追加されますが、後述するUnscopedメソッドを使用することで無効化することができます。

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

使用するソースコード

今回使用する全てのソースコードは下記になります。
各メソッドの紹介する章では、必要な部分のみを抜粋します。

package request

type Departments struct {
  IsUsed bool `query:"is_used"`
}
package request

type CreateUser struct {
  EmployeeNumber string `json:"employee_number" validate:"required,len=8"`
  FirstName      string `json:"first_name" validate:"required,max=255"`
  LastName       string `json:"last_name" validate:"required,max=255"`
  Email          string `json:"email" validate:"required,max=255"`
  DepartmentID   int    `json:"department_id" validate:"required,min=1,max=255"`
}

type CreateUsers struct {
  CreateUsers []CreateUser `json:"users" validate:"required,max=100,unique_employee_number,unique_email"`
}

type GetUser struct {
  EmployeeNumber string `query:"employee_number" validate:"required,len=8"`
}

type GetUsers struct {
  QueryEmployeeNumbers string   `query:"employee_number"`
  EmployeeNumbers      []string `validate:"required,dive,len=8"`
  IsDelete             bool     `query:"is_delete"`
  IsJoinedDepartment   bool     `query:"is_joined_department"`
}

type UpdateUser struct {
  FirstName    string `json:"first_name" validate:"max=255"`
  LastName     string `json:"last_name" validate:"max=255"`
  Email        string `json:"email" validate:"max=255"`
  DepartmentID int    `json:"department_id" validate:"min=1,max=255"`
}

type UpdateUsers struct {
  ParamEmployeeNumbers string       `param:"employee_number" validate:"required"`
  EmployeeNumbers      []string     `validate:"required,unique,dive,len=8"`
  UpdateUsers          []UpdateUser `json:"users" validate:"required,max=100,unique_email"`
}

type DeleteUsers struct {
  ParamEmployeeNumbers string   `param:"employee_number" validate:"required"`
  EmployeeNumbers      []string `validate:"required,unique,dive,len=8"`
  IsPhysical           bool     `query:"is_physical"`
}
package response

import "time"

type Departments struct {
  Departments []Department `json:"departments"`
}

type Department struct {
  ID             string    `json:"id"`
  DepartmentName string    `json:"department_name"`
  CreatedAt      time.Time `json:"created"`
  UpdatedAt      time.Time `json:"updated"`
  DeletedAt      time.Time `json:"deleted"`
}
package response

import "time"

type Users struct {
  Users []User `json:"users"`
}

type User struct {
  ID             string    `json:"id"`
  EmployeeNumber string    `json:"employee_number"`
  FirstName      string    `json:"first_name"`
  LastName       string    `json:"last_name"`
  Email          string    `json:"email"`
  DepartmentID   int       `json:"department_id"`
  CreatedAt      time.Time `json:"created"`
  UpdatedAt      time.Time `json:"updated"`
  DeletedAt      time.Time `json:"deleted"`
}

type JoinedDepartment struct {
  EmployeeNumber string    `json:"employee_number"`
  FirstName      string    `json:"first_name"`
  LastName       string    `json:"last_name"`
  Email          string    `json:"email"`
  DepartmentName string    `json:"department_name"`
  CreatedAt      time.Time `json:"created"`
  UpdatedAt      time.Time `json:"updated"`
  DeletedAt      time.Time `json:"deleted"`
}
package handler

import (
  "net/http"

  "github.com/github-name/repository-name/app/server/handler/request"
  "github.com/github-name/repository-name/domain/service"

  "github.com/labstack/echo/v4"
  "github.com/pkg/errors"
)

type Departments interface {
  GetDepartments(c echo.Context) error
}

type departments struct {
  service service.Service
}

func NewDepartments(service service.Service) Departments {
  return &departments{
    service: service,
  }
}

func (d *departments) GetDepartments(c echo.Context) error {

  var req request.Departments

  if err := c.Bind(&req); err != nil {
    err = errors.Wrap(err, ErrBindRequest)
    return c.JSON(http.StatusBadRequest, NewErrorResponse(http.StatusBadRequest, ErrCodeBadRequest, err))
  }

  if err := c.Validate(&req); err != nil {
    err = errors.Wrap(err, ErrValidateRequest)
    return c.JSON(http.StatusBadRequest, NewErrorResponse(http.StatusBadRequest, ErrCodeBadRequest, err))
  }

  departments, err := d.service.GetDepartments(c, req)

  if err != nil {
    err = errors.Wrap(err, "GetDepartments in service")
    return c.JSON(http.StatusInternalServerError, NewErrorResponse(http.StatusInternalServerError, ErrGetDepartment, err))
  }

  return c.JSON(http.StatusOK, departments)
}
package handler

import (
  "reflect"
)

var (
	ErrCodeBadRequest  = 1
	ErrCodeGetUser     = 2
	ErrCodeGetUsers    = 3
	ErrCodeCreateUsers = 4
	ErrCodeUpdateUsers = 5
	ErrCodeDeleteUsers = 6
	ErrGetDepartment   = 7
)

var (
	ErrBindRequest     = "failed to bind request data"
	ErrValidateRequest = "invalid condition parameter"
)

type Error struct {
	Message string `json:"message"`
	Code    int    `json:"code"`
}

type ErrorResponse struct {
	Status int `json:"status"`
	Error  Error
}

func NewErrorResponse(httpStatus int, errCode int, errMeasage interface{}) ErrorResponse {
  return ErrorResponse{
    Status: httpStatus,
    Error: Error{
      Message: getString(errMeasage),
      Code:    errCode,
    },
  }
}

func getString(i interface{}) string {
  switch v := i.(type) {
  case error:
    return v.Error()
  default:
    return reflect.ValueOf(v).String()
  }
}
package handler

import "github.com/github-name/repository-name/domain/service"

type Handler interface {
  Users
  Departments
}

type handler struct {
  Users
  Departments
}

func NewHandler(service service.Service) Handler {
  return &handler{
    Users:       NewUsers(service),
    Departments: NewDepartments(service),
  }
}
package handler

import (
  "net/http"

  "github.com/github-name/repository-name/app/server/handler/request"
  "github.com/github-name/repository-name/domain/service"

  "github.com/labstack/echo/v4"
  "github.com/pkg/errors"
)

type Users interface {
  GetUser(c echo.Context) error
  GetUsers(c echo.Context) error
  CreateUsers(c echo.Context) error
  UpdateUsers(c echo.Context) error
  DeleteUsers(c echo.Context) error
}

type users struct {
  service service.Service
}

func NewUsers(s service.Service) Users {
  return &users{
    service: s,
  }
}

func (u *users) GetUser(c echo.Context) error {
  var req request.GetUser
  if err := c.Bind(&req); err != nil {
    err = errors.Wrap(err, ErrBindRequest)
    return c.JSON(http.StatusBadRequest, NewErrorResponse(http.StatusBadRequest, ErrCodeBadRequest, err))
  }

  if err := c.Validate(req); err != nil {
    err = errors.Wrap(err, ErrValidateRequest)
    return c.JSON(http.StatusBadRequest, NewErrorResponse(http.StatusBadRequest, ErrCodeBadRequest, err))
  }

  user, err := u.service.GetUser(c, req)

  if err != nil {
    err = errors.Wrap(err, "GetUser() in service")
    resp := NewErrorResponse(http.StatusInternalServerError, ErrCodeGetUsers, err)
    return c.JSON(http.StatusInternalServerError, resp)
  }
  return c.JSON(http.StatusOK, user)
}

// 構造体にインターフェースのメソッドを実装
func (u *users) GetUsers(c echo.Context) error {
  var req request.GetUsers

  if err := c.Bind(&req); err != nil {
    err = errors.Wrap(err, ErrBindRequest)
    return c.JSON(http.StatusBadRequest, NewErrorResponse(http.StatusBadRequest, ErrCodeBadRequest, err))
  }

  if req.QueryEmployeeNumbers != "" {
    req.EmployeeNumbers = strings.Split(req.QueryEmployeeNumbers, ",")
  }

  if err := c.Validate(&req); err != nil {
    err = errors.Wrap(err, ErrValidateRequest)
    return c.JSON(http.StatusBadRequest, NewErrorResponse(http.StatusBadRequest, ErrCodeBadRequest, err))
  }

  if req.IsJoinedDepartment {
    usersJoinedDepartments, err := u.service.GetUsersJoinedDepartment(c, req)

    if err != nil {
      err = errors.Wrap(err, "GetUsersJoinedDepartment in service")
      resp := NewErrorResponse(http.StatusInternalServerError, ErrCodeGetUsers, err)
      return c.JSON(http.StatusInternalServerError, resp)
    }

    return c.JSON(http.StatusOK, usersJoinedDepartments)

  } else {
    users, err := u.service.GetUsers(c, req)
    if err != nil {
      err = errors.Wrap(err, "GetUsers in service")
      resp := NewErrorResponse(http.StatusInternalServerError, ErrCodeGetUsers, err)
      return c.JSON(http.StatusInternalServerError, resp)
    }
    return c.JSON(http.StatusOK, users)
  }
}

func (u *users) CreateUsers(c echo.Context) error {
  var req request.CreateUsers
  if err := c.Bind(&req); err != nil {
    err = errors.Wrap(err, ErrBindRequest)
    return c.JSON(http.StatusBadRequest, NewErrorResponse(http.StatusBadRequest, ErrCodeBadRequest, err))
  }

  if err := c.Validate(req); err != nil {
    err = errors.Wrap(err, "invalid condition parameter")
    return c.JSON(http.StatusBadRequest, NewErrorResponse(http.StatusBadRequest, ErrCodeBadRequest, err))
  }

  users, err := u.service.CreateUsers(c, req)

  if err != nil {
    err = errors.Wrap(err, "CreateUsers() in service")
    resp := NewErrorResponse(http.StatusInternalServerError, ErrCodeCreateUsers, err)
    return c.JSON(http.StatusInternalServerError, resp)
  }
  return c.JSON(http.StatusOK, users)
}

func (u *users) UpdateUsers(c echo.Context) error {
  var req request.UpdateUsers
  if err := c.Bind(&req); err != nil {
    err = errors.Wrap(err, ErrBindRequest)
    return c.JSON(http.StatusBadRequest, NewErrorResponse(http.StatusBadRequest, ErrCodeUpdateUsers, err))
  }

  if req.ParamEmployeeNumbers != "" {
    req.EmployeeNumbers = strings.Split(req.ParamEmployeeNumbers, ",")
  }

  if err := c.Validate(&req); err != nil {
    err = errors.Wrap(err, ErrValidateRequest)
    return c.JSON(http.StatusBadRequest, NewErrorResponse(http.StatusBadRequest, ErrCodeUpdateUsers, err))
  }

  users, err := u.service.UpdateUsers(c, req)

  if err != nil {
    err = errors.Wrap(err, "UpdateUsers() in service")
    resp := NewErrorResponse(http.StatusBadRequest, ErrCodeUpdateUsers, err)
    return c.JSON(http.StatusInternalServerError, resp)
  }
  return c.JSON(http.StatusOK, users)
}

func (u *users) DeleteUsers(c echo.Context) error {
  var req request.DeleteUsers

  if err := c.Bind(&req); err != nil {
    err = errors.Wrap(err, ErrBindRequest)
    return c.JSON(http.StatusBadRequest, NewErrorResponse(http.StatusBadRequest, ErrCodeBadRequest, err))
  }

  if req.ParamEmployeeNumbers != "" {
    req.EmployeeNumbers = strings.Split(req.ParamEmployeeNumbers, ",")
  }

  if err := c.Validate(&req); err != nil {
    err = errors.Wrap(err, ErrValidateRequest)
    return c.JSON(http.StatusBadRequest, NewErrorResponse(http.StatusBadRequest, ErrCodeBadRequest, err))
  }

  err := u.service.DeleteUsers(c, req)

  if err != nil {
    err = errors.Wrap(err, "DeleteUsers() in service")
    return c.JSON(http.StatusInternalServerError, NewErrorResponse(http.StatusInternalServerError, ErrCodeDeleteUsers, err))
  }
  return c.JSON(http.StatusNoContent, nil)
}
package handler

import (
  "github.com/go-playground/validator/v10"
  "github.com/labstack/echo/v4"
)

type CustomValidator struct {
  validator *validator.Validate
}

func NewValidator() echo.Validator {
  validator := validator.New()
  _ = validator.RegisterValidation("unique_employee_number", IsUniqueEmployeeNumber)
  _ = validator.RegisterValidation("unique_email", IsUniqueEmail)
  return &CustomValidator{validator: validator}
}

func (cv *CustomValidator) Validate(i interface{}) error {
  return cv.validator.Struct(i)
}

func IsUniqueEmployeeNumber(fl validator.FieldLevel) bool {

  if fl.Field().Kind() != reflect.Slice {
    return false
  }

  validEmployeeNumbers := map[string]struct{}{}
  for i := 0; i < fl.Field().Len(); i++ {
    if fl.Field().Index(i).Kind() != reflect.Struct {
      return false
    }
    switch value := fl.Field().Index(i).FieldByName("EmployeeNumber"); value.Kind() {
    case reflect.String:
      if _, has := validEmployeeNumbers[value.String()]; has {
        return false
      } else {
        validEmployeeNumbers[value.String()] = struct{}{}
      }
    default:
      return false
    }

  }
  return true
}

func IsUniqueEmail(fl validator.FieldLevel) bool {

  if fl.Field().Kind() != reflect.Slice {
    return false
  }

  validEmails := map[string]struct{}{}
  for i := 0; i < fl.Field().Len(); i++ {
    if fl.Field().Index(i).Kind() != reflect.Struct {
      return false
    }

    switch value := fl.Field().Index(i).FieldByName("Email"); value.Kind() {
    case reflect.String:
      if _, has := validEmails[value.String()]; has {
        return false
      } else {
        validEmails[value.String()] = struct{}{}
      }
    default:
      return false
    }
  }
  return true
}
package server

import (
  "github.com/github-name/repository-name/app/server/handler"

  "github.com/labstack/echo/v4"
)

type server struct {
  echo *echo.Echo
}

func NewServer(e *echo.Echo) *server {
  return &server{
    echo: e,
  }
}

func (s *server) RegisterHandler(h handler.Handler) *server {
  s.echo.GET("/user", h.GetUser)
  s.echo.GET("/users", h.GetUsers)
  s.echo.POST("/users", h.CreateUsers)
  s.echo.PUT("/users/:employee_number", h.UpdateUsers)
  s.echo.DELETE("/user/:employee_number", h.DeleteUsers)
  s.echo.GET("/departments", h.GetDepartments)
  return s
}

func (s *server) Start(port string) error {
  return s.echo.Start(":" + port)
}
package main

import (
  "github.com/github-name/repository-name/app/server"
  "github.com/github-name/repository-name/app/server/handler"
  "github.com/github-name/repository-name/config"
  "github.com/github-name/repository-name/domain/service"
  "github.com/github-name/repository-name/infra"

  _ "github.com/go-sql-driver/mysql"
  "github.com/labstack/echo/v4"
  "github.com/labstack/echo/v4/middleware"
  "xorm.io/xorm"
  xlog "xorm.io/xorm/log"
)

func NewDB() *xorm.Engine {
  // DBエンジン作成のための文字列作成
  dns := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8&parseTime=true&loc=UTC",
    config.DB.ID, config.DB.Password, config.DB.Host, config.DB.Port, config.DB.DB)

  // エンジン作成
  db, err := xorm.NewEngine("mysql", dns)

  if err != nil {
    log.Fatal(err)
	}

  // ロガーの作成と各種設定
  logger := xlog.NewSimpleLogger(os.Stdout)
  db.SetLogger(logger)
  db.ShowSQL(true)
  db.SetLogLevel(config.DB.LogLevel)

  return db
}

func NewHandler(db *xorm.Engine) handler.handler {

  repo := infra.NewDBRepository(db)
  service := service.NewService(repo)
  handler := handler.NewHandler(service)

  return handler
}

func main() {

  // 環境設定ファイルを読み込み
  if err := config.ReadConfig(); err != nil {
    log.Fatal(err)
	}

  // DBインスタンス作成
  db := NewDB()

  // APIサーバー構築
  handler := NewHandler(db)
  echo := echo.New()
	e.Validator = handler.NewValidator()

  // ハンドラ登録
  server := server.NewServer(echo).RegisterHandler(handler)
  e.Use(middleware.CORSWithConfig(middleware.CORSConfig{
    AllowOrigins: config.App.AllowOrigins,
    AllowMethods: []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPatch, http.MethodPost, http.MethodDelete},
  }))

  server := server.NewServer(e).RegisterHandler(h)
  log.Fatal(server.Start(config.App.APIPort))

}
package config

import (
  "fmt"
  "os"
  "strconv"
  "strings"

  xlog "xorm.io/xorm/log"
)

type db struct {
  ID            string        `env:"DB_ID"`
  Password      string        `env:"DB_PASSWORD"`
  Host          string        `env:"DB_HOST"`
  Port          string        `env:"DB_PORT"`
  DB            string        `env:"DB_DB"`
  LogLevel      xlog.LogLevel `env:"DB_LOG_LEVEL"`
  MaxConnection int           `env:"DB_MAX_CONNECTION"`
}

type app struct {
  AllowOrigins []string `env:"ALLOW_CORS_ORIGINS"`
  APIPort      string   `env:"API_PORT"`
}

var (
  DB  *db
  App *app
)

func ReadConfig() error {
	envKeys := []string{
    "DB_ID",
    "DB_PASSWORD",
    "DB_HOST",
    "DB_PORT",
    "DB_DB",
    "ALLOW_CORS_ORIGINS",
    "API_PORT",
  }

  env := map[string]string{}

  for _, k := range envKeys {
    v := os.Getenv(k)
    if v == "" {
      return fmt.Errorf("cannot get environment variable %s", k)
    }
    env[k] = v
	}

  dbLogLevel := getLogLevelFromStr(os.Getenv("DB_LOG_LEVEL"))

  DB = &db{
    ID:            env["DB_ID"],
    Password:      env["DB_PASSWORD"],
    Host:          env["DB_HOST"],
    Port:          env["DB_PORT"],
    DB:            env["DB_DB"],
    LogLevel:      dbLogLevel,
    MaxConnection: dbMaxConn,
	}
  orgins := []string{}
  orgins = strings.Split(env["ALLOW_CORS_ORIGINS"], ",")

  App = &app{
    AllowOrigins: orgins,
    APIPort: env["API_PORT"],
  }

  return nil
}

// ログレベルの設定
func getLogLevelFromStr(str string) xlog.LogLevel {
  switch str {
  case "DEBUG":
    return xlog.LOG_DEBUG
  case "INFO":
    return xlog.LOG_INFO
  case "WARNING":
    return xlog.LOG_WARNING
  case "ERR":
    return xlog.LOG_ERR
  case "OFF":
    return xlog.LOG_OFF
  default:
    return xlog.LOG_INFO
  }
}
[mysqld]
character-set-server=utf8mb4
explicit-defaults-for-timestamp=1
general-log=1
general-log-file=/var/log/mysql/mysqld.log

[client]
default-character-set=utf8mb4
-- 部署マスタ
CREATE TABLE m_department (
  id INT NOT NULL,
  department_name VARCHAR(255) NOT NULL,
  created_at DATETIME NOT NULL,
  updated_at DATETIME NOT NULL,
  deleted_at DATETIME,
  PRIMARY KEY (id)
);

-- ユーザーマスタ
create table users(
  id VARCHAR(20) NOT NULL,
  first_name  VARCHAR(255) NOT NULL,
  last_name  VARCHAR(20) NOT NULL,
  email VARCHAR(255) NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  deleted_at TIMESTAMP,
  PRIMARY KEY (id)
);
INSERT INTO
  m_department
VALUES
  (1, '総務部', NOW(), NOW(), NULL),
  (2, '人事部', NOW(), NOW(), NULL),
  (3, '事業部', NOW(), NOW(), NULL),
  (4, '営業部', NOW(), NOW(), NULL),
  (5, '経理部', NOW(), NOW(), NULL),
  (6, '開発部', NOW(), NOW(), NULL),
  (7, '製造部', NOW(), NOW(), NULL);
MYSQL_ROOT_PASSWORD=users
MYSQL_DATABASE=users
MYSQL_USER=users
MYSQL_PASSWORD=users
package model

import "time"

type Department struct {
  ID             int       `xorm:"id not null"`
  DepartmentName string    `xorm:"not null"`
  CreatedAt      time.Time `xorm:"created"`
  UpdatedAt      time.Time `xorm:"updated"`
  DeletedAt      time.Time `xorm:"deleted"`
  IsUsed         bool      `xorm:"-"`
}

func (Department) TableName() string {
  return "m_department"
}
package model

import "time"

type User struct {
  ID             string    `xorm:"id pk not null"`
  EmployeeNumber string    `xorm:"not null"`
  FirstName      string    `xorm:"not null"`
  LastName       string    `xorm:"not null"`
  Email          string    `xorm:"not null"`
  DepartmentID   int       `xorm:"department_id not null"`
  CreatedAt      time.Time `xorm:"created"`
  UpdatedAt      time.Time `xorm:"updated"`
  DeletedAt      time.Time `xorm:"deleted"`
}

func (User) TableName() string {
  return "t_users"
}

type GetUsersCondition struct {
  EmployeeNumbers []string
  IsDelete        bool
}

type JoinedDepartment struct {
  User       User       `xorm:"extends"`
  Department Department `xorm:"extends"`
}

func (JoinedDepartment) TableName() string {
  return "t_users"
}

type UpdateUser struct {
  EmployeeNumber string    `xorm:"not null"`
  FirstName      string    `xorm:"not null"`
  LastName       string    `xorm:"not null"`
  Email          string    `xorm:"not null"`
  DepartmentID   int       `xorm:"department_id not null"`
  CreatedAt      time.Time `xorm:"created"`
  UpdatedAt      time.Time `xorm:"updated"`
}

func (UpdateUser) TableName() string {
  return "t_users"
}

type UpdateUsers struct {
  EmployeeNumbers []string
  UpdateUsers     []UpdateUser
}

func (UpdateUsers) TableName() string {
  return "t_users"
}

type DeleteUsers struct {
  EmployeeNumbers []string
  IsPhysical      bool
}

func (DeleteUsers) TableName() string {
  return "t_users"
}
package repo

import (
  "github.com/github-name/repository-name/domain/repo/model"

  "github.com/labstack/echo/v4"
)

type Departments interface {
  GetDepartments(c echo.Context, cond model.Department) ([]model.Department, error)
}
package repo

type Repo interface {
  Users
  Departments
}
package repo

import (
  "github.com/github-name/repository-name/domain/repo/model"

  "github.com/labstack/echo/v4"
)

type Users interface {
  GetUser(c echo.Context, user model.User) (*model.User, error)
  GetUsers(c echo.Context, condition model.GetUsersCondition) ([]model.User, error)
  GetUsersJoinedDepartment(c echo.Context, condition model.GetUsersCondition) ([]model.JoinedDepartment, error)
  CreateUsers(c echo.Context, users []model.User) ([]model.User, error)
  UpdateUsers(c echo.Context, users model.UpdateUsers) ([]model.User, error)
  DeleteUsers(c echo.Context, users model.DeleteUsers) error
}
package service

import (
  "github.com/github-name/repository-name/app/server/handler/request"
  "github.com/github-name/repository-name/app/server/handler/response"
  "github.com/github-name/repository-name/domain/repo"
  "github.com/github-name/repository-name/domain/repo/model"

  "github.com/jinzhu/copier"
  "github.com/labstack/echo/v4"
  "github.com/pkg/errors"
)

type Departments interface {
  GetDepartments(c echo.Context, req request.Department) (*response.Departments, error)
}

type departments struct {
  repo repo.Repo
}

func NewDepartments(r repo.Repo) Departments {
  return &departments{
    repo: r,
  }
}

func (d *departments) GetDepartments(c echo.Context, req request.Department) (*response.Departments, error) {
  var cond model.Department

  if err := copier.Copy(&cond, req); err != nil {
    return nil, errors.Wrap(err, ErrCopyRequest)
  }

  departments, err := d.repo.GetDepartments(c, cond)

  if err != nil {
    return nil, errors.Wrap(err, "GetDepartments() in infra")
  }

  var resp response.Departments

  if err := copier.Copy(&resp.Departments, departments); err != nil {
    return nil, errors.Wrap(err, "failed to copy departments")
  }

  return &resp, nil
}
package service

var (
  ErrCopyRequest = "failed to copy request"
)
package service

import "github.com/github-name/repository-name/domain/repo"

type Service interface {
  Users
  Departments
}

type service struct {
  Users
  Departments
}

func NewService(repo repo.Repo) Service {
  return &service{
    Users:       NewUsers(repo),
    Departments: NewDepartments(repo),
  }
}
package service

import (
  "github.com/github-name/repository-name/app/server/handler/response"
  "github.com/github-name/repository-name/domain/repo"

  "github.com/jinzhu/copier"
	"github.com/labstack/echo/v4"
  "github.com/pkg/errors"
)

type Users interface {
  GetUser(c echo.Context, req request.GetUser) (*response.User, error)
  GetUsers(c echo.Context, req request.GetUsers) (*response.Users, error)
  GetUsersJoinedDepartment(c echo.Context, req request.GetUsers) ([]response.JoinedDepartment, error)
  CreateUsers(c echo.Context, req request.CreateUsers) (*response.Users, error)
  UpdateUsers(c echo.Context, req request.UpdateUsers) (*response.Users, error)
  DeleteUsers(c echo.Context, req request.DeleteUsers) error
}

type users struct {
  repo repo.Repo
}

func NewUsers(r repo.Repo) Users {
  return &users{
    repo: r,
  }
}

func (u *users) GetUser(c echo.Context, req request.GetUser) (*response.User, error) {
  var modelUser model.User

  if err := copier.Copy(&modelUser, req); err != nil {
    return nil, errors.Wrap(err, ErrCopyRequest)
  }

  user, err := u.repo.GetUser(c, modelUser)

  if err != nil {
    return nil, errors.Wrap(err, "GetUser() in infra")
  }

  var res response.User

  if err := copier.Copy(&res, user); err != nil {
    return nil, errors.Wrap(err, "failed to copy user")
  }
  return &res, nil
}

func (u *users) GetUsers(c echo.Context, req request.GetUsers) (*response.Users, error) {
  var conditionGetUsers model.GetUsersCondition

  conditionGetUsers.EmployeeNumbers = req.EmployeeNumbers
  conditionGetUsers.IsDelete = req.IsDelete

  users, err := u.repo.GetUsers(c, conditionGetUsers)

  if err != nil {
    return nil, errors.Wrap(err, "GetUsers() in infra")
	}

  var res response.Users

  if err := copier.Copy(&res.Users, users); err != nil {
    return nil, errors.Wrap(err, "failed to copy users")
  }
  return &res, nil
}

func (u *users) GetUsersJoinedDepartment(c echo.Context, req request.GetUsers) ([]response.JoinedDepartment, error) {
  var conditionGetUsers model.GetUsersCondition

  conditionGetUsers.EmployeeNumbers = req.EmployeeNumbers
  conditionGetUsers.IsDelete = req.IsDelete

  usersJoinedDepartments, err := u.repo.GetUsersJoinedDepartment(c, conditionGetUsers)

  if err != nil {
    return nil, errors.Wrap(err, "GetUsersJoinedDepartment() in infra")
  }

  res := make([]response.JoinedDepartment, 0, len(usersJoinedDepartments))

  for _, userJoinedDepartment := range usersJoinedDepartments {
    res = append(res, response.JoinedDepartment{
      EmployeeNumber: userJoinedDepartment.User.EmployeeNumber,
      FirstName:      userJoinedDepartment.User.FirstName,
      LastName:       userJoinedDepartment.User.LastName,
      Email:          userJoinedDepartment.User.Email,
      DepartmentName: userJoinedDepartment.Department.DepartmentName,
      CreatedAt:      userJoinedDepartment.User.CreatedAt,
      UpdatedAt:      userJoinedDepartment.User.UpdatedAt,
      DeletedAt:      userJoinedDepartment.User.DeletedAt,
    })
  }
	return res, nil
}

func (u *users) CreateUsers(c echo.Context, req request.CreateUsers) (*response.Users, error) {
  var newUsers []model.User

  if err := copier.Copy(&newUsers, req.CreateUsers); err != nil {
    return nil, errors.Wrap(err, ErrCopyRequest)
  }

  users, err := u.repo.CreateUsers(c, newUsers)

  if err != nil {
    return nil, errors.Wrap(err, "CreateUsers() in infra")
  }

  var res response.Users

  if err := copier.Copy(&res.Users, users); err != nil {
    return nil, errors.Wrap(err, "failed to copy created users")
  }
  return &res, nil
}

func (u *users) UpdateUsers(c echo.Context, req request.UpdateUsers) (*response.Users, error) {
  var modelUpdateUsers model.UpdateUsers

  if err := copier.Copy(&modelUpdateUsers, req); err != nil {
    err = errors.Wrap(err, ErrCopyRequest)
  }

  users, err := u.repo.UpdateUsers(c, modelUpdateUsers)

  if err != nil {
    return nil, errors.Wrap(err, "UpdateUsers() in infra")
  }

  var res response.Users

  if err := copier.Copy(&res.Users, users); err != nil {
    return nil, errors.Wrap(err, "failed to copy updated users")
  }
  return &res, nil
}

func (u *users) DeleteUsers(c echo.Context, req request.DeleteUsers) error {
  var modelDeleteUsers model.DeleteUsers

  if err := copier.Copy(&modelDeleteUsers, req); err != nil {
    return errors.Wrap(err, ErrCopyRequest)
  }

  err := u.repo.DeleteUsers(c, modelDeleteUsers)

  if err != nil {
    return errors.Wrap(err, "DeleteUsers() in infra")
	}
  return nil
}
package infra

import (
  "github.com/github-name/repository-name/domain/repo"
  "github.com/github-name/repository-name/domain/repo/model"

  "github.com/labstack/echo/v4"
  "github.com/pkg/errors"

  "xorm.io/builder"
  "xorm.io/xorm"
)

type departments struct {
  engine *xorm.Engine
}

func NewDepartments(engine *xorm.Engine) repo.Departments {
  return &departments{
    engine: engine,
  }
}

func (d *departments) GetDepartments(c echo.Context, cond model.Department) ([]model.Department, error) {
  var modelDepartment []model.Department

  if cond.IsUsed {

    subQuery := builder.
      Select("department_id").
      From("t_users").
      Where(builder.IsNull{"deleted_at"})

    count, err := d.engine.In("id", subQuery).Asc("id").FindAndCount(&modelDepartment)

    if err != nil {
      return nil, errors.Wrap(err, "failed to get used department")
    }

    if count == 0 {
      return nil, errors.New("All department data are not used")
    }
  } else {
    count, err := d.engine.FindAndCount(&modelDepartment)

    if err != nil {
      return nil, errors.Wrap(err, "failed to get used department")
    }

    if count == 0 {
      return nil, errors.New("Department data do not extst")
    }
  }
  return modelDepartment, nil
}
package infra

var (
  ErrSessionCommit                = "failed to commit db session"
  ErrSessionBegin                 = "failed to begin db session"
  ErrGetUsers                     = "failed to get users"
  ErrGetUser                      = "failed to get user"
  ErrCheckExistenceEmployeeNumber = "failed to check existence of employee numnber"
  ErrExistenceEmployeeNumber      = "Some employee nunmbers are already existing"
  ErrCheckExistenceEmail          = "failed to check existence of email"
  ErrExistenceEmail               = "Some Emails are already existing"
  ErrCheckExistenceDepartmentID   = "failed to check existence of Department ID"
  ErrExistenceDepartmentID        = "Some Department ids do not exist"
)
package infra

import (
  "github.com/github-name/repository-name/domain/repo"

  "xorm.io/xorm"
)

type repository struct {
  repo.Users
  repo.Departments
}

func NewDBRepository(engine *xorm.Engine) repo.Repo {
  return &repository{
    Users:       NewUsers(engine),
    Departments: NewDepartments(engine),
  }
}
package infra

import (
  "github.com/github-name/repository-name/domain/repo"
  "github.com/github-name/repository-name/domain/repo/model"

  "github.com/labstack/echo/v4"
  "github.com/pkg/errors"
  "github.com/rs/xid"
  "xorm.io/xorm"
)

type users struct {
  engine *xorm.Engine
}

func NewUsers(engine *xorm.Engine) repo.Users {
  return &users{
    engine: engine,
  }
}

func (u *users) GetUser(c echo.Context, user model.User) (*model.User, error) {
  exist, err := u.engine.Where("employee_number = ?", user.EmployeeNumber).Get(&user)

  if err != nil {
    return nil, errors.Wrap(err, ErrGetUser)
  }

  if !exist {
    return nil, errors.New("This user does not exist")
  }
  return &user, nil
}

func (u *users) GetUsers(c echo.Context, condition model.GetUsersCondition) ([]model.User, error) {
  var users []model.User

  if condition.IsDelete {
    if err := u.engine.In("employee_number", condition.EmployeeNumbers).Unscoped().Find(&users); err != nil {
      return nil, errors.Wrap(err, ErrGetUsers)
    }
  } else {
    if err := u.engine.In("employee_number", condition.EmployeeNumbers).Find(&users); err != nil {
      return nil, errors.Wrap(err, ErrGetUsers)
    }
  }
  return users, nil
}

func (u *users) GetUsersJoinedDepartment(c echo.Context, condition model.GetUsersCondition) ([]model.JoinedDepartment, error) {
  var usersJoinedDepartments []model.JoinedDepartment

  if condition.IsDelete {
    if err := u.engine.
      Join("INNER", "m_department", "t_users.department_id = m_department.id").
      In("employee_number", condition.EmployeeNumbers).
      Unscoped().
      Find(&usersJoinedDepartments); err != nil {
      return nil, errors.Wrap(err, ErrGetUsers)
    }
  } else {
    if err := u.engine.
	    Join("INNER", "m_department", "t_users.department_id = m_department.id").
      In("employee_number", condition.EmployeeNumbers).
      Find(&usersJoinedDepartments); err != nil {
      return nil, errors.Wrap(err, ErrGetUsers)
    }
  }
  return usersJoinedDepartments, nil
}

func (u *users) CreateUsers(c echo.Context, users []model.User) ([]model.User, error) {
  session := u.engine.NewSession()
  defer session.Close()

  if err := session.Begin(); err != nil {
    return nil, errors.Wrap(err, ErrSessionBegin)
  }

  for _, user := range users {
    exist, err := session.Cols("employee_number").Exist(&model.User{EmployeeNumber: user.EmployeeNumber})

    if err != nil {
      return nil, errors.Wrap(err, ErrCheckExistenceEmployeeNumber)
	  }

    if exist {
      return nil, errors.New(ErrExistenceEmployeeNumber)
    }

    exist, err = session.Cols("email").Exist(&model.User{Email: user.Email})
    if err != nil {
      return nil, errors.Wrap(err, ErrCheckExistenceEmail)
    }

    if exist {
      return nil, errors.New(ErrExistenceEmail)
    }

    exist, err = session.Table("m_department").Cols("id").Exist(&model.Department{
      DepartmentID: user.DepartmentID,
    })

    if err != nil {
      return nil, errors.Wrap(err, ErrCheckExistenceDepartmentID)
    }

    if !exist {
      return nil, errors.New("ErrExistenceDepartmentID")
    }
  }

  newIDs := make([]string, len(users))
  for i := range users {
    newIDs[i] = xid.New().String()
    users[i].ID = newIDs[i]
  }

  if _, err := session.Insert(&users); err != nil {
    _ = session.Rollback()
    return nil, errors.Wrap(err, "failed to insert users")
  }

  var createdUsers []model.User

  if err := session.In("id", newIDs).Find(&createdUsers); err != nil {
    _ = session.Rollback()
    return nil, errors.Wrap(err, "failed to get created users")
  }

  if err := session.Commit(); err != nil {
    _ = session.Rollback()
    return nil, errors.Wrap(err, ErrSessionCommit)
  }
  return createdUsers, nil
}

func (u *users) UpdateUsers(c echo.Context, users model.UpdateUsers) ([]model.User, error) {
  session := u.engine.NewSession()
  defer session.Close()

  if err := session.Begin(); err != nil {
    return nil, errors.Wrap(err, ErrSessionBegin)
  }

  for _, empNo := range users.EmployeeNumbers {
    exist, err := session.Cols("employee_number").Exist(&model.UpdateUser{EmployeeNumber: empNo})

    if err != nil {
      return nil, errors.Wrap(err, ErrCheckExistenceEmployeeNumber)
    }

    if !exist {
      return nil, errors.New(ErrExistenceDepartmentID)
    }
  }

  for i, user := range users.UpdateUsers {

    var modelUsers []model.User

    if user.Email != "" {
      exist, err := session.Cols("email").Exist(&model.UpdateUser{
        Email: user.Email,
      })

      if err != nil {
        return nil, errors.Wrap(err, ErrCheckExistenceEmail)
      }

      if len(modelUsers) == 0 && users.EmployeeNumbers[i] != modelUsers[0].EmployeeNumber {
        return nil, errors.New(ErrExistenceEmail)
      }
    }

    if user.DepartmentID != 0 {
      exist, err := session.Table("m_department").Cols("id").Exist(&model.Department{
        ID: user.DepartmentID,
      })

      if err != nil {
        return nil, errors.Wrap(err, ErrCheckExistenceDepartmentID)
      }

      if !exist {
        return nil, errors.New(ErrExistenceDepartmentID)
      }
    }
  }

  for i, user := range users.UpdateUsers {
    _, err := session.Where("employee_number = ?", users.EmployeeNumbers[i]).Update(&user)
    if err != nil {
      _ = session.Rollback()
      return nil, errors.Wrap(err, "failed to update users")
    }
  }

	var updatedUsers []model.User

	if err := session.In("employee_number", users.EmployeeNumbers).Find(&updatedUsers); err != nil {
		_ = session.Rollback()
		return nil, errors.Wrap(err, "failed to get updated users")
	}

	if err := session.Commit(); err != nil {
		_ = session.Rollback()
		return nil, errors.Wrap(err, ErrSessionCommit)
	}

	return updatedUsers, nil
}

func (u *users) DeleteUsers(c echo.Context, users model.DeleteUsers) error {
  session := u.engine.NewSession()
  defer session.Close()

  if err := session.Begin(); err != nil {
    _ = session.Rollback()
    return errors.Wrap(err, ErrSessionBegin)
  }

  for _, empNo := range users.EmployeeNumbers {

    exist, err := session.Cols("employee_number").Exist(&model.User{EmployeeNumber: empNo})

    if err != nil {
      _ = session.Rollback()
      return errors.Wrap(err, ErrCheckExistenceEmployeeNumber)
    }

    if !exist {
      _ = session.Rollback()
      return errors.New("Some employee Numbers do not exist")
    }

    if _, err := session.Delete(&model.User{
      EmployeeNumber: empNo,
    }); err != nil {
      _ = session.Rollback()
      return errors.Wrap(err, "failed to delete users")
    }
  }

  if err := session.Commit(); err != nil {
    _ = session.Rollback()
    return errors.Wrap(err, ErrSessionCommit)
  }
  return nil
}
# DB接続時に使用するユーザーのID
DB_ID=users

# DB接続時に使用するユーザーのパスワード
DB_PASSWORD=users

# DBのエンドポイント
DB_HOST=db

# DB接続時に使用するポート番号
DB_PORT=3306

# DB接続時に対象とするDB名
DB_DB=users

# DBのログレベル(DEBUG, INFO, WARNING, ERR, OFF のいずれかを指定)
DB_LOG_LEVEL=ERR
# image取得(容量が小さいalpineを選択)
FROM golang:1.17.6-alpine
# アップデートとalpineにgitがないのでインストール
RUN apk add --update &&  apk add git
# ホストのファイルをコンテナの作業ディレクトリにコピー
COPY . /go/src/app
# ワーキングディレクトリの設定
WORKDIR /go/src/app/cmd
version: "3.7"

services:
  app:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: users-app
    networks:
      - users
    depends_on:
      - "db"
    ports:
      - "80:80"
    volumes:
      - .:/go/src/app
      - ../../../../../../pkg/mod:/go/pkg/mod
    tty: true
    env_file:
      - ./.env
  db:
    image: mysql:5.7
    container_name: users-db
    networks:
      - users
    ports:
      - "3306:3306"
    volumes:
      - ./db/init.d:/docker-entrypoint-initdb.d
      - ./db/conf.d:/etc/mysql/conf.d
    command: mysqld --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
    env_file:
      - db/.env
networks:
  users:
    name: users

.PHONY: build, up, exec, down

build:
  docker-compose build --no-cache

up: build
  docker-compose up -d

exec: up
  docker-compose exec app go run main.go

down:
  docker-compose down

DB接続

xormを使用するためには、DBエンジンを作成する必要があり、今回はMySQLを利用します。(DBは、Dockerでコンテナを立ち上げています。)

// DBエンジンの構造体
type Engine struct {
  ・・・
  driverName string
  dataSourceName string
  ・・・
}

// ロガーの構造体
type SimpleLogger struct {
  DEBUG   *log.Logger
  ERR     *log.Logger
  INFO    *log.Logger
  WARN    *log.Logger
  level   LogLevel
  showSQL bool
}

// DBエンジンを作成する関数
func NewEngine(driverName string, dataSourceName string) (*Engine, error)

// ロガーを作成する関数
func NewSimpleLogger(out io.Writer) *SimpleLogger

// ロガーを設定する関数
func (engine *Engine) SetLogger(logger interface{})

// 出力するログレベルを設定する関数(設定したログレベルのみ出力される)
func (engine *Engine) SetLogLevel(level log.LogLevel)

// 実行するSQL文を標準出力に表示するかしないかを決める関数
func (engine *Engine) ShowSQL(show ...bool)
// DBエンジン作成のための文字列作成
dns := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8&parseTime=true&loc=UTC",
  config.DB.ID, config.DB.Password, config.DB.Host, config.DB.Port, config.DB.DB)

// エンジン作成
db, err := xorm.NewEngine("mysql", dns)

if err != nil {
  log.Fatal(err)
}

// ロガーの作成と各種設定
logger := xlog.NewSimpleLogger(os.Stdout)
db.SetLogger(logger)
db.ShowSQL(true)
db.SetLogLevel(config.DB.LogLevel)

CRUD – Create(Insert)

まずは、CRUDの中のデータの追加(Create)を行っていきます。
今回対象のデータは、ユーザーデータと部署データになります。
データの中には、重複が不可のユニークキーが割り当てられているデータがあるので、それらが存在していないことを確認してからデータの追加を行っていきます。
セッションを作成し、データの存在/重複チェック、データの追加、追加したデータの取得までを1つのセッションで行っていきます。その間で何かエラーがあれば、ロールバックして元の状態に戻すようにします。

セッションを生成する場合は、Session型のメソッド、生成しない場合は、Engine型のメソッドを使用します。

// DBエンジンの構造体
type Engine struct {
  ・・・
  driverName string
  dataSoutceName string
  ・・・
}

// DBセッションの構造体
type Session struct {
  ・・・
  engine *Engine
  ・・・
}

// DBセッションを作成する関数
func (engine *Engine) NewSession() *Session

// セッションを使用して、トランザクションを開始する関数
func (session *Session) Begin() error

// セッションをリリースして、DBとの接続を切断する関数
func (session *Session) Close() error

// トランザクション中に何かのエラーがあったときに、ロールバック(元に戻す)する関数
func (session *Session) Rollback() error

// トランザクション中の全てのDB操作をコミットし、DBに結果を反映させる関数
func (session *Session) Commit() error

//  指定したテーブルにレコードを追加する関数(1レコードまたは複数レコード可)
func (session *Session) Insert(beans ...interface{}) (int64, error)

// 指定したデータがレコードに存在するかを確認する関数(存在する: true/存在しない: false)
func (session *Session) Exist(bean ...interface{}) (bool, error)

// テーブルのレコードを取得する関数
func (session *Session) Find(rowsSlicePtr interface{}, condiBean ...interface{}) error

// 第1引数に指定したカラムに第2引数で指定したデータを持つレコードを抽出するための条件関数
func (session *Session) In(column string, args ...interface{}) *Session

// 第1引数に指定したテーブル名に対して操作を行うようにする関数
func (session *Session) Table(tableNameOrBean interface{}) *Session

// 第1引数に指定したカラム名に対して操作を行うようにする関数
func (session *Session) Cols(columns ...string) *Session

// 全てのカラム名に対して操作を行うようにする関数
func (session *Session) AllCols() *Session
func (u *users) CreateUsers(c echo.Context, users []model.User) ([]model.User, error) {
  session := u.engine.NewSession()
  defer session.Close()

  if err := session.Begin(); err != nil {
    return nil, errors.Wrap(err, ErrSessionBegin)
  }

  // データの存在/重複確認
  for _, user := range users {

    // Colsメソッドで対象のカラムを指定(構造体でデータがゼロ値以外のデータのみ使用するので、実際は不要)
    exist, err := session.Cols("employee_number").Exist(&model.User{EmployeeNumber: user.EmployeeNumber})

    if err != nil {
      return nil, errors.Wrap(err, ErrCheckExistenceEmployeeNumber)
	  }

    if exist {
      return nil, errors.New(ErrExistenceEmployeeNumber)
    }

    // Colsメソッドで対象のカラムを指定(構造体でデータがゼロ値以外のデータのみ使用するので、実際は不要)
    exist, err = session.Cols("email").Exist(&model.User{Email: user.Email})
    if err != nil {
      return nil, errors.Wrap(err, ErrCheckExistenceEmail)
    }

    if exist {
      return nil, errors.New(ErrExistenceEmail)
    }

    // テーブル名指定(構造体のメソッドでテーブル名は指定しているので、実際は不要)
    exist, err = session.Table("m_department").Cols("id").Exist(&model.Department{
      DepartmentID: user.DepartmentID,
    })

    if err != nil {
      return nil, errors.Wrap(err, ErrCheckExistenceDepartmentID)
    }

    if !exist {
      return nil, errors.New("ErrExistenceDepartmentID")
    }
  }

  // DBに登録するIDを生成
  newIDs := make([]string, len(users))
  for i := range users {
    newIDs[i] = xid.New().String()
    users[i].ID = newIDs[i]
  }

  // DBにユーザーを登録
  if _, err := session.Insert(&users); err != nil {
    _ = session.Rollback()
    return nil, errors.Wrap(err, "failed to insert users")
  }

  // 登録したユーザーを取得
  var createdUsers []model.User

  if err := session.In("id", newIDs).Find(&createdUsers); err != nil {
    _ = session.Rollback()
    return nil, errors.Wrap(err, "failed to get created users")
  }

  if err := session.Commit(); err != nil {
    _ = session.Rollback()
    return nil, errors.Wrap(err, ErrSessionCommit)
  }
  return createdUsers, nil
}
curl -X POST  -H "Content-Type: application/json"  http://localhost:80/users -d '{"users": [{"first_name": "Ichiro", "last_name": "Suzuki", "email": "taro_susuki@example.com"},{"first_name": "Jiro", "last_name": "Suzuki", "email": "jiro_susuki@example.com"}]}'
{"users":[{"id":"c9phu1ouj5q4aocfjs20","first_name":"Ichiro","last_name":"Suzuki","email":"taro_susuki@example.com","created":"2022-05-05T00:49:11Z","updated":"2022-05-05T00:49:11Z","deleted":"0001-01-01T00:00:00Z"},{"id":"c9phu1ouj5q4aocfjs2g","first_name":"Jiro","last_name":"Suzuki","email":"jiro_susuki@example.com","created":"2022-05-05T00:49:11Z","updated":"2022-05-05T00:49:11Z","deleted":"0001-01-01T00:00:00Z"}]}
{"status":400,"Error":{"message":"invalid condition parameter: Key: 'CreateUsers.CreateUsers' Error:Field validation for 'CreateUsers' failed on the 'unique_employee_number' tag","code":1}}
{"status":400,"Error":{"message":"invalid condition parameter: Key: 'CreateUsers.CreateUsers' Error:Field validation for 'CreateUsers' failed on the 'unique_email' tag","code":1}}
{"status":500,"Error":{"message":"CreateUsers() in service: CreateUsers() in infra: Some employee nunmbers are already existing","code":4}}
{"status":500,"Error":{"message":"CreateUsers() in service: CreateUsers() in infra: Some Email are already existing","code":2}}
{"status":500,"Error":{"message":"CreateUsers() in service: CreateUsers() in infra: Some Department ids do not exist","code":4}}
INSERT INTO "t_users"(
  "employee_number"
  "first_name",
  "last_name",
  "email",
  "department_id",
  "created_at",
  "updated_at"
  "deleted_at"
)
VALUES(
  $1,
  $2,
  $3,
  $4,
  $5
),
(
  ・・・
)

EngineまたはSession型(今回はSession型)のInsertメソッドに構造体を渡すとその構造体名をスネークケースに変換したもの(UserLog → user_log)と同じテーブル名に対して、insertしてくれます。(insertだけでなく、他のCRUDも同様)
もし、構造体名のスネークケースと違うテーブルにinsertしたい場合は、その構造体にテーブル名を返すTableNameメソッドを実装することで任意のテーブルにinsertすることができます。
その他にも、EngineまたはSession型のTableメソッドを使用し、直接テーブル名を指定することもできます。
テーブル名の参照優先順位としては、下記になります。

  1. EngineまたはSession型のTableメソッドで指定したテーブル名
  2. 使用する構造体のTableNameメソッドで返すテーブル名
  3. 構造体名をスネークケースに変換したテーブル名

また、構造体のフィールドも同様に、フィールド名をスネークケースに変換したものと同じカラムに対して処理を行います。
もし、構造体のフィールド名と異なるカラム名に対して処理したい場合は、xormタグにカラム名を指定することで、指定したカラム名に対して処理を行います。
その他にも、EngineまたはSession型のColsメソッドを使用し、直接カラム名を指定することもできます。AllColsメソッドを使用すると全てのカラムに対して処理を行います。
カラム名の参照優先順位としては、下記になります。

  1. EngineまたはSession型のAllColsメソッドまたはColsメソッドで指定したカラム名
  2. 使用する構造体のキー名のxormタグで指定したカラム名
  3. 構造体のキー名をスネークケースに変換したカラム名

CRUDで指定する構造体でColsメソッドで列名を指定しなかった場合、フィールドの型のゼロ値であれば、そのデータをDBに反映することはありませんが、指定した場合は、ゼロ値でも反映することになります。
AllColsメソッドを使用した場合も同様にゼロ値でもDBに反映することになります。

CRUD – Read(Get/Find)

次は、CRUDの中のデータの取得(Read)を行っていきます。
データの取得には2種類あり、単体取得のGetメソッドと複数取得のFindメソッドがあります。
データを取得する際は条件を付けて取得したいかと思いますが、その時は、WhereメソッドやInメソッドを使用し、複数の条件を指定したい場合は、AndメソッドまたはOrメソッドを使用することで実現できます。
また、検索データを並べ替えたい場合は、OrderByメソッド、Ascメソッド、Descメソッドなどを用いることで実現できます。

// DBエンジンの構造体
type Engine struct {
  ・・・
  driverName string
  dataSoutceName string
  ・・・
}

// DBセッションの構造体
type Session struct {
  ・・・
  engine *Engine
  ・・・
}

// テーブルの1レコードをf取得する関数
func (engine *Engine) Get(beans ...interface{}) (bool, error)

// テーブルの複数レコードをf取得する関数
func (engine *Engine) Find(beans interface{}, condiBeans ...interface{}) error

// 複数レコードとレコードの件数をf取得する関数
func (engine *Engine) FindAndCount(rowsSlicePtr interface{}, condiBean ...interface{}) (int64, error)

// 第1引数に指定した条件に第2引数で指定したデータでレコードを抽出するための条件関数
func (engine *Engine) Where(query interface{}, args ...interface{}) *Session

// 論理削除したデータも扱えるようになる関数
func (engine *Engine) Unscoped() *Session

// 第1引数に指定した条件に第2引数で指定したデータでレコードを抽出するための条件関数(今回未使用)
func (session *Session) And(query interface{}, args ...interface{}) *Session

// 第1引数に指定した条件に第2引数で指定したデータでレコードを抽出するための条件関数(今回未使用)
func (session *Session) Or(query interface{}, args ...interface{}) *Session

// 第1引数に指定したカラム名に対して昇順にするための条件関数(今回未使用)
func (engine *Engine) Asc(colNames ...string) *Session

// 第1引数に指定したカラム名に対して降順にするための条件関数(今回未使用)
func (engine *Engine) Desc(colNames ...string) *Session

Get

ユーザーデータを1件取得します。

func (u *users) GetUser(c echo.Context, user model.User) (*model.User, error) {

  // ユーザーデータを1件取得
  exist, err := u.engine.Where("employee_number = ?", user.EmployeeNumber).Get(&user)

  if err != nil {
    return nil, errors.Wrap(err, ErrGetUser)
  }

  // ユーザーが存在しなければエラー
  if !exist {
    return nil, errors.New("This user does not exist")
  }
  return &user, nil
}
curl -X GET http://localhost:80/user?employee_number=J0000001
{"users":[{"id":"c9phu1ouj5q4aocfjs20","first_name":"Ichiro","last_name":"Suzuki","email":"taro_susuki@example.com","created":"2022-05-05T00:49:11Z","updated":"2022-05-05T00:49:11Z","deleted":"0001-01-01T00:00:00Z"},{"id":"c9phu1ouj5q4aocfjs2g","first_name":"Jiro","last_name":"Suzuki","email":"jiro_susuki@example.com","created":"2022-05-05T00:49:11Z","updated":"2022-05-05T00:49:11Z","deleted":"0001-01-01T00:00:00Z"}]}
{"status":500,"Error":{"message":"GetUser() in service: GetUser() in infra: This user does not exist","code":3}}
SELECT 
  *
FROM
  "t_users"
WHERE
  "employee_number" = $1
AND
  "deleted_at" IS NULL OR "deleted_at" = '0001-01-01 00:00:00' // deletedタグによる
LIMIT 1

Find

複数のユーザーデータを取得します。
従業員番号が、J0000001のユーザーデータを論理削除し、Unscopedメソッドを使用した場合としなかった場合で結果をそれぞれ見ていきます。

func (u *users) GetUsers(c echo.Context, condition model.GetUsersCondition) ([]model.User, error) {
  var users []model.User

  if condition.IsDelete {
    // 論理削除したユーザーデータも含めて取得
    if err := u.engine.In("employee_number", condition.EmployeeNumbers).Unscoped().Find(&users); err != nil {
      return nil, errors.Wrap(err, ErrGetUsers)
    }
  } else {
    // 論理削除したユーザーデータも含めず取得
    if err := u.engine.In("employee_number", condition.EmployeeNumbers).Find(&users); err != nil {
      return nil, errors.Wrap(err, ErrGetUsers)
    }
  }
  return users, nil
}
curl -X GET http://localhost:80/users\?employee_number=J0000001,J0000002\&is_delete\=true
{"users":[{"id":"ca51c7b2lt3ngcu1iop0","employee_number":"J0000001","first_name":"Ichiro","last_name":"Suzuki","email":"taro_susuki@example.com","department_id":1,"created":"2022-05-22T10:53:17Z","updated":"2022-05-22T10:53:17Z","deleted":"2022-05-22T20:37:42Z"},{"id":"ca51c7b2lt3ngcu1iopg","employee_number":"J0000002","first_name":"Jiro","last_name":"Suzuki","email":"jiro_susuki@example.com","department_id":2,"created":"2022-05-22T10:53:17Z","updated":"2022-05-22T10:53:17Z","deleted":"0001-01-01T00:00:00Z"}]}
curl -X GET http://localhost:80/users\?employee_number=J0000001,J0000002\&is_delete\=false
{"users":[{"id":"ca51c7b2lt3ngcu1iopg","employee_number":"J0000002","first_name":"Jiro","last_name":"Suzuki","email":"jiro_susuki@example.com","department_id":2,"created":"2022-05-22T10:53:17Z","updated":"2022-05-22T10:53:17Z","deleted":"0001-01-01T00:00:00Z"}]}
// Unscopedあり
SELECT 
  *
FROM
  "t_users"
WHERE
  "employee_number" = $1
IN
  ($1, $2)

// Unscopedなし
SELECT 
  *
FROM
  "t_users"
WHERE
  "employee_number"
IN
  ($1, $2)
AND
  "deleted_at" IS NULL OR "deleted_at" = '0001-01-01 00:00:00' // deletedタグによる

Unscopedメソッドを使用することで、論理削除されたレコード(deletedタグとマッピングされているカラムにレコードが存在)も取得することができます。

テーブルを結合したRead

ユーザーと部署テーブルを結合し、ユーザーに紐づく部署データを取得します。
前述しましたが、結合したテーブルのデータを取得するには、結合したテーブルのデータをマッピングする構造体を別途用意する必要があり、そのフィールドには、結合する各テーブルのデータの構造体を定義し、その構造体にextendsタグを付与することにより、結合したテーブルのデータをマッピングすることができます。

// DBエンジンの構造体
type Engine struct {
  ・・・
  driverName string
  dataSoutceName string
  ・・・
}

// 第1引数に結合の種類、第2引数に結合先のテーブル名、第3引数に結合条件を指定しテーブル結合する関数
func (engine *Engine) Join(joinOperator string, tablename interface{}, condition string, args ...interface{}) *Session
func (u *users) GetUsersJoinedDepartment(c echo.Context, condition model.GetUsersCondition) ([]model.JoinedDepartment, error) {
  var usersJoinedDepartments []model.JoinedDepartment
 
  if condition.IsDelete {
    // ユーザーが所属する部門データも併せて取得( 論理削除したユーザーデータも含める)
    if err := u.engine.
      Join("INNER", "m_department", "t_users.department_id = m_department.id").
      In("employee_number", condition.EmployeeNumbers).
      Unscoped().
      Find(&usersJoinedDepartments); err != nil {
      return nil, errors.Wrap(err, ErrGetUsers)
    }
  } else {
    // ユーザーが所属する部門データも併せて取得( 論理削除したユーザーデータも含めない)
    if err := u.engine.
	    Join("INNER", "m_department", "t_users.department_id = m_department.id").
      In("employee_number", condition.EmployeeNumbers).
      Find(&usersJoinedDepartments); err != nil {
      return nil, errors.Wrap(err, ErrGetUsers)
    }
  }
  return usersJoinedDepartments, nil
}
curl -X GET http://localhost:80/users\?employee_number=J0000001,J0000002\&is_delete\=true\&is_joined_department\=true
[{"employee_number":"J0000001","first_name":"Ichiro","last_name":"Suzuki","email":"taro_susuki@example.com","department_name":"人事部","created":"2022-05-22T10:53:17Z","updated":"2022-05-22T10:53:17Z","deleted":"2022-05-22T20:37:42Z"},{"employee_number":"J0000002","first_name":"Jiro","last_name":"Suzuki","email":"jiro_susuki@example.com","department_name":"経理部","created":"2022-05-22T10:53:17Z","updated":"2022-05-22T10:53:17Z","deleted":"0001-01-01T00:00:00Z"}]
curl -X GET http://localhost:80/users\?employee_number=J0000001,J0000002\&is_delete\=false\&is_joined_department\=true
[{"employee_number":"J0000002","first_name":"Jiro","last_name":"Suzuki","email":"jiro_susuki@example.com","department_name":"経理部","created":"2022-05-22T10:53:17Z","updated":"2022-05-22T10:53:17Z","deleted":"0001-01-01T00:00:00Z"}]
// Unscopedあり
SELECT 
  *
FROM
  "t_users" as t1
INNER JOIN
  "m_department" as t2
ON
 t1.department_id = t2.id
WHERE
  "employee_number" = $1
IN
  ($1, $2)

// Unscopedなし
SELECT 
  *
FROM
  "t_users" as t1
INNER JOIN
  "m_department" as t2
ON
 t1.department_id = t2.id
WHERE
  "employee_number"
IN
  ($1, $2)
AND
  "deleted_at" IS NULL OR "deleted_at" = '0001-01-01 00:00:00' // deletedタグによる
サブクエリを使用したRead

サブクエリを使用する場合、xormのbuilderパッケージを使用します。
サブクエリの結果は今回使用するようなWHERE句の条件やテーブルをJOINする条件として使用することができます。

// ビルダーの構造体
type Builder struct {
  ・・・
  subquery *Builder
  ・・・
}

// 第1引数で指定したカラムのでデータを取得する関数
func (b *Builder) Select(cols ...string) *Builder

// 第1, 2引数で操作するデータのソースを指定する関数
func (b *Builder) From(subject interface{}, alias ...string) *Builder

// 第1引数で操作するデータの条件を指定する関数
func (b *Builder) Where(cond Cond) *Builder

// 指定したカラムがNULLのデータを返すように条件を指定する型
type IsNull string
func (d *departments) GetDepartments(c echo.Context, cond model.Department) ([]model.Department, error) {
  var modelDepartment []model.Department

  // サブクエリを作成し、条件にするユーザーデータに使用されている部門データを抽出
  subQuery := builder.
    Select("department_id").
    From("t_users").
    Where(builder.IsNull{"deleted_at"})

  // サブクエリで抽出したデータを使用し、メインクエリを実行して、部門データを取得
  count, err := d.engine.In("id", subQuery).Asc("id").FindAndCount(&modelDepartment)
  if err != nil {
    return nil, errors.Wrap(err, "failed to get used department")
  }

  if count == 0 {
    return nil, errors.New("All department data are not used")
  }

  return modelDepartment, nil
}
curl -X GET http://localhost:80/departments
{"Departments":[{"id":"\u0001","department_name":"総務部","created":"2022-05-24T23:20:28Z","updated":"2022-05-24T23:20:28Z","deleted":"0001-01-01T00:00:00Z"},{"id":"\u0002","department_name":"人事部","created":"2022-05-24T23:20:28Z","updated":"2022-05-24T23:20:28Z","deleted":"0001-01-01T00:00:00Z"},{"id":"\u0003","department_name":"事業部","created":"2022-05-24T23:20:28Z","updated":"2022-05-24T23:20:28Z","deleted":"0001-01-01T00:00:00Z"},{"id":"\u0004","department_name":"営業部","created":"2022-05-24T23:20:28Z","updated":"2022-05-24T23:20:28Z","deleted":"0001-01-01T00:00:00Z"},{"id":"\u0005","department_name":"経理部","created":"2022-05-24T23:20:28Z","updated":"2022-05-24T23:20:28Z","deleted":"0001-01-01T00:00:00Z"},{"id":"\u0006","department_name":"開発部","created":"2022-05-24T23:20:28Z","updated":"2022-05-24T23:20:28Z","deleted":"0001-01-01T00:00:00Z"},{"id":"\u0007","department_name":"製造部","created":"2022-05-24T23:20:28Z","updated":"2022-05-24T23:20:28Z","deleted":"0001-01-01T00:00:00Z"}]}
curl -X GET http://localhost:80/departments?is_used=true
{"Departments":[{"id":"\u0001","department_name":"総務部","created":"2022-05-24T23:20:28Z","updated":"2022-05-24T23:20:28Z","deleted":"0001-01-01T00:00:00Z"},{"id":"\u0002","department_name":"人事部","created":"2022-05-24T23:20:28Z","updated":"2022-05-24T23:20:28Z","deleted":"0001-01-01T00:00:00Z"}]}
{"status":500,"Error":{"message":"GetDepartments in service: GetDepartments() in infra: All department data are not used","code":7}}
SELECT 
  *
FROM
  "m_department"
WHERE
  "id" IN  (
      SELECT 
        department_id
      FROM
        t_users
      WHERE
         "deleted_at" IS NULL OR "deleted_at" = '0001-01-01 00:00:00' // deletedタグによる
    ) 

CRUD – Update

次は、CRUDの中のデータの更新(Update)を行っていきます。
今回は行いませんが、前述した通り、AllColsメソッドを使用すれば、構造体のフィールドでゼロ値のデータがある場合、ゼロ値で更新され、Colsメソッドで指定したカラムと紐づくフィールドがゼロ値の場合もゼロ値で更新されてしまいます。

// DBセッションの構造体
type Session struct {
  ・・・
  engine *Engine
  ・・・
}

// 指定したテーブルのレコードを更新する関数
func (session *Session) Update(bean interface{}, condiBean ...interface{}) (int64, error)
func (u *users) UpdateUsers(c echo.Context, users model.UpdateUsers) ([]model.User, error) {
  session := u.engine.NewSession()
  defer session.Close()

  if err := session.Begin(); err != nil {
    return nil, errors.Wrap(err, ErrSessionBegin)
  }

  // データの存在/重複確認
  for _, empNo := range users.EmployeeNumbers {
    exist, err := session.Cols("employee_number").Exist(&model.UpdateUser{EmployeeNumber: empNo})

    if err != nil {
      return nil, errors.Wrap(err, ErrCheckExistenceEmployeeNumber)
    }

    if !exist {
      return nil, errors.New(ErrExistenceDepartmentID)
    }
  }

  for i, user := range users.UpdateUsers {

    var modelUsers []model.User

    if user.Email != "" {
      exist, err := session.Cols("email").Exist(&model.UpdateUser{
        Email: user.Email,
      })

      if err != nil {
        return nil, errors.Wrap(err, ErrCheckExistenceEmail)
      }

      if len(modelUsers) == 0 && users.EmployeeNumbers[i] != modelUsers[0].EmployeeNumber {
        return nil, errors.New(ErrExistenceEmail)
      }
    }

    if user.DepartmentID != 0 {
      exist, err := session.Table("m_department").Cols("id").Exist(&model.Department{
        ID: user.DepartmentID,
      })

      if err != nil {
        return nil, errors.Wrap(err, ErrCheckExistenceDepartmentID)
      }

      if !exist {
        return nil, errors.New(ErrExistenceDepartmentID)
      }
    }
  }

  // ユーザーデータの更新
  for i, user := range users.UpdateUsers {
    _, err := session.Where("employee_number = ?", users.EmployeeNumbers[i]).Update(&user)
    if err != nil {
      _ = session.Rollback()
      return nil, errors.Wrap(err, "failed to update users")
    }
  }

	var updatedUsers []model.User

  // 更新したユーザーデータの取得
	if err := session.In("employee_number", users.EmployeeNumbers).Find(&updatedUsers); err != nil {
		_ = session.Rollback()
		return nil, errors.Wrap(err, "failed to get updated users")
	}

	if err := session.Commit(); err != nil {
		_ = session.Rollback()
		return nil, errors.Wrap(err, ErrSessionCommit)
	}

	return updatedUsers, nil
}
curl -X PUT -H "Content-Type: application/json"  http://localhost:80/users/J0000001,J0000002 -d '{"users": [{"first_name": "Saburo", "last_name": "Tanaka", "email": "saburo_tanaka@example.com","department_id":1},{"first_name": "Shiro", "last_name": "Tanaka", "email": "shiro_tanaka@example.com","department_id":2}]}'
{"users":[{"id":"ca6mub5snfvtbvpspphg","employee_number":"J0000001","first_name":"Saburo","last_name":"Tanaka","email":"saburo_tanaka@example.com","department_id":1,"created":"2022-05-24T23:50:04Z","updated":"2022-05-27T00:28:59Z","deleted":"0001-01-01T00:00:00Z"},{"id":"ca6mub5snfvtbvpsppi0","employee_number":"J0000002","first_name":"Shiro","last_name":"Tanaka","email":"shiro_tanaka@example.com","department_id":2,"created":"2022-05-24T23:50:04Z","updated":"2022-05-27T00:28:59Z","deleted":"0001-01-01T00:00:00Z"}]}
{"status":400,"Error":{"message":"invalid condition parameter: Key: 'UpdateUsers.UpdateUsers' Error:Field validation for 'UpdateUsers' failed on the 'unique_email' tag","code":5}}
{"status":500,"Error":{"message":"CreateUsers() in service: CreateUsers() in infra: Some Email are already existing","code":2}}
UPDATE 
  "t_users"
SET
  "first_name" = $1,
  "last_name" = $2,
  "email" = $3,
  "department_id" = $4,
)
WHERE
  "employee_number" = id
AND
  "deleted_at" IS NULL OR "deleted_at" = '0001-01-01 00:00:00' // deletedタグによる

CRUD – Delete

最後に、CRUDの中のデータの削除(Delete)を行っていきます。

前述した通り、削除には論理削除と物理削除があります。
論理削除の場合、deleted_atなどのカラムを用意して、そのカラムとマッピングする構造体のフィールドのxormタグにdeletedタグを付与し、そのカラムをnull以外のデータに更新することで「削除されているレコード」とみなします。実際にレコードは削除しません。
ただし、Unscopedメソッドを使用した場合、deletedタグは無効化されるので、物理削除され、データが完全に削除されるため、復元は不可能になります。
また、対象のテーブルとマッピングする構造体のどのフィールドのxormタグにもdeletedタグを付与しないで、Deleteメソッドを実行すれば、レコードをDB上から完全に削除してしまうことができます。

// DBセッションの構造体
type Session struct {
  ・・・
  engine *Engine
  ・・・
}

// 指定したテーブルのデータを削除する関数
func (session *Session) Delete(beans ...interface{}) (int64, error)
func (u *users) DeleteUsers(c echo.Context, users model.DeleteUsers) error {
  session := u.engine.NewSession()
  defer session.Close()

  if err := session.Begin(); err != nil {
    _ = session.Rollback()
    return errors.Wrap(err, ErrSessionBegin)
  }

  for _, empNo := range users.EmployeeNumbers {

    // ユーザーデータの存在確認
    exist, err := session.Cols("employee_number").Exist(&model.User{EmployeeNumber: empNo})

    if err != nil {
      _ = session.Rollback()
      return errors.Wrap(err, ErrCheckExistenceEmployeeNumber)
    }

    if !exist {
      _ = session.Rollback()
      return errors.New("Some employee Numbers do not exist")
    }

    if users.IsPhysical {
      // 物理削除
      if _, err := session.Unscoped().Delete(&model.User{
          EmployeeNumber: empNo,
        }); err != nil {
        _ = session.Rollback()
        return errors.Wrap(err, "failed to delete users")
      }
    } else {
      // 論理削除
      if _, err := session.Delete(&model.User{
        EmployeeNumber: empNo,
      }); err != nil {
        _ = session.Rollback()
        return errors.Wrap(err, "failed to delete users")
      }
    }
  }

  if err := session.Commit(); err != nil {
    _ = session.Rollback()
    return errors.Wrap(err, ErrSessionCommit)
  }
  return nil
}
curl -X DELETE http://localhost:80/user/J0000001?is_physical=false
// なし
curl -X DELETE http://localhost:80/user/J0000002?is_physical=true
// なし
// Unscopedあり
DELETE
FROM
  "t_users"
WHERE
  "employee_number" = id

// Unscopedなし
UPDATE 
  "t_users"
SET
  "deleted_at" = $1
)
WHERE
  "employee_number" = id
AND
  "deleted_at" IS NULL OR "deleted_at" = '0001-01-01 00:00:00' // deletedタグによる

まとめ

この記事では、Go言語のXORMについてまとめました。

少しGo言語をいじってみたいと思ったら、ブラウザ上で実行できる公式チュートリアルでコードを実際に書いてみると雰囲気がつかめるかもしれません。
今後、さらにGo言語のことについて記事にしていきたいと思います。

もし「記事の内容が間違えている!」等ありましたら、Twitterまたはお問い合わせフォームからご連絡いただければと思います。

最後までご覧いただきありがとうございました。

参考サイト

xorm.io/docs/

xorm.io/xorm

xorm.io/builder