虎の穴開発室ブログ

虎の穴ラボ株式会社所属のエンジニアが書く技術ブログです

MENU

RailsでMariaDBのCHECK制約を試行してみた話

こんにちは、虎の穴ラボの辻村です。

本記事は虎の穴ラボ Advent Calendar 2022の21日目の記事になります。

昨日20日目は、H.Y.さんによる「水槽の水替え時期を教えてくれる仕組みを作った。」が投稿されています。
明日22日目は、はっとりさんによる「JavaScriptのMinifyについて - terser を使ってみよう」が投稿される予定です。

こちらもぜひご覧ください。

はじめに

目的

CHECK制約をRailsアプリケーションで扱う、メリットデメリットを言語化します。

経緯など

自作アプリでCHECK制約を導入しましたが、やはりどうにも扱いづらさを感じたので、試行した内容をまとめ直しました。

なお、自作アプリでと書きましたが、本記事に載せているものは自作アプリのものとは異なるコードとなります。

前提

OS: AmazonLinux:2(20221103.3)
DB: MariaDB: 10.10.2
Ruby: 3.1.3
Rails: 7.0.4

CHECK制約とは

細かい話はこの辺りを参照してください。

mariadb.com

簡単に言うと、単一カラムに対するデータ更新、登録において、そのデータが指定された条件に合致するデータであるかをチェックするための制約です。

MySQL系だと、以下のバージョン以降で扱うことができます。

MariaDB: 10.2.1
MySQL: 8.0.16

できること

  • OR、ANDなどの条件の使用

  • CASE文、正規表現を使用した判定

  • 同一レコードの複数カラムの比較

できないこと

  • 他テーブルや他レコードとの比較

試行内容

  • Rails上でCHECK制約を管理(migrate、rollback)できるようにします。
  • Unitテスト(RSpec)でエラー発生時の挙動を担保します。

DB側

20221215085401_create_products.rb

class CreateProducts < ActiveRecord::Migration[7.0]
  def change
    create_table :products, comment: '商品を管理するテーブル' do |t|
      t.string :name, null: false, comment: '商品名'
      t.integer :price, null:false, comment: '価格'
      t.timestamps
    end

    reversible do |dir|
      dir.up do
        # CHECK制約を追加
        execute "ALTER TABLE products ADD CONSTRAINT check_products_price CHECK (#{Product::LIMITED_LOWER_PRICE} <= price AND price <= #{Product::LIMITED_UPPER_PRICE});"
      end

      dir.down do
        # CHECK制約を削除
        execute "ALTER TABLE products DROP CONSTRAINT check_products_price;"
      end
    end
  end
end

schema.rb

ActiveRecord::Schema[7.0].define(version: 2022_12_15_085401) do
  create_table "products", charset: "utf8mb4", collation: "utf8mb4_bin", comment: "商品を管理するテーブル", force: :cascade do |t|
    t.string "name", null: false, comment: "商品名"
    t.integer "price", null: false, comment: "価格"
    t.datetime "created_at", null: false
    t.datetime "updated_at", null: false
    t.check_constraint "0 <= `price` and `price` <= 999999999", name: "check_products_price"
  end
end

実行コード

必要ではないところは割愛しています。

モデル

CHECK制約の動作確認が目的であるため、validationは今回はつけません。

product.rb

class Product < ApplicationRecord
  LIMITED_LOWER_PRICE = 0
  LIMITED_UPPER_PRICE = 999999999
end

テストコード本体

RSpec.describe Product, type: :model do
  describe '境界入力値' do
    context 'price' do
      PRICE_INVALID_MESSAGE_REGEX = /CONSTRAINT `check_products_price` failed for/
      let(:lower_limited_price){ described_class::LIMITED_LOWER_PRICE }
      let(:upper_limited_price){ described_class::LIMITED_UPPER_PRICE }
      let(:product){ create(:product, price: 2000) }
      it '下限値-1' do
        product.price = lower_limited_price - 1
        expect { product.save! }.to raise_error(ActiveRecord::StatementInvalid, PRICE_INVALID_MESSAGE_REGEX)
      end

      it '下限値' do
        product.price = lower_limited_price
        product.save!
        expect(product.reload.price).to eq lower_limited_price
      end

      it '上限値' do
        product.price = upper_limited_price
        product.save!
        expect(product.reload.price).to eq upper_limited_price
      end

      it '上限値+1' do
        product.price = upper_limited_price + 1
        expect { product.save! }.to raise_error(ActiveRecord::StatementInvalid, PRICE_INVALID_MESSAGE_REGEX)
      end
    end
  end
end

実行結果

上記テストコードを実行した結果がこちらです。

$ bundle exec rspec

Product
  境界入力値
    price
      下限値-1
      下限値
      上限値
      上限値+1

Finished in 1.22 seconds (files took 1.53 seconds to load)
4 examples, 0 failures

ちなみに、「下限値-1」のエラーメッセージは、以下のように返却されてきます。

ActiveRecord::StatementInvalid:
Mysql2::Error: CONSTRAINT `check_products_price` failed for `try_check_constraint_test`.`products`
(中略)
Mysql2::Error:
CONSTRAINT `check_products_price` failed for `try_check_constraint_test`.`products`

別の書き方について(Rails6.1以降)

調べているとexecuteを利用せずに、create_table内でチェック制約を付与する方法があるようなので、こちらも試してみることにしました。
記載方法はこちらの記事を参考にしました。 techracho.bpsinc.jp

記事を参考に修正したコード

class CreateProducts < ActiveRecord::Migration[7.0]
  def change
    create_table :products, comment: '商品を管理するテーブル' do |t|
      t.string :name, null: false, comment: '商品名'
      t.integer :price, null:false, comment: '価格'
      t.timestamps
      t.check_constraint 'check_products_price', "#{Product::LIMITED_LOWER_PRICE} <= price AND price <= #{Product::LIMITED_UPPER_PRICE}"
    end
  end
end

一見これで通りそうに見えますが、以下のエラーが発生します。

$ bin/rails db:migrate
== 20221215085401 CreateProducts: migrating ===================================
-- create_table(:products, {:comment=>"商品を管理するテーブル"})
rails aborted!
StandardError: An error has occurred, all later migrations canceled:

wrong number of arguments (given 2, expected 1)

check_constraint (ActiveRecord::ConnectionAdapters::ColumnMethods::Table) - APIdock

上記を参考に修正した結果がこちら(差分のみ抜粋)。

t.check_constraint "#{Product::LIMITED_LOWER_PRICE} <= price AND price <= #{Product::LIMITED_UPPER_PRICE}", name: 'check_products_price'

修正したコードを実行した結果

$ bin/rails db:migrate
== 20221215085401 CreateProducts: migrating ===================================
-- create_table(:products, {:comment=>"商品を管理するテーブル"})
   -> 0.0166s
== 20221215085401 CreateProducts: migrated (0.0169s) ==========================

やってみてどうだったか

感じたメリット

  • やはり、本来はセーフティーとしてあるべき制約。どうしても厳密にデータを制御、不正なデータを弾きたい場合に有用と感じました。

  • Rails 6.1以降だと、記載が楽になるのがありがたいです。(TABLE作成時に書くことができれば、DROP処理を書く必要がないため)

  • Rails外からの処理に対しても、共通的に値チェックすることができるのは良いと思います。

  • Rails内部からも、validateをskipして登録することが可能なので、万が一のことを考えて重要なカラムにはこのチェックを仕込んで想定外の値が登録されるのを避けると言うことも可能です。

感じたデメリット

  • チェック処理がアプリ側と二重に走るようになるので、性能への影響が心配です。(性能測定しようかとも考えましたが、今回はやめました)

  • アプリ側のチェックと二重の手間になるので、管理、修正コストが嵩みそうです。

  • エラーハンドリングが大変そうです。(ただし、本来このようなものはアプリ側で弾く想定なので、エラーハンドリング自体は気にしなくてもよさそうです)

  • DBに処理依存することになります。(ただし、最近のDBでCHECK制約に対応していないDBはあまりないはずなので、そこまで気にするようなことではないはずです)。

最後に

  • 使い所としては、やはり最終的な防衛ラインとして活用するのがあるべき姿と感じます。

  • RailsでCHECK制約を管理できるのは、自分でアプリを作る分にはありがたいです。

  • 可能であれば導入して、最低限の制約は固めてしまいたいですが、CHECK制約を業務で取り込もうとすると、それなりの労力(性能的な根拠も)が必要そうなので、なかなか活用の機会が少ないと感じました。

P.S.

採用情報
■募集職種
yumenosora.co.jp

カジュアル面談も随時開催中です
■お申し込みはこちら!
news.toranoana.jp

■ToraLab.fmスタートしました!
メンバーによるPodcastを配信中!
是非スキマ時間に聞いて頂けると嬉しいです。
anchor.fm
■Twitterもフォローしてくださいね!
ツイッターでも随時情報発信をしています
twitter.com