Skip to content

Latest commit

 

History

History
2469 lines (1905 loc) · 75.3 KB

File metadata and controls

2469 lines (1905 loc) · 75.3 KB

clails Model ガイド

概要

clails の Model は Ruby on Rails の ActiveRecord を参考にしたデータベースアクセスレイヤーです。 ただし、Rails とは異なり、すべてのカラム情報をハッシュテーブルで管理し、カラムへのアクセスには専用の関数を使用します。

基本概念

  • Model はデータベーステーブルと対応します
  • カラム情報は直接クラス定義に記述せず、データベースから自動的に取得されます
  • カラムへのアクセスには ref 関数、値の設定には (setf ref) を使用します
  • 変更されたカラムのみが更新されます(dirty flag による追跡)

1. Migration ファイルの書き方

Migration ファイルはデータベースのスキーマ変更を管理するファイルです。 db/migrate/ ディレクトリ配下に配置します。

ファイル命名規則

YYYYmmdd-HHMMSS-description.lisp

例: 20240101-120000-create-users-table.lisp

テーブル作成

(in-package #:your-app/db/migrate)

(defmigration "20240101-120000-create-users-table"
  (:up #'(lambda (conn)
           (create-table conn :table "users"
                              :columns '(("name" :type :string
                                                 :not-null T)
                                         ("email" :type :string
                                                  :not-null T)
                                         ("age" :type :integer
                                                :not-null NIL)
                                         ("is-active" :type :boolean
                                                      :default-value T))))
   :down #'(lambda (conn)
             (drop-table conn :table "users"))))

カラムの追加

(defmigration "20240102-130000-add-phone-to-users"
  (:up #'(lambda (conn)
           (add-column conn :table "users"
                            :columns '(("phone" :type :string
                                                :not-null NIL))))
   :down #'(lambda (conn)
             (drop-column conn :table "users"
                               :column "phone"))))

インデックスの追加

(defmigration "20240103-140000-add-index-to-users"
  (:up #'(lambda (conn)
           (add-index conn :table "users"
                           :index "idx-users-email"
                           :columns '("email"))))
   :down #'(lambda (conn)
             (drop-index conn :table "users"
                              :index "idx-users-email"))))

カラムタイプ一覧

  • :string - 文字列(VARCHAR)
  • :text - テキスト(TEXT)
  • :integer - 整数
  • :float - 浮動小数点数
  • :decimal - 固定小数点数
  • :datetime - 日時
  • :date - 日付
  • :time - 時刻
  • :boolean - 真偽値

データベースからの戻り値

カラムの型によって、データベースから取得される値の形式が異なります。

  • :datetime - Universal Time(整数)として返されます
  • :date - Universal Time(整数)として返されます。時刻部分は 00:00:00 です
  • :time - 00:00:00 からの経過秒数(整数)として返されます

カラムオプション

  • :not-null - NULL を許可しない場合は T、許可する場合は NIL
  • :default-value - デフォルト値を指定

Migration の実行

;; データベース作成
(clails/model/migration:db-create)

;; Migration 実行
(clails/model/migration:db-migrate)

;; Migration ステータス確認
(clails/model/migration:db-status)

;; 最後の Migration をロールバック
(clails/model/migration:db-rollback)

;; 最後の N 個の Migration をロールバック
(clails/model/migration:db-rollback :step 3)

;; データベースに初期データを投入
(clails/model/migration:db-seed)

db-rollback

最後の N 個の Migration をロールバックします。最近のデータベース変更を元に戻す必要がある場合に便利です。

;; 最後の Migration をロールバック
(clails/model/migration:db-rollback)

;; 最後の 3 つの Migration をロールバック
(clails/model/migration:db-rollback :step 3)

db-seed

db/seeds.lisp から初期データをデータベースに投入します。通常、テストデータや初期アプリケーションデータをデータベースに投入するために使用されます。

;; db/seeds.lisp を作成してシードデータを記述
;; 例: db/seeds.lisp の内容
;; (let ((user (make-record '<user>
;;                         :name "管理者"
;;                         :email "admin@example.com")))
;;   (save user))

;; シードコマンドを実行
(clails/model/migration:db-seed)

2. Model の定義

基本的な Model 定義

(in-package #:your-app/model)

(defmodel <user> (<base-model>)
  (:table "users"))

これだけで、users テーブルに対応する Model が定義されます。 カラム情報はデータベースから自動的に取得されます。

テーブル情報の初期化

アプリケーション起動時に一度だけ実行します。

(clails/model/base-model:initialize-table-information)

3. 親子関係のある Model の定義

関連の種類

  • :belongs-to - 親への参照(外部キーを持つ側)
  • :has-many - 子への参照(外部キーを持たない側)

サンプル: 会社と部署の関係

;; 親 Model
(defmodel <company> (<base-model>)
  (:table "company"
   :relations ((:has-many "your-app/model::<department>"
                 :as :departments
                 :foreign-key :company-id))))

;; 子 Model
(defmodel <department> (<base-model>)
  (:table "department"
   :relations ((:belongs-to "your-app/model::<company>"
                 :column :company
                 :key :company-id)
               (:has-many "your-app/model::<employee>"
                 :as :employees
                 :foreign-key :department-id))))

;; 孫 Model
(defmodel <employee> (<base-model>)
  (:table "employee"
   :relations ((:belongs-to "your-app/model::<department>"
                 :column :department
                 :key :department-id))))

関連のパラメータ

:has-many の場合

  • :as - 関連にアクセスするためのエイリアス(キーワード)
  • :foreign-key - 子テーブルが持つ外部キー(キーワード)

:belongs-to の場合

  • :column - 親にアクセスするためのエイリアス(キーワード)
  • :key - 自テーブルが持つ外部キー(キーワード)

4. データの作成 (make-record)

新しいレコードのインスタンスを作成します。

;; 新規インスタンス作成
(defvar *user* (make-record '<user>
                            :name "Taro Yamada"
                            :email "taro@example.com"
                            :age 30
                            :is-active T))

;; カラムの値を取得
(ref *user* :name)      ; => "Taro Yamada"
(ref *user* :email)     ; => "taro@example.com"
(ref *user* :age)       ; => 30
(ref *user* :is-active) ; => T

;; カラムの値を設定
(setf (ref *user* :age) 31)

5. クエリの書き方

基本的なクエリ

;; 全件取得
(defvar *users* (execute-query
                  (query <user>
                         :as :user)
                  '()))

;; WHERE 句を使用
(defvar *active-users* (execute-query
                         (query <user>
                                :as :user
                                :where (:= (:user :is-active) T))
                         '()))

;; 並び替え
(defvar *sorted-users* (execute-query
                         (query <user>
                                :as :user
                                :order-by ((:user :created-at :desc)))
                         '()))

;; LIMIT と OFFSET
(defvar *page-users* (execute-query
                       (query <user>
                              :as :user
                              :limit 10
                              :offset 20)
                       '()))

WHERE 句の演算子

  • := - 等しい
  • :> - より大きい
  • :< - より小さい
  • :>= - 以上
  • :<= - 以下
  • :!= - 等しくない
  • :null - NULL である
  • :not-null - NULL でない
  • :in - IN 句
  • :not-in - NOT IN 句
  • :between - BETWEEN 句
  • :not-between - NOT BETWEEN 句
  • :and - AND 条件
  • :or - OR 条件
  • :like - LIKE 句
  • :not-like - NOT LIKE 句

WHERE 句のサンプル

;; 比較演算子
(query <user>
       :as :user
       :where (:> (:user :age) 20))

;; NULL チェック
(query <user>
       :as :user
       :where (:null (:user :phone)))

;; IN 句
(query <user>
       :as :user
       :where (:in (:user :id) (1 2 3)))

;; IN 句(パラメータ使用)
(execute-query
  (query <user>
         :as :user
         :where (:in (:user :id) :ids))
  '(:ids (1 2 3)))

;; NOT IN 句
(query <user>
       :as :user
       :where (:not-in (:user :status) ("inactive" "deleted")))

;; NOT IN 句(パラメータ使用)
(execute-query
  (query <user>
         :as :user
         :where (:not-in (:user :id) :excluded-ids))
  '(:excluded-ids (10 20 30)))

;; BETWEEN 句
(query <user>
       :as :user
       :where (:between (:user :age) 20 30))

;; BETWEEN 句(パラメータ使用)
(execute-query
  (query <user>
         :as :user
         :where (:between (:user :created-at) :start-date :end-date))
  '(:start-date "2024-01-01" :end-date "2024-12-31"))

;; NOT BETWEEN 句
(query <user>
       :as :user
       :where (:not-between (:user :age) 0 17))

;; AND 条件
(query <user>
       :as :user
       :where (:and (:= (:user :is-active) T)
                    (:>= (:user :age) 18)))

;; OR 条件
(query <user>
       :as :user
       :where (:or (:= (:user :age) 20)
                   (:= (:user :age) 30)))

;; LIKE 句
(query <user>
       :as :user
       :where (:like (:user :email) "%@example.com"))

;; NOT LIKE 句
(query <user>
       :as :user
       :where (:not-like (:user :email) "%@test.com"))

パラメータの自動型変換

execute-query では、WHERE句で使用されるパラメータに対して、データベース固有の型に自動的に変換する機能を提供しています。これにより、アプリケーション側では型を意識せずにCommon Lispの値をそのまま使用できます。

対応する演算子

以下の演算子を使用した場合、パラメータ値が自動的に変換されます:

  • := (equal)
  • :> (greater than)
  • :< (less than)
  • :>= (greater than or equal)
  • :<= (less than or equal)
  • :!= (not equal)
  • :in (in)
  • :not-in (not in)
  • :between (between)
  • :not-between (not between)

注意: LIMIT/OFFSET のパラメータには型変換は適用されません。

対応する型

以下のカラム型に対して自動変換が行われます:

  • :string - 文字列型
  • :text - テキスト型
  • :integer - 整数型
  • :float - 浮動小数点型
  • :decimal - 固定小数点型
  • :datetime - 日時型
  • :date - 日付型
  • :time - 時刻型
  • :boolean - 真偽値型

Boolean型の自動変換

Boolean型のカラムに対しては、Common Lispの t/nil を使用でき、データベースごとに適切な値に自動変換されます。

;; Boolean型の自動変換
(let* ((query (query <user> :as :user :where (:= (:user :is-active) :active)))
       (result (execute-query query '(:active t))))
  ;; t が自動的にデータベースの boolean 型に変換される
  ;; MySQL: 1, PostgreSQL: true, SQLite3: 1
  result)

;; 複数条件での自動変換
(let* ((query (query <task> 
                     :as :task 
                     :where (:and (:= (:task :done) :done)
                                  (:> (:task :priority) :min-priority))))
       (result (execute-query query '(:done nil :min-priority 10))))
  ;; done と priority がそれぞれの型に応じて自動変換される
  result)

Datetime型の自動変換

Datetime型のカラムに対しては、universal-time(整数)を渡すことができ、データベースの日時形式に自動変換されます。

;; Datetime型の自動変換
(let* ((completed-time (encode-universal-time 0 0 10 24 1 2026))
       (query (query <task>
                     :as :task
                     :where (:>= (:task :completed-at) :completed-time)))
       (result (execute-query query `(:completed-time ,completed-time))))
  ;; universal-time が "2026-01-24 10:00:00" のような形式に変換される
  result)

IN句での自動変換

IN句やBETWEEN句でも自動型変換が適用されます。

;; IN句での自動変換
(let* ((query (query <product>
                     :as :product
                     :where (:in (:product :category-id) :category-ids)))
       (result (execute-query query '(:category-ids (1 2 3)))))
  ;; リスト内の各値が適切な型に変換される
  result)

;; BETWEEN句での自動変換
(let* ((start-time (encode-universal-time 0 0 0 1 1 2026))
       (end-time (encode-universal-time 0 0 0 31 12 2026))
       (query (query <event>
                     :as :event
                     :where (:between (:event :event-date) :start-date :end-date)))
       (result (execute-query query `(:start-date ,start-time :end-date ,end-time))))
  result)

型変換の無効化

型変換を無効化したい場合は、:convert-types nil を指定できます。

;; 型変換を無効化(従来の動作)
(let* ((query (query <user> :as :user :where (:= (:user :is-active) :active)))
       (result (execute-query query '(:active 1) :convert-types nil)))
  ;; 値がそのまま渡される
  result)

注意事項

  1. 型情報の取得: カラムの型情報が取得できない場合は、変換をスキップし、値はそのまま渡されます
  2. 既存コードとの互換性: デフォルトで型変換が有効ですが、型情報がない場合は従来通り動作するため、既存コードへの影響はありません
  3. データベース固有の変換: 各データベースの型変換は cl-db-fn を通じて実装されており、一貫性が保たれています

ORDER BY 句

;; 昇順
(query <user>
       :as :user
       :order-by ((:user :name)))

;; 降順
(query <user>
       :as :user
       :order-by ((:user :created-at :desc)))

;; 複数カラム
(query <user>
       :as :user
       :order-by ((:user :age :desc)
                  (:user :name)))

動的なクエリ構築(query-builder)

query マクロは静的なクエリ定義に適していますが、実行時にクエリを動的に構築したい場合は query-builder 関数を使用します。

基本的な使い方

;; query-builder でクエリインスタンスを作成
(defvar *q* (query-builder '<user> :as :user))

;; set-* 関数でクエリを構築
(set-columns *q* '((user :id :name :email)))
(set-where *q* '(:= (:user :status) :status))
(set-order-by *q* '((:user :created-at :desc)))
(set-limit *q* 10)

;; execute-query で実行
(execute-query *q* '(:status "active"))

setter 関数

すべての setter 関数は、設定されている内容を置き換えます。また、メソッドチェーンのためにクエリインスタンス自身を返します。

  • set-columns - SELECT するカラムを設定
  • set-joins - JOIN 句を設定
  • set-where - WHERE 句を設定(nil で削除)
  • set-order-by - ORDER BY 句を設定
  • set-limit - LIMIT 句を設定
  • set-offset - OFFSET 句を設定
;; メソッドチェーンの例
(execute-query
  (set-limit
    (set-offset
      (set-columns (query-builder '<user> :as :user)
                   '((user :id :name)))
      10)
    20)
  '())

動的なカラム選択

(defun search-users (search-column keyword)
  "指定されたカラムでユーザーを検索"
  (let ((q (query-builder '<user> :as :user)))
    ;; 実行時に決まるカラムを指定
    (set-columns q `((user :id ,search-column)))
    (set-where q `(:like (:user ,search-column) :keyword))
    (execute-query q (list :keyword (format nil "%~A%" keyword)))))

;; 使用例
(search-users :name "John")    ; name カラムで検索
(search-users :email "example") ; email カラムで検索

動的な WHERE 句の構築

(defun find-users (params)
  "パラメータに応じて動的に検索条件を追加"
  (let ((q (query-builder '<user> :as :user))
        (conditions nil))
    (set-columns q '((user :id :name :email :status)))
    
    ;; 条件を動的に追加
    (when (getf params :status)
      (push '(:= (:user :status) :status) conditions))
    
    (when (getf params :min-age)
      (push '(:>= (:user :age) :min-age) conditions))
    
    (when (getf params :keyword)
      (push '(:or
              (:like (:user :name) :keyword)
              (:like (:user :email) :keyword))
            conditions))
    
    ;; 条件を AND で結合
    (when conditions
      (set-where q `(:and ,@(nreverse conditions))))
    
    (execute-query q params)))

;; 使用例
(find-users '(:status "active" :min-age 20))
(find-users '(:keyword "%test%"))
(find-users '(:status "active" :keyword "%admin%"))

動的なソート順

(defun list-users (sort-by sort-order)
  "ソート順を動的に変更"
  (let ((q (query-builder '<user> :as :user)))
    (set-columns q '((user :id :name :created-at)))
    ;; 実行時にソート順を決定
    (set-order-by q `((:user ,sort-by ,sort-order)))
    (execute-query q '())))

;; 使用例
(list-users :name :asc)        ; 名前の昇順
(list-users :created-at :desc) ; 作成日時の降順

生成される SQL の確認

;; ログで確認(LOG_LEVEL=sql:debug を設定)
(execute-query q params)

;; generate-query で直接確認
(let ((q (query-builder '<user> :as :user)))
  (set-columns q '((user :id :name)))
  (set-where q '(:= (:user :status) :status))
  (multiple-value-bind (sql params)
      (generate-query q '(:status "active"))
    (format t "SQL: ~A~%" sql)
    (format t "Params: ~S~%" params)))
;; => SQL: SELECT USER.ID as "USER.ID", USER.NAME as "USER.NAME" FROM users as USER WHERE USER.STATUS = ?
;; => Params: ("active")

query マクロと query-builder の使い分け

  • query マクロ: 静的で型安全なクエリが必要な場合(推奨)

    • クエリの構造がコンパイル時に確定している
    • IDE のサポート(補完、エラー検出)を受けたい
    • 最もパフォーマンスが良い
  • query-builder: 動的なクエリ構築が必要な場合

    • 検索条件をユーザー入力に応じて変更
    • カラムやソート順を実行時に決定
    • 複雑な条件分岐でクエリを組み立てる

6. JOIN を含むクエリの書き方

INNER JOIN

;; 部署と会社を JOIN
(defvar *departments*
  (execute-query
    (query <department>
           :as :dept
           :columns ((dept :id :name)
                     (company :name))
           :joins ((:inner-join :company)))
    '()))

;; 結果の取得
(dolist (dept *departments*)
  (format t "Department: ~A, Company: ~A~%"
          (ref dept :name)
          (ref (ref dept :company) :name)))

;; ref-in を使った簡潔な書き方
(dolist (dept *departments*)
  (format t "Department: ~A, Company: ~A~%"
          (ref dept :name)
          (ref-in dept :company :name)))

LEFT JOIN

(defvar *departments*
  (execute-query
    (query <department>
           :as :dept
           :joins ((:left-join :company)))
    '()))

多段 JOIN

;; 従業員 → 部署 → 会社
(defvar *employees*
  (execute-query
    (query <employee>
           :as :emp
           :columns ((emp :id :name :employee-number)
                     (dept :name)
                     (company :name))
           :joins ((:inner-join :department)
                   (:inner-join :company :through :department)))
    '()))

;; 結果の取得
(dolist (emp *employees*)
  (format t "Employee: ~A, Department: ~A, Company: ~A~%"
          (ref emp :name)
          (ref-in emp :department :name)
          (ref-in emp :company :name)))

JOIN と WHERE 句の組み合わせ

(defvar *filtered-employees*
  (execute-query
    (query <employee>
           :as :emp
           :joins ((:inner-join :department))
           :where (:= (:dept :name) "Sales"))
    '()))

ref と ref-in 関数

JOIN したデータにアクセスするには ref 関数を使います。

;; 基本的な使い方
(ref dept :name)              ; 部署名
(ref (ref dept :company) :name) ; 会社名(JOIN したデータ)

;; JOIN したデータの取得例
(dolist (dept *departments*)
  (format t "Department: ~A, Company: ~A~%"
          (ref dept :name)
          (ref (ref dept :company) :name)))

ref-in は複数回の ref 呼び出しを簡略化するマクロです。リレーションやリストのインデックスを組み合わせてネストしたデータに簡潔にアクセスできます。

;; ref-in の使い方
(ref-in employee :department :name)        ; (ref (ref employee :department) :name) と同じ
(ref-in employee :company :name)           ; (ref (ref employee :company) :name) と同じ

;; リストとリレーションの組み合わせ
(ref-in blog :comments 0 :comment-account :username)
;; => (ref (ref (nth 0 (ref blog :comments)) :comment-account) :username) に展開される

;; JOIN と ref-in の組み合わせ例
(dolist (emp *employees*)
  (format t "Employee: ~A, Department: ~A, Company: ~A~%"
          (ref emp :name)
          (ref-in emp :department :name)
          (ref-in emp :company :name)))

7. データの保存

INSERT と UPDATE

save 関数は自動的に INSERT または UPDATE を判断します。

;; INSERT(ID が nil の場合)
(defvar *new-user* (make-record '<user>
                                :name "Hanako Suzuki"
                                :email "hanako@example.com"
                                :age 25))
(save *new-user*)
;; 保存後、ID が自動的に設定されます
(ref *new-user* :id) ; => 1

;; UPDATE(ID が存在し、dirty flag が立っている場合)
(setf (ref *new-user* :age) 26)
(save *new-user*)

;; dirty flag がない場合は何もしない
(save *new-user*) ; => *new-user* (変更がないため UPDATE は実行されない)

デフォルト値の扱い

明示的に値を設定しなかったカラムには、データベースで定義されたデフォルト値が適用されます。

;; Migration でデフォルト値を定義
;; ("status" :type :string :default-value "active")

;; status を設定せずに保存
(defvar *product* (make-record '<product> :name "Sample"))
(save *product*)

;; データベースのデフォルト値が適用される
(ref *product* :status) ; => "active"

Validation

validate メソッドをオーバーライドしてバリデーションを実装します。

(defmethod validate ((inst <user>))
  (when (or (null (ref inst :name))
            (string= (ref inst :name) ""))
    (setf (ref-error inst :name)
          "Name is required"))
  
  (when (or (null (ref inst :email))
            (string= (ref inst :email) ""))
    (setf (ref-error inst :email)
          "Email is required")))

;; バリデーションエラーがある場合、save は NIL を返す
(defvar *invalid-user* (make-record '<user> :name "" :email ""))
(save *invalid-user*) ; => NIL

;; エラーの確認
(has-error-p *invalid-user*) ; => T
(ref-error *invalid-user* :name) ; => "Name is required"
(ref-error *invalid-user* :email) ; => "Email is required"

楽観的ロック

楽観的ロックを使用するには、Migration でバージョン管理用のカラムを作成し、defmodel:version オプションを指定します。

;; Migration: バージョン管理用のカラムを作成
(defmigration "20251001-000000-create-posts-table"
  (:up #'(lambda (conn)
           (create-table conn :table "posts"
                              :columns '(("title" :type :string :not-null T)
                                         ("content" :type :text)
                                         ("lock-version" :type :integer
                                                         :not-null T
                                                         :default-value 0))))
   :down #'(lambda (conn)
             (drop-table conn :table "posts"))))

;; Model: :version オプションでバージョン管理カラムを指定
(defmodel <post> (<base-model>)
  (:table "posts"
   :version :lock-version))

楽観的ロックが有効な場合、更新時にバージョンが一致しないと optimistic-lock-error が発生します。

;; 同じレコードを2つのインスタンスで取得
(defvar *post1* (first (execute-query
                         (query <post>
                                :as :post
                                :where (:= (:post :id) 1))
                         '())))
(defvar *post2* (first (execute-query
                         (query <post>
                                :as :post
                                :where (:= (:post :id) 1))
                         '())))

;; post1 を更新(成功)
(setf (ref *post1* :title) "Updated by user 1")
(save *post1*)
(ref *post1* :lock-version) ; => 1

;; post2 を更新(失敗: バージョンが古い)
(setf (ref *post2* :title) "Updated by user 2")
(handler-case
    (save *post2*)
  (clails/condition:optimistic-lock-error ()
    (format t "Record was modified by another process~%")))

注意: lock-version という名前のカラムがあっても、defmodel:version オプションを指定しなければ楽観的ロックは有効になりません。


8. 悲観的ロック

悲観的ロックを使用することで、データベースレベルでレコードやデータベースをロックし、他のトランザクションによる同時更新を防ぐことができます。

clails では with-locked-transaction マクロを使用して悲観的ロックを実現します。

基本的な使い方

;; レコードをロックして更新
(clails/model/lock:with-locked-transaction (user
                                           (query <user>
                                                  :as :user
                                                  :where (:= (:user :id) :user-id))
                                           (list :user-id 1))
  ;; このトランザクション内で user を安全に更新できる
  (setf (ref user :balance) (+ (ref user :balance) 100))
  (save user))

クエリの指定方法

with-locked-transaction には2つの形式でクエリを指定できます。

1. clails の query DSL を使用

(with-locked-transaction (users
                         (query <user>
                                :as :user
                                :where (:= (:user :status) :status))
                         (list :status "active"))
  ;; users を処理
  (loop for user in users
        do (process-user user)))

2. cl-batis の SQL 定義を使用

(use-package :batis.macro)

;; cl-batis で SQL を定義
(defparameter *get-active-users*
  (select "SELECT * FROM users WHERE status = #{:status}"))

;; with-locked-transaction で使用
(with-locked-transaction (users
                         *get-active-users*
                         (list :status "active"))
  (loop for user in users
        do (process-user user)))

ロックモード

データベースごとにサポートされるロックモードが異なります。

PostgreSQL のロックモード

  • :for-update - 行を更新または削除のためにロック(デフォルト)
  • :for-share - 行を読み取りのためにロック
  • :for-no-key-update - キー以外の更新のためにロック
  • :for-key-share - キーの読み取りのためにロック
;; FOR UPDATE でロック
(with-locked-transaction (user
                         (query <user> :as :user :where (:= (:user :id) :id))
                         (list :id 1)
                         :mode :for-update)
  (setf (ref user :status) "processing")
  (save user))

;; FOR SHARE でロック
(with-locked-transaction (user
                         (query <user> :as :user :where (:= (:user :id) :id))
                         (list :id 1)
                         :mode :for-share)
  ;; 読み取り専用の処理
  (ref user :name))

MySQL のロックモード

  • :for-update - 行を更新または削除のためにロック(デフォルト)
  • :for-share - 行を読み取りのためにロック
(with-locked-transaction (user
                         (query <user> :as :user :where (:= (:user :id) :id))
                         (list :id 1)
                         :mode :for-update)
  (setf (ref user :balance) (+ (ref user :balance) 100))
  (save user))

SQLite3 のロックモード

SQLite3 は行レベルロックをサポートしていないため、データベースレベルでロックします。

  • :immediate - 読み取りを許可し、書き込みをロック(デフォルト、推奨)
  • :exclusive - すべてのアクセスをロック(注意して使用)
  • :deferred - 最初のクエリまでロックを遅延
;; IMMEDIATE モード(推奨)
(with-locked-transaction (user
                         (query <user> :as :user :where (:= (:user :id) :id))
                         (list :id 1)
                         :mode :immediate)
  (setf (ref user :balance) (+ (ref user :balance) 100))
  (save user))

注意: SQLite3 で :exclusive モードを使用すると、ロックを使用していない他の接続も含めてすべての接続がエラーになります。ほとんどの場合、:immediate モードの使用を推奨します。

NOWAIT オプション

ロックが取得できない場合、待機せずにすぐにエラーを返します(PostgreSQL と MySQL でサポート)。

(handler-case
    (with-locked-transaction (user
                             (query <user> :as :user :where (:= (:user :id) :id))
                             (list :id 1)
                             :nowait T)
      (setf (ref user :balance) (+ (ref user :balance) 100))
      (save user))
  (error (e)
    (format t "Could not acquire lock: ~A~%" e)))

SKIP LOCKED オプション

ロックされている行をスキップして、ロックされていない行のみを取得します(PostgreSQL と MySQL でサポート)。

;; ロックされていないユーザーを取得して処理
(with-locked-transaction (users
                         (query <user>
                                :as :user
                                :where (:= (:user :status) :status))
                         (list :status "pending")
                         :skip-locked T)
  (loop for user in users
        do (progn
             (setf (ref user :status) "processing")
             (save user))))

使用上の注意

  1. トランザクション: with-locked-transaction は自動的にトランザクションを開始し、正常終了時にコミット、エラー時にロールバックします
  2. デッドロック: 複数のレコードをロックする場合は、常に同じ順序でロックしてデッドロックを避けてください
  3. ロックの保持時間: トランザクション内の処理は可能な限り短時間で完了させてください
  4. SQLite3 の制約: SQLite3 は行レベルロックをサポートしていないため、データベース全体がロックされます
  5. リトライロジック: SQLite3 ではロックエラー時に自動的にリトライします(指数バックオフ付き)

9. Native Query(ネイティブクエリ)

clails では、cl-batis を使用してネイティブな SQL クエリを実行することができます。 複雑な集計処理や、クエリビルダーでは表現しにくいクエリを直接 SQL で記述できます。

cl-batis について

cl-batis は MyBatis にインスパイアされた SQL マッパーライブラリです。 SQL を Common Lisp のコード内に直接記述し、動的にパラメータをバインドできます。

詳細は cl-batis のドキュメント を参照してください。

SELECT クエリの定義と実行

基本的な SELECT

cl-batis では2つの形式でクエリを定義できます。

アノテーション形式(@select):

(use-package :cl-batis)
(cl-syntax:use-syntax :annot)

;; SELECT クエリを定義
@select
("SELECT * FROM users WHERE id = :id")
(defsql get-user-by-id (id))

;; クエリを実行
(defvar *user* (first (execute-query get-user-by-id
                                     (list :id 1))))

関数形式(select):

(use-package :cl-batis)

;; SELECT クエリを定義
(select
 ("SELECT * FROM users WHERE id = :id")
 (defsql get-user-by-id (id)))

;; クエリを実行
(defvar *user* (first (execute-query get-user-by-id
                                     (list :id 1))))

複数の条件を持つクエリ

アノテーション形式:

@select
("SELECT * FROM users"
 (sql-where
   (sql-cond (not (null name))
             " AND name LIKE :name ")
   (sql-cond (not (null email))
             " AND email = :email "))
 "ORDER BY created_at DESC")
(defsql search-users (name email))

関数形式:

(select
 ("SELECT * FROM users"
  (sql-where
    (sql-cond (not (null name))
              " AND name LIKE :name ")
    (sql-cond (not (null email))
              " AND email = :email "))
  "ORDER BY created_at DESC")
 (defsql search-users (name email)))

使用例:

;; 名前で検索
(execute-query search-users
               (list :name "%yamada%" :email nil))

;; 名前とメールで検索
(execute-query search-users
               (list :name "%yamada%" :email "yamada@example.com"))

;; 条件なし
(execute-query search-users
               (list :name nil :email nil))

JOIN を含むクエリ

;; アノテーション形式
@select
("SELECT u.*, d.name as department_name
  FROM users u
  INNER JOIN departments d ON u.department_id = d.id
  WHERE u.is_active = :is_active")
(defsql get-users-with-departments (is_active))

;; 関数形式
(select
 ("SELECT u.*, d.name as department_name
   FROM users u
   INNER JOIN departments d ON u.department_id = d.id
   WHERE u.is_active = :is_active")
 (defsql get-users-with-departments (is_active)))

(execute-query get-users-with-departments
               (list :is_active T))

集計クエリ

@select
("SELECT department_id, COUNT(*) as user_count
  FROM users
  WHERE created_at >= :start_date
    AND created_at < :end_date
  GROUP BY department_id
  HAVING COUNT(*) > :min_count")
(defsql count-users-by-department (start_date end_date min_count))

(execute-query count-users-by-department
               (list :start_date "2024-01-01"
                     :end_date "2024-12-31"
                     :min_count 5))

UPDATE クエリの定義と実行

UPDATE クエリには @update または update を使用します。

基本的な UPDATE

アノテーション形式:

@update
("UPDATE users
  SET status = :status,
      updated_at = :updated_at
  WHERE id = :id")
(defsql update-user-status (status updated_at id))

関数形式:

(update
 ("UPDATE users
   SET status = :status,
       updated_at = :updated_at
   WHERE id = :id")
 (defsql update-user-status (status updated_at id)))

使用例:

;; クエリを実行(更新された行数が返される)
(defvar *affected-rows*
  (execute-query update-user-status
                 (list :status "active"
                       :updated_at (get-universal-time)
                       :id 1)))

条件付き UPDATE

sql-set を使用して、条件に応じて SET 句を動的に構築します。

@update
("UPDATE users"
 (sql-set
   (sql-cond (not (null name))
             " name = :name, ")
   (sql-cond (not (null email))
             " email = :email, ")
   " updated_at = :updated_at ")
 (sql-where
   " id = :id "))
(defsql update-user-fields (name email updated_at id))

;; 名前のみ更新
(execute-query update-user-fields
               (list :id 1
                     :name "New Name"
                     :email nil
                     :updated_at (get-universal-time)))

;; 名前とメールを更新
(execute-query update-user-fields
               (list :id 1
                     :name "New Name"
                     :email "newemail@example.com"
                     :updated_at (get-universal-time)))

バッチ UPDATE

@update
("UPDATE users
  SET is_active = FALSE,
      updated_at = :updated_at
  WHERE last_login_at < :threshold_date
    AND is_active = TRUE")
(defsql deactivate-old-users (updated_at threshold_date))

(execute-query deactivate-old-users
               (list :threshold_date "2023-01-01"
                     :updated_at (get-universal-time)))

INSERT/DELETE クエリ

INSERT や DELETE クエリも同様に @update または update で定義できます(SQL の種類に関わらず、SELECT 以外は @update/update を使用します)。

;; INSERT(アノテーション形式)
@update
("INSERT INTO logs (user_id, action, created_at)
  VALUES (:user_id, :action, :created_at)")
(defsql insert-log (user_id action created_at))

;; INSERT(関数形式)
(update
 ("INSERT INTO logs (user_id, action, created_at)
   VALUES (:user_id, :action, :created_at)")
 (defsql insert-log (user_id action created_at)))

(execute-query insert-log
               (list :user_id 1
                     :action "login"
                     :created_at (get-universal-time)))

;; DELETE
@update
("DELETE FROM logs
  WHERE created_at < :threshold_date")
(defsql delete-old-logs (threshold_date))

(execute-query delete-old-logs
               (list :threshold_date "2023-01-01"))

cl-batis の動的 SQL

cl-batis は sql-condsql-wheresql-set を使用した動的な SQL 生成機能を提供しています。

sql-where と sql-cond の使用

条件に応じて WHERE 句の一部を含めるかどうかを制御します。

@select
("SELECT * FROM users"
 (sql-where
   (sql-cond (not (null name))
             " AND name = :name ")
   (sql-cond (not (null min_age))
             " AND age >= :min_age ")))
(defsql find-users (name min_age))

;; name のみ指定
(execute-query find-users (list :name "Alice" :min_age nil))
;; => SELECT * FROM users WHERE AND name = ?

;; 両方指定
(execute-query find-users (list :name "Alice" :min_age 20))
;; => SELECT * FROM users WHERE AND name = ? AND age >= ?

;; 条件なし
(execute-query find-users (list :name nil :min_age nil))
;; => SELECT * FROM users

sql-set の使用

UPDATE 文で条件に応じて SET 句を構築します。

@update
("UPDATE users"
 (sql-set
   (sql-cond (not (null name))
             " name = :name, ")
   (sql-cond (not (null email))
             " email = :email, ")
   " updated_at = :updated_at ")
 (sql-where
   " id = :id "))
(defsql update-user (name email updated_at id))

トランザクション内での使用

Native Query も通常のクエリと同様にトランザクション内で使用できます。

(with-transaction
  ;; 複数のクエリをアトミックに実行
  (execute-query update-user-status
                 (list :status "active" :id 1))
  (execute-query insert-log
                 (list :user_id 1
                       :action "status_changed"
                       :created_at (get-universal-time))))

使用上の注意

  1. SQL インジェクション対策: パラメータは必ず :param_name の形式で記述し、直接文字列連結しないでください
  2. 戻り値の型:
    • SELECT クエリ: plist のリストを返します
    • UPDATE/INSERT/DELETE クエリ: 影響を受けた行数を返します
  3. パラメータ名: キーワードシンボルを使用してください(:id:name など)
  4. データベース依存: ネイティブクエリを使用する場合、データベース固有の SQL 構文に注意してください
  5. @select/@update マクロ: @select@update はアノテーション形式のマクロで、(cl-syntax:use-syntax :annot) が必要です

10. トランザクション管理

clails では、データベーストランザクションを簡単に扱うための with-transaction マクロを提供しています。

基本的な使い方

(clails/model/transaction:with-transaction
  ;; トランザクション内で実行される処理
  (let ((user (make-record '<user> :name "Taro" :email "taro@example.com")))
    (save user))
  (let ((profile (make-record '<profile> :user-id (ref user :id) :bio "Hello")))
    (save profile)))

トランザクションの動作

  • 自動コミット: ブロック内の処理がすべて正常に完了すると、自動的にコミットされます
  • 自動ロールバック: ブロック内でエラーが発生すると、自動的にロールバックされます
  • コネクション管理: コネクションの取得と解放は自動的に行われます
;; 正常終了の場合: 自動コミット
(with-transaction
  (let ((user (make-record '<user> :name "Taro")))
    (save user)))
;; => ここでコミットされる

;; エラー発生の場合: 自動ロールバック
(handler-case
    (with-transaction
      (let ((user (make-record '<user> :name "Taro")))
        (save user))
      (error "Something went wrong"))
  (error (e)
    ;; トランザクションは自動的にロールバックされる
    (format t "Transaction rolled back: ~A~%" e)))

ネストしたトランザクション(セーブポイント)

with-transaction はネストして使用でき、ネストされたトランザクションは自動的にセーブポイントとして実装されます。

(with-transaction
  ;; 外側のトランザクション
  (let ((company (make-record '<company> :name "ACME Corp")))
    (save company))
  
  ;; 内側のトランザクション(セーブポイント)
  (handler-case
      (with-transaction
        (let ((department (make-record '<department> 
                                      :name "Sales"
                                      :company-id (ref company :id))))
          (save department))
        (error "Department creation failed"))
    (error (e)
      ;; 内側のトランザクションのみロールバックされる
      ;; company は保存される
      (format t "Department transaction rolled back~%"))))

ネストしたトランザクションの挙動

両方コミット

(with-transaction
  ;; 外側のデータを保存
  (save outer-record)
  
  (with-transaction
    ;; 内側のデータを保存
    (save inner-record)))
;; => 両方のデータが保存される

内側のみロールバック

(with-transaction
  (save outer-record)
  
  (handler-case
      (with-transaction
        (save inner-record)
        (error "Inner error"))
    (error (e) nil)))
;; => outer-record のみ保存される
;; => inner-record はロールバックされる

外側ごとロールバック

(handler-case
    (with-transaction
      (save outer-record)
      
      (with-transaction
        (save inner-record))
      
      (error "Outer error"))
  (error (e) nil))
;; => すべてロールバックされる

複数のモデルを扱う場合

(defun create-user-with-profile (name email bio)
  "ユーザーとプロフィールをトランザクション内で作成"
  (with-transaction
    (let* ((user (make-record '<user> :name name :email email))
           (saved-user (save user)))
      (when saved-user
        (let ((profile (make-record '<profile> 
                                   :user-id (ref saved-user :id)
                                   :bio bio)))
          (save profile))))))

;; 使用例
(create-user-with-profile "Taro Yamada" "taro@example.com" "Hello, World!")
;; => ユーザーとプロフィールの両方が作成される
;; => どちらかが失敗すると、両方ロールバックされる

トランザクション内でのクエリ実行

トランザクション内では、通常通りクエリを実行できます。

(with-transaction
  ;; データの作成
  (let ((user (make-record '<user> :name "Taro")))
    (save user))
  
  ;; 作成したデータの検索
  (let ((users (execute-query
                 (query <user>
                        :as :user
                        :where (:= (:user :name) :name))
                 '(:name "Taro"))))
    (format t "Found ~A users~%" (length users))))

明示的なロールバック

トランザクションを明示的にロールバックしたい場合は、エラーを発生させます。

(handler-case
    (with-transaction
      (save user)
      
      ;; 何か条件によってロールバックしたい場合
      (when (some-condition-p)
        (error "Explicit rollback"))
      
      (save profile))
  (error (e)
    (format t "Transaction aborted: ~A~%" e)))

注意事項

  1. ネストの深さ: 過度なネストは避け、必要な場合のみ使用してください
  2. 長時間のトランザクション: トランザクションは短時間で完了するように設計してください
  3. デッドロック: 複数のトランザクションが同じリソースにアクセスする場合、デッドロックに注意してください
  4. コネクション管理: with-transaction はスレッドローカルなコネクションを使用します。同じスレッド内では同じコネクションが再利用されます

11. データの削除

単一レコードの削除

(defvar *user* (first (execute-query
                        (query <user>
                               :as :user
                               :where (:= (:user :id) 1))
                        '())))

;; 削除
(destroy *user*)

;; 削除後は frozen-p が T になり、変更できなくなる
(frozen-p *user*) ; => T
(save *user*)     ; => NIL (frozen なので保存できない)

複数レコードの削除

(defvar *users* (execute-query
                  (query <user>
                         :as :user
                         :where (:= (:user :is-active) NIL))
                  '()))

;; 一括削除
(destroy *users*)

カスケード削除

:cascade T を指定すると、:has-many で定義された関連レコードも削除されます。

;; 会社を削除すると、その会社に属する部署もすべて削除される
(destroy *company* :cascade T)

;; 部署を削除すると、その部署に属する従業員もすべて削除される
(destroy *department* :cascade T)

トランザクション内での削除

削除処理もトランザクション内で実行できます。

(with-transaction
  ;; 複数のレコードを削除
  (destroy user1)
  (destroy user2)
  ;; どちらかが失敗すると、両方ロールバックされる
  )

12. その他の便利な機能

dirty flag の確認

(defvar *user* (make-record '<user> :name "Test"))
(has-dirty-p *user*) ; => T (新規作成時は dirty)

(save *user*)
(has-dirty-p *user*) ; => NIL (保存後は dirty flag がクリアされる)

(setf (ref *user* :name) "New Name")
(has-dirty-p *user*) ; => T (変更があったので dirty)

エラーのクリア

(clear-error *user*)
(has-error-p *user*) ; => NIL

dirty flag のクリア

dirty flag を手動でクリアします。変更を破棄したい場合に便利です。

(setf (ref *user* :name) "New Name")
(has-dirty-p *user*) ; => T

(clear-dirty-flag *user*)
(has-dirty-p *user*) ; => NIL

デバッグユーティリティ

show-model-data

デバッグ目的で Model インスタンスのすべてのカラム値を表示します。

(show-model-data *user*)
;; 出力例:
;; ID: 1
;; NAME: "Taro Yamada"
;; EMAIL: "taro@example.com"
;; AGE: 30
;; ...

show-model-columns

Model のすべてのカラム定義を表示します。

(show-model-columns *user*)
;; カラム名、型、変換関数などの情報が出力されます

debug-table-information

Model 定義のデバッグ用に、テーブル情報レジストリ全体を表示します。

(debug-table-information)
;; 登録されているすべての Model のテーブル情報が出力されます

JSON への変換

Model インスタンスは自動的に JSON に変換できます。

(jonathan:to-json *user*)
;; => "{\"ID\":1,\"NAME\":\"Taro Yamada\",\"EMAIL\":\"taro@example.com\",...}"

13. よくある使用パターン

ページネーション

(defun get-users-page (page per-page)
  (let ((offset (* (1- page) per-page)))
    (execute-query
      (query <user>
             :as :user
             :order-by ((:user :created-at :desc))
             :limit per-page
             :offset offset)
      '())))

;; 使用例
(get-users-page 1 20)  ; 1ページ目(1-20件)
(get-users-page 2 20)  ; 2ページ目(21-40件)

検索

(defun search-users-by-name (keyword)
  (execute-query
    (query <user>
           :as :user
           :where (:like (:user :name) :keyword)
           :order-by ((:user :name)))
    (list :keyword (format nil "%~A%" keyword))))

バッチ処理とトランザクション

大量のデータを処理する場合は、トランザクションを適切に使用します。

(defun import-users (user-data-list)
  "ユーザーデータを一括インポート"
  (with-transaction
    (loop for user-data in user-data-list
          do (let ((user (make-record '<user>
                                     :name (getf user-data :name)
                                     :email (getf user-data :email))))
               (unless (save user)
                 (error "Failed to save user: ~A" (getf user-data :name)))))))

;; 使用例
(import-users '((:name "User1" :email "user1@example.com")
                (:name "User2" :email "user2@example.com")
                (:name "User3" :email "user3@example.com")))
;; => すべて成功するか、すべて失敗する

条件付き保存

(defun update-user-if-not-modified (user-id new-name expected-version)
  "バージョンが一致する場合のみユーザーを更新"
  (with-transaction
    (let ((user (first (execute-query
                        (query <user>
                               :as :user
                               :where (:= (:user :id) :id))
                        (list :id user-id)))))
      (when user
        (if (= (ref user :lock-version) expected-version)
            (progn
              (setf (ref user :name) new-name)
              (save user)
              T)
            (progn
              (format t "User was modified by another process~%")
              NIL))))))

14. バルク操作

バルク操作は大量データを効率的に処理するための機能です。オンライン処理ではなく、バッチ処理やバックグラウンドジョブでの使用を想定しています。

14.1. SELECT: with-query-cursor

大量のSELECT結果をバッチ単位で効率的に処理します。全レコードをメモリに載せずにストリーミング処理を行います。

特徴

  • データベース固有の最適化
    • PostgreSQL: サーバーサイドカーソル(トランザクション自動管理)
    • MySQL: ストリーミング結果セット
    • SQLite3: LIMIT/OFFSET ページネーション
  • バッチサイズの指定が可能(デフォルト: 1000)
  • query マクロと cl-batis の両方に対応

基本的な使い方

;; query マクロを使用
(with-query-cursor (user-rows
                    (query <user> :as :u :order-by ((:u :id)))
                    nil
                    :batch-size 500)
  (dolist (row user-rows)
    (format t "User: ~A, Email: ~A~%"
            (getf row :name)
            (getf row :email))))

パラメータ付きクエリ

(with-query-cursor (user-rows
                    (query <user>
                           :as :u
                           :where (:> (:u :age) :age)
                           :order-by ((:u :id)))
                    '(:age 20)
                    :batch-size 1000)
  (dolist (row user-rows)
    (process-user-row row)))

JOIN を含むクエリ

(with-query-cursor (blog-rows
                    (query <blog>
                           :as :blog
                           :joins ((:left-join :account))
                           :order-by ((:blog :id)))
                    nil
                    :batch-size 500)
  (dolist (row blog-rows)
    (format t "Blog: ~A, Author: ~A~%"
            (getf row :|BLOG.TITLE|)
            (getf row :|ACCOUNT.NAME|))))

cl-batis を使用

(select ("SELECT * FROM users WHERE age > :age ORDER BY id")
  (defsql find-users-by-age-greater-than (age)))

(with-query-cursor (user-rows
                    find-users-by-age-greater-than
                    '(:age 20)
                    :batch-size 500)
  (dolist (row user-rows)
    (format t "User: ~A, Age: ~A~%"
            (getf row :name)
            (getf row :age))))

トランザクション内での使用

;; PostgreSQL: トランザクションを明示的に開始(推奨)
(with-transaction
  (with-query-cursor (user-rows
                      (query <user> :as :u :order-by ((:u :id)))
                      nil
                      :batch-size 500)
    (dolist (row user-rows)
      (process-user-row row)))
  
  ;; 他の処理も同じトランザクション内で実行可能
  (save summary-data))

;; 外部コネクションを使用
(with-db-connection (conn)
  (with-transaction-using-connection conn
    (with-query-cursor (user-rows
                        (query <user> :as :u :order-by ((:u :id)))
                        nil
                        :batch-size 500
                        :connection conn)
      (dolist (row user-rows)
        (process-user-row row)))))

SQLの確認(デバッグ用)

(show-query-sql (query <user>
                       :as :u
                       :where (:and (:= (:u :status) :status)
                                   (:> (:u :age) :age))
                       :order-by ((:u :id)))
                '(:status "active" :age 20))
;; => "SELECT U.ID as \"U.ID\", U.NAME as \"U.NAME\", ...
;;     WHERE U.STATUS = 'active' AND U.AGE > 20 ORDER BY U.ID"

注意事項

  1. ORDER BY の指定は必須: すべてのデータベースで ORDER BY を指定することを強く推奨します。ORDER BY がない場合、バッチ間で結果の順序が保証されず、重複や欠落が発生する可能性があります。

  2. データベース別の注意点:

    • PostgreSQL: カーソルを使用するため、自動的にトランザクションが開始されます
    • MySQL: ストリーミング結果セット使用中は接続が占有されます
    • SQLite3: OFFSET が大きくなると性能が劣化します
  3. バッチサイズの選択: デフォルトは 1000 行です。メモリ使用量とデータベース往復回数のトレードオフを考慮して調整してください(推奨範囲: 100〜5000 行)

14.2. INSERT: insert-allinsert-bulk

大量データの挿入を効率的に行う2つの関数を提供します。

insert-all: 1件ずつINSERT(ID書き戻しあり)

各モデルインスタンスに対して insert1 を実行し、INSERT後の id, created_at, updated_at が各インスタンスに設定されます。

(let* ((users (list (make-record '<user> :name "Alice" :age 30 :email "alice@example.com")
                    (make-record '<user> :name "Bob" :age 25 :email "bob@example.com")
                    (make-record '<user> :name "Charlie" :age 35 :email "charlie@example.com")))
       (inserted-users (insert-all users)))
  
  ;; 各インスタンスの id が設定される
  (dolist (user inserted-users)
    (format t "Inserted ID: ~A, Name: ~A~%"
            (slot-value user 'id)
            (ref user :name))))
;; 出力:
;; Inserted ID: 1, Name: Alice
;; Inserted ID: 2, Name: Bob
;; Inserted ID: 3, Name: Charlie

トランザクション内で実行:

(with-transaction (conn)
  (let ((users (list (make-record '<user> :name "Alice" :age 30)
                     (make-record '<user> :name "Bob" :age 25))))
    (insert-all users :connection conn)))

insert-bulk: バルクINSERT(高速、ID書き戻しなし)

バルク INSERT を実行し、挿入件数のみを返します。plist または model のリストを受け取ります。

plist を使用:

(insert-bulk '<user>
             '(:name :age :email)
             '((:name "Alice" :age 30 :email "alice@example.com")
               (:name "Bob" :age 25 :email "bob@example.com")
               (:name "Charlie" :age 35 :email "charlie@example.com"))
             :batch-size 100)
;; => 3

model インスタンスを使用:

(let ((users (list (make-record '<user> :name "Alice" :age 30 :email "alice@example.com")
                   (make-record '<user> :name "Bob" :age 25 :email "bob@example.com")
                   (make-record '<user> :name "Charlie" :age 35 :email "charlie@example.com"))))
  (insert-bulk '<user>
               '(:name :age :email)
               users
               :batch-size 100))
;; => 3

タイムスタンプの自動設定:

;; created_at, updated_at は未指定でも自動的に設定される
(insert-bulk '<user>
             '(:name :age)  ; created_at, updated_at は自動追加
             '((:name "Alice" :age 30)
               (:name "Bob" :age 25))
             :batch-size 100)

トランザクション内で実行:

(with-transaction (conn)
  (insert-bulk '<user>
               '(:name :age :email)
               large-data-list
               :connection conn
               :use-transaction nil))  ; 外部でトランザクション管理

使い分け

関数 用途 戻り値 ID書き戻し 速度
insert-all INSERT後のモデルを使用 モデルのリスト 遅い
insert-bulk CSVインポート等の高速挿入 挿入数(整数) 速い

14.3. UPDATE: update-allupdate-bulk

大量データの更新を効率的に行う2つの関数を提供します。

update-all: 1件ずつUPDATE

各モデルインスタンスに対して update1 を実行します。dirty-flag が設定されたカラムのみが更新されます。

(let* ((users (list user1 user2 user3)))
  ;; 各ユーザーの情報を変更
  (setf (ref user1 :name) "Alice Updated")
  (setf (ref user2 :age) 26)
  (setf (ref user3 :email) "charlie-new@example.com")
  
  ;; 一括更新
  (let ((updated-users (update-all users)))
    (dolist (user updated-users)
      (format t "Updated ID: ~A, updated-at: ~A~%"
              (slot-value user 'id)
              (ref user :updated-at)))))

異なるmodelクラスの混在:

(let* ((user (make-record '<user> :name "Alice" :age 30))
       (post (make-record '<post> :title "Hello" :content "World"))
       (inserted-user (first (insert-all (list user))))
       (inserted-post (first (insert-all (list post)))))
  
  ;; 両方のインスタンスを変更
  (setf (ref inserted-user :age) 31)
  (setf (ref inserted-post :title) "Hello World")
  
  ;; 一括更新(user は users テーブル、post は posts テーブルに UPDATE される)
  (let ((updated (update-all (list inserted-user inserted-post))))
    (format t "Updated ~A records~%" (length updated))))
;; => Updated 2 records

トランザクション内で実行:

(with-transaction (conn)
  (let ((users (list user1 user2)))
    (update-all users :connection conn)))

update-bulk: バッチUPDATE

バルク UPDATE を実行し、更新件数を返します。更新するカラムを明示的に指定します。

(let ((users (list user1 user2 user3)))
  ;; 各ユーザーの情報を変更
  (setf (ref user1 :name) "Alice Updated")
  (setf (ref user2 :age) 26)
  (setf (ref user3 :email) "charlie-new@example.com")
  
  ;; バルク更新(カラムを指定)
  (let ((count (update-bulk '<user> '(:name :age :email) users)))
    (format t "Updated ~A records~%" count)))
;; => Updated 3 records

updated_at は自動更新:

;; updated_at は未指定でも自動的に更新される
(update-bulk '<user> '(:name :age) users)
;; => name, age, updated_at が更新される

id と created_at は除外:

;; id と created_at は指定しても無視される
(update-bulk '<user> '(:id :name :created-at :age) users)
;; => name, age, updated_at が更新される(id と created_at は除外)

型不一致時の動作指定:

;; デフォルトは :error - 異なる型があればエラー
(update-bulk '<user>
             '(:name :age)
             (list user1 post1 user2))  ; post1 は <post> インスタンス
;; => type-mismatch-error が発生

;; :skip - 異なる型はスキップ
(update-bulk '<user>
             '(:name :age)
             (list user1 post1 user2)
             :on-type-mismatch :skip)
;; => 2 (user1 と user2 のみ更新、post1 はスキップ)

使い分け

関数 用途 戻り値 カラム指定 速度
update-all dirty-flagに基づく更新 モデルのリスト dirty-flag 遅い
update-bulk カラムを明示的に指定して更新 更新数(整数) 明示的 中速

14.4. DELETE: delete-alldelete-bulk

大量データの削除を効率的に行う2つの関数を提供します。

delete-all: 1件ずつDELETE

各モデルインスタンスに対して destroy を実行します。カスケード削除に対応しています。

;; 基本的な削除
(let ((users (list user1 user2 user3)))
  (let ((deleted-users (delete-all users)))
    (format t "Deleted ~A users~%" (length deleted-users))))
;; => Deleted 3 users

カスケード削除:

;; カスケード削除を有効にする
(let ((users (list user1 user2)))
  (delete-all users :cascade t))
;; => user1, user2 とそれらに関連するレコードを削除

トランザクション内で実行:

(with-transaction (conn)
  (let ((users (list user1 user2)))
    (delete-all users :connection conn)))

delete-bulk: バッチDELETE

バルク DELETE を実行し、削除件数を返します。IN 句を使用した最適化版で実装されています。

;; 基本的な使い方
(let ((users (list user1 user2 user3)))
  (let ((count (delete-bulk '<user> users)))
    (format t "Deleted ~A records~%" count)))
;; => Deleted 3 records

カスタムバッチサイズ:

(delete-bulk '<user> large-user-list :batch-size 50)

型不一致時の動作指定:

;; デフォルトは :error - 異なる型があればエラー
(delete-bulk '<user> (list user1 product1 user2))
;; => type-mismatch-error が発生

;; :skip - 異なる型はスキップ
(delete-bulk '<user> (list user1 product1 user2) :on-type-mismatch :skip)
;; => 2 (user1 と user2 のみ削除、product1 はスキップ)

トランザクション内で実行:

(with-transaction (conn)
  (delete-bulk '<user> users
               :connection conn
               :use-transaction nil))  ; 外部でトランザクション管理

使い分け

関数 用途 戻り値 カスケード 速度
delete-all カスケード削除が必要 モデルのリスト 遅い
delete-bulk 高速に削除 削除数(整数) 速い

14.5. エラーハンドリング

type-mismatch-error

型不一致が発生した場合のエラーです。

(handler-case
    (update-bulk '<user>
                 '(:name :age)
                 (list user1 post1 user2))
  (type-mismatch-error (e)
    (format t "Type mismatch: ~A~%" e)))

:on-type-mismatch オプション

  • :error: エラーを発生させる(デフォルト)
  • :skip: スキップして続行
;; エラーを発生させずにスキップ
(let ((count (update-bulk '<user>
                          '(:name :age)
                          (list user1 post1 user2)
                          :on-type-mismatch :skip)))
  (format t "Updated ~A records~%" count))
;; => Updated 2 records

14.6. バルク操作のベストプラクティス

1. ORDER BY の指定

すべてのデータベースで ORDER BY を指定することを強く推奨します。

;; 推奨
(with-query-cursor (rows
                    (query <user> :as :u :order-by ((:u :id)))
                    nil)
  (dolist (row rows)
    (process-row row)))

;; 非推奨: ORDER BY なし(結果の順序が保証されない)
(with-query-cursor (rows
                    (query <user> :as :u)
                    nil)
  (dolist (row rows)
    (process-row row)))

2. バッチサイズの選択

メモリ使用量とデータベース往復回数のトレードオフを考慮します。

;; 推奨範囲: 100〜5000 行
(with-query-cursor (rows
                    (query <user> :as :u :order-by ((:u :id)))
                    nil
                    :batch-size 1000)  ; デフォルト
  ...)

3. トランザクション管理

一貫性が必要な場合は with-transaction で囲みます。

(with-transaction
  (with-query-cursor (rows
                      (query <user> :as :u :order-by ((:u :id)))
                      nil)
    (dolist (row rows)
      (process-row row)))
  
  ;; 他の処理も同じトランザクション内で
  (save summary-data))

4. オンライン処理での使用は非推奨

バルク操作は長時間接続を占有したり、トランザクションを維持したりするため、オンライン処理での使用は非推奨です。バッチ処理やバックグラウンドジョブでの使用を想定しています。

;; 非推奨: オンライン処理での使用
(defun show-all-users ()
  (with-query-cursor (rows
                      (query <user> :as :u :order-by ((:u :id)))
                      nil
                      :batch-size 10000)  ; バッチサイズが大きすぎる
    (render-html rows)))  ; すべての結果を画面に表示

;; 推奨: バッチ処理での使用
(defun batch-process-users ()
  (with-query-cursor (rows
                      (query <user> :as :u :order-by ((:u :id)))
                      nil
                      :batch-size 1000)
    (dolist (row rows)
      (process-user-row row))))

15. クエリキャッシュ機能

clails では、クエリのパース結果をキャッシュすることで、同じクエリを繰り返し実行する際のパフォーマンスを向上させています。

概要

<query> クラスは、クエリ全体(SELECT、JOIN、WHERE、ORDER BY、LIMIT、OFFSET)のパース結果を内部的にキャッシュします。これにより、execute-query でのクエリ実行時に、初回のみパース処理が行われ、2回目以降はキャッシュされた結果が使用されます。

キャッシュの仕組み

自動キャッシュ

クエリは初回実行時に自動的にキャッシュされます。ユーザーが明示的にキャッシュを管理する必要はありません。

;; クエリを定義
(defvar *user-query* 
  (query <user>
         :as :user
         :where (:= (:user :is-active) :active)
         :order-by ((:user :created-at :desc))))

;; 初回実行: パース処理が実行され、結果がキャッシュされる
(execute-query *user-query* '(:active t))

;; 2回目以降: キャッシュされた結果が使用される(パース処理はスキップ)
(execute-query *user-query* '(:active t))
(execute-query *user-query* '(:active nil))

キャッシュの無効化

クエリ定義を変更すると、キャッシュは自動的に無効化され、次回実行時に再度パース処理が行われます。

;; query-builder で動的にクエリを構築
(defvar *q* (query-builder '<user> :as :user))
(set-columns *q* '((user :id :name)))
(set-where *q* '(:= (:user :status) :status))

;; 初回実行: キャッシュ生成
(execute-query *q* '(:status "active"))

;; クエリ定義を変更: キャッシュが自動的に無効化される
(set-where *q* '(:> (:user :age) :min-age))

;; 次回実行: 新しいクエリでパース処理が実行され、キャッシュされる
(execute-query *q* '(:min-age 20))

キャッシュの対象

以下のクエリコンポーネントのパース結果がキャッシュされます:

  • SELECT句: カラム選択のパース結果
  • JOIN句: JOIN条件のパース結果
  • WHERE句: 条件式のパース結果(動的IN句のテンプレートを含む)
  • ORDER BY句: ソート条件のパース結果
  • LIMIT/OFFSET句: ページネーションのパース結果

動的IN句の扱い

動的IN句(パラメータでリストを渡す場合)では、IN句のテンプレートがキャッシュされ、実際の展開は実行時に行われます。

(defvar *blog-query*
  (query <blog>
         :as :blog
         :where (:in (:blog :id) :blog-ids)))

;; 初回実行: IN句のテンプレートがキャッシュされる
(execute-query *blog-query* '(:blog-ids (1 2 3)))

;; 2回目実行: キャッシュされたテンプレートから動的に展開される
(execute-query *blog-query* '(:blog-ids (4 5 6 7 8)))

;; 異なる数の値でも正しく展開される
(execute-query *blog-query* '(:blog-ids (10)))

パフォーマンスへの影響

キャッシュ機能により、以下のような状況でパフォーマンスが向上します:

1. ページネーション

(defvar *page-query*
  (query <user>
         :as :user
         :where (:= (:user :status) :status)
         :order-by ((:user :created-at :desc))
         :limit 20
         :offset :offset))

;; 各ページの取得でパース処理がスキップされる
(execute-query *page-query* '(:status "active" :offset 0))   ; ページ1
(execute-query *page-query* '(:status "active" :offset 20))  ; ページ2
(execute-query *page-query* '(:status "active" :offset 40))  ; ページ3

2. バッチ処理

(defvar *batch-query*
  (query <task>
         :as :task
         :where (:= (:task :status) :status)))

;; 大量のクエリ実行で効果を発揮
(loop for i from 1 to 1000
      do (execute-query *batch-query* '(:status "pending")))

3. 複雑なクエリ

複雑なJOINやWHERE句を持つクエリでは、パース処理のコストが大きいため、キャッシュの効果が顕著になります。

(defvar *complex-query*
  (query <employee>
         :as :emp
         :joins ((:inner-join :department)
                 (:inner-join :company :through :department))
         :where (:and (:= (:dept :name) :dept-name)
                      (:>= (:emp :salary) :min-salary)
                      (:in (:company :industry) :industries))
         :order-by ((:emp :salary :desc))))

;; 複雑なクエリでも2回目以降はパース処理がスキップされる
(execute-query *complex-query* 
               '(:dept-name "Sales" 
                 :min-salary 50000 
                 :industries ("Tech" "Finance")))

注意事項

  1. クエリの再利用: 同じクエリを複数回実行する場合は、クエリオブジェクトを変数に保存して再利用してください
  2. メモリ使用量: キャッシュはクエリオブジェクトのライフサイクルと同期して管理されます。不要になったクエリオブジェクトは適切に破棄してください
  3. スレッドセーフティ: 現在の実装では、<query> インスタンスを複数のスレッドから同時に使用することは想定していません

ベストプラクティス

推奨: クエリの再利用

;; 推奨: クエリオブジェクトを再利用
(defvar *user-search-query*
  (query <user>
         :as :user
         :where (:like (:user :name) :keyword)))

(defun search-users (keyword)
  (execute-query *user-search-query* 
                 (list :keyword (format nil "%~A%" keyword))))

;; 何度呼び出しても効率的
(search-users "Alice")
(search-users "Bob")
(search-users "Charlie")

非推奨: クエリの再生成

;; 非推奨: 毎回クエリを生成(キャッシュの恩恵を受けられない)
(defun search-users-bad (keyword)
  (execute-query
    (query <user>
           :as :user
           :where (:like (:user :name) :keyword))
    (list :keyword (format nil "%~A%" keyword))))

まとめ

clails の Model は以下の特徴を持ちます。

  1. 明示的な設計: カラム情報は自動取得され、アクセスは専用の関数を使用
  2. 効率的な更新: dirty flag により、変更されたカラムのみを更新
  3. 柔軟なクエリ: DSL によるクエリ構築で、JOIN や複雑な条件も記述可能
  4. 関連の管理: :belongs-to:has-many による親子関係の定義
  5. 安全性: バリデーション、楽観的ロック・悲観的ロック、トランザクションのサポート
  6. トランザクション管理: with-transaction による簡単なトランザクション制御とネストしたトランザクション(セーブポイント)のサポート
  7. ネイティブクエリ: cl-batis を使用した柔軟な SQL クエリの実行
  8. バルク操作: 大量データを効率的に処理するためのストリーミング処理とバッチ操作のサポート
  9. クエリキャッシュ: クエリのパース結果を自動的にキャッシュし、同じクエリの繰り返し実行時のパフォーマンスを向上

詳細な API リファレンスについては、各関数の docstring を参照してください。