Rails

【Rails】DB制約とバリデーションの整合性をチェックできるGem「database_consistency」について

Rails

 

今回はデータベース制約とバリデーションの整合性を簡単にチェックできるGem「database_consistency」について紹介したいと思います。このGemを用いることでforeign_keyやindexの付与漏れを未然に防ぐことをできるため、アプリケーションの品質を保つことができます。

Database Consistencyとは

Database Consistencyとはデータベース制約とバリデーションの整合性をチェックしてくれるGemになります。

The main goal of the project is to provide an easy way to check the consistency of the database constraints with the application validations.

プロジェクトの主な目標は、データベースの制約とアプリケーションの検証との整合性をチェックする簡単な方法を提供することです。

参照: database_consistency

チェックできる項目

2021/11現在、Database Consistencyでチェックできる項目は以下になります。

  • find missing null constraints (ColumnPresenceChecker)
  • find missing length validations (LengthConstraintChecker)
  • find missing presence validations (NullConstraintChecker)
  • find missing uniqueness validations (UniqueIndexChecker)
  • find missing foreign keys for BelongsTo associations (ForeignKeyTypeChecker)
  • find missing unique indexes for uniqueness validation (MissingUniqueIndexChecker)
  • find missing index for HasOne and HasMany associations (MissingIndexChecker)
  • find primary keys with integer/serial type (PrimaryKeyTypeChecker)
  • find mismatching primary key types with their foreign keys (ForeignKeyTypeChecker)
  • find redundant non-unique indexes (RedundantIndexChecker)
  • find redundant uniqueness constraint (RedundantUniqueIndexChecker)

ColumnPresenceChecker

Imagine your model has a validates :email, presence: true validation on some field or a required belongs_to :user association but doesn’t have a not-null constraint in the database. In that case, your model’s definition assumes (in most cases) you won’t have null values in the database but it’s possible to skip validations or directly write improper data in the table. Keep in mind that belongs_to is required by default starting from Rails 5 given config.load_defaults is in place and unless config.active_record.belongs_to_required_by_default is explicitly set to false.

To avoid the inconsistency and be always sure your value won’t be null you should add not-null constraint.

モデルにvalidates: email, presence: trueの検証がいくつかのフィールドにあるか、必須のbelongs_to: userアソシエーションがあるが、データベースにnull以外の制約がないことを想像してください。その場合、モデルの定義では、(ほとんどの場合)データベースにnull値がないことを前提としていますが、検証をスキップしたり、テーブルに不適切なデータを直接書き込んだりすることは可能です。 config.load_defaultsが設定されていて、config.active_record.belongs_to_required_by_defaultが明示的にfalseに設定されていない限り、デフォルトではRails5以降belongs_toが必要であることに注意してください。

不整合を回避し、値がnullにならないようにするために、null以外の制約を追加する必要があります。

参照: ColumnPresenceChecker

LengthConstraintChecker

Imagine your model has limit constraint on some field in the database but doesn’t have validates :email, length: { maximum: <VALUE> } validation. In that case, you’re sure that you won’t have values with exceeded length in the database. But each attempt to save a value with exceeded length on that field will be rolled back with error raised and without errors on your object. Mostly, you’d like to catch it properly and for that length validator exists.

We fail if any of following conditions are satisfied:

  • there is no length validation for the column
  • there is length validation for the column but with greater limit than in database, so some values will still throw an error

モデルのデータベースの一部のフィールドに制限制約がありますが、validates: email, length: {maximum: <VALUE>}検証がない場合を想像してください。その場合、データベースに長さを超える値がないことを確認できます。ただし、そのフィールドで長さを超えた値を保存しようとするたびに、エラーが発生し、オブジェクトでエラーが発生することなくロールバックされます。ほとんどの場合、それを適切にキャッチしたいと考えており、その長さのバリデーターが存在します。

次の条件のいずれかが満たされた場合、失敗します。

  • カラムの長さの検証はありません
  • カラムの長さの検証がありますが、データベースよりも制限が大きいため、一部の値は引き続きエラーをスローします

参照: LengthConstraintChecker

NullConstraintChecker

Imagine your model has not-null constraint on some field in the database but doesn’t have validates :email, presence: true validation. In that case, you’re sure that you won’t have null values in the database. But each attempt to save the nil value on that field will be rolled back with error raised and without errors on your object. Mostly, you’d like to catch it properly and for that presence validator exists.

We fail if the column satisfies the following conditions:

  • column is required in the database
  • column is not a primary key (we don’t need need presence validators for primary keys)
  • model records timestamps and column’s name is not created_at or updated_at
  • column is not used for any Presence or Inclusion validators
  • column is not used for any Exclusion validators with nil
  • column is not used for any Numericality validators with allow_nil disabled
  • column is not used for required BelongsTo association
  • column has not a default value
  • column has not a default function

モデルのデータベースの一部のフィールドにnull以外の制約があるが、validates: email, presence: trueバリデーションがない場合を想像してみてください。その場合、データベースにnull値がないことを確認できます。ただし、そのフィールドにnil値を保存しようとするたびに、エラーが発生し、オブジェクトにエラーが発生することなくロールバックされます。ほとんどの場合、それを適切にキャッチしたいと考えており、そのためにプレゼンスバリデーターが存在します。

カラムが次の条件を満たす場合、失敗します。

  • データベースにカラムが必要です
  • カラムは主キーではありません(主キーにプレゼンスバリデーターは必要ありません)
  • モデルはタイムスタンプを記録し、カラムの名前はcreated_atまたはupdated_atではありません
  • カラムは、プレゼンスまたはインクルージョンバリデーターには使用されません
  • カラムは、nilを使用する除外バリデーターには使用されません。
  • カラムは、allow_nilが無効になっている数値バリデーターには使用されません
  • カラムは、必要なBelongsToアソシエーションには使用されません
  • カラムにデフォルト値がありません
  • カラムにはデフォルトの機能がありません

参照: NullConstraintChecker

UniqueIndexChecker

Imagine your model has a unique index in the database but doesn’t have validates :email, uniqueness: true validation. In that case, you’re sure that you won’t have duplicated values in the database. But each attempt to save a duplicated value on that field will be rolled back with error raised and without errors on your object. Mostly, you’d like to catch it properly and for that uniqueness validator exists. This checker also support unique index on multiple columns (which should have a validates :email, uniqueness: { scope: :last_name } validation).

We fail if any of following conditions are satisfied:

  • there is no uniqueness validation for the column(s)

モデルのデータベースに一意のインデックスがありますが、validates :email, uniqueness: true バリデーションがないとします。その場合、データベースに重複する値がないことを確認できます。ただし、そのフィールドに重複した値を保存しようとするたびに、エラーが発生し、オブジェクトにエラーが発生することなくロールバックされます。ほとんどの場合、あなたはそれを適切にキャッチしたいと思っており、そのために一意性バリデーターが存在します。このチェッカーは、複数のカラムの一意のインデックスもサポートします(validates: email, uniqueness: {scope: :last_name} バリデーションが必要です)。

次の条件のいずれかが満たされた場合、失敗します。

  • カラムの一意性の検証はありません

参照: UniqueIndexChecker

ForeignKeyTypeChecker

Imagine your model has belongs_to :user. It can happen that the user, it’s being belonging to, may not be existing anymore in the database. This could bring bugs and in order to ensure the data consistency, you need to have foreign key constraint in the database.

We fail if the following conditions are satisfied:

  • belongs_to association is not polymorphic
  • there is no foreign key constraint

モデルにbelongs_to: userがあるとします。所属しているユーザーがデータベースに存在しなくなっている可能性があります。これによりバグが発生する可能性があり、データの整合性を確保するには、データベースに外部キー制約を設定する必要があります。

次の条件が満たされると失敗します。

  • 所属する関連付けはポリモーフィックではありません
  • 外部キー制約はありません

参照: ForeignKeyChecker

MissingUniqueIndexChecker

Imagine your model has a validates :email, uniqueness: true validation but has no unique index in the database. As general problem your validation can be skipped or there is possible duplicates insert because of race condition. To keep your data consistent you should cover your validation with proper unique index in the database (if possible). It will ensure you don’t have duplicates.

We fail if the following conditions are satisfied:

  • there is no unique index for the uniqueness validation

モデルにvalidates: email, uniqueness: trueバリデーションがあり、データベースに一意のインデックスがないことを想像してください。一般的な問題として、検証をスキップするか、競合状態のために重複挿入が発生する可能性があります。データの一貫性を保つために、データベース内の適切な一意のインデックスで検証をカバーする必要があります(可能な場合)。それはあなたが重複を持っていないことを保証します。

次の条件が満たされると失敗します。

  • 一意性検証のための一意のインデックスはありません

参照: MissingUniqueIndexChecker

MissingIndexChecker

Imagine your model has a has_one :user association but has no index in the database. In this case querying the database to get the associated instance can be very inefficient. Mostly, you’ll need an index to process such queries fast.

We fail if the following conditions are satisfied:

  • there is no index for the HasOne or HasMany association
  • it has a through option

モデルにhas_one: userアソシエーションがあり、データベースにインデックスがないとします。この場合、関連付けられたインスタンスを取得するためにデータベースにクエリを実行すると、非常に非効率になる可能性があります。ほとんどの場合、このようなクエリを高速に処理するにはインデックスが必要です。

次の条件が満たされると失敗します。

  • HasOneまたはHasManyアソシエーションのインデックスはありません
  • スルーオプションがあります

参照: MissingIndexChecker

PrimaryKeyTypeChecker

ActiveRecord has changed its default types for primary keys (PR). Given no one is immune to problems short types may create, we added a checker to identify those IDs.

We fail if the following conditions are satisfied:

  • primary key type is not in the list: bigint, bigserial, uuid.

ActiveRecordは、主キー(PR)のデフォルトタイプを変更しました。短いタイプで発生する可能性のある問題の影響を受けない人はいないため、これらのIDを識別するためのチェッカーを追加しました。

次の条件が満たされると失敗します。

  • 主キータイプがリストにありません: bigint, bigserial, uuid

参照: PrimaryKeyTypeChecker

ForeignKeyTypeChecker

It’s dangerous to have foreign key type to be different than paired primary key type. Given no one is immune to possible problems, we added a checker to identify those mismatches.

We fail if the following conditions are satisfied:

  • foreign key type is not the same as paired primary key.

外部キータイプがペアの主キータイプと異なることは危険です。起こりうる問題の影響を受けない人はいないため、これらの不一致を特定するためのチェッカーを追加しました。

次の条件が満たされると失敗します。

  • 外部キータイプは、ペアの主キーと同じではありません。

参照: ForeignKeyTypeChecker

RedundantIndexChecker

This checker helps to identify redundant non-unique indexes. Assuming you have an index in the database that covers column A and another index that covers columns A and B (order is important). In this case, the first index may be removed as it is covered by second one.

We fail if the following conditions are satisfied:

  • there is an index that has prefix that consists the current one.

このチェッカーは、冗長で一意でないインデックスを識別するのに役立ちます。データベースにカラムAをカバーするインデックスと、カラムAとBをカバーする別のインデックスがあると仮定します(順序は重要です)。この場合、最初のインデックスは2番目のインデックスでカバーされているため、削除される可能性があります。

次の条件が満たされると失敗します。

  • 現在のインデックスを構成するプレフィックスを持つインデックスがあります。

参照: RedundantIndexChecker

RedundantUniqueIndexChecker

This checker helps to identify redundant uniqueness on some indexes. Assuming you have an unique index in the database that covers columns A and B (order is not important) and another unique index that covers column A only. In this case, the first unique constraint is redundant as it is covered by the second one.

We fail if the following conditions are satisfied:

  • there is an unique index that consists only from columns for the current one.

このチェッカーは、一部のインデックスの冗長な一意性を識別するのに役立ちます。データベースにカラムAとBをカバーする一意のインデックス(順序は重要ではありません)と、カラムAのみをカバーする別の一意のインデックスがあるとします。この場合、最初の一意性制約は2番目の制約でカバーされているため、冗長です。

次の条件が満たされると失敗します。

  • 現在のカラムのカラムのみで構成される一意のインデックスがあります。

参照: RedundantUniqueIndexChecker

使い方

ではDatabase Consistencyの使い方について見ていきます。

まずはdevelopment環境で「gem ‘database_consistency’, require: false」を記載し、bundle installを実行します。

group :development do
  gem 'database_consistency', require: false
end
% bundle install

 

あとは以下のコマンドを入力するだけで、整合性を自動チェックしてくれます。

% bundle exec database_consistency
# チェック項目に引っかかった場合

% bundle exec database_consistency
LengthConstraintChecker fail User name column has limit in the database but do not have length validator
ColumnPresenceChecker fail User name column should be required in the database

カスタマイズ

デフォルトではすべてのチェック項目が走ってしまいますが「.database_consistency.yml」を作成することでチェック項目を指定することができます。

% touch .database_consistency.yml

 

以下のように対象モデルのチェック項目をtrue or falseでスキップすることができます。

DatabaseConsistencySettings:
  color: true
  log_level: DEBUG

DatabaseConsistencyCheckers:
  MissingIndexChecker:
    enabled: true # 1st priority (completely turn off particular checker)
  MissingUniqueIndexChecker:
    enabled: true
  ColumnPresenceChecker:
    enabled: true
  NullConstraintChecker:
    enabled: true

User:
  enabled: true # 2nd priority (turn off checking User model)
  phone:
    enabled: true # 3rd priority (turn off phone checking phone field/attribute of User model)
    ColumnPresenceChecker:
      enabled: true # 4th priority (turn off checker for phone field/attribute of User model)
  name:
    enabled: true
  code:
    enabled: true
    NullConstraintChecker:
      enabled: true
  name+email:
    MissingUniqueIndexChecker:
      enabled: true

Country:
  users:
    MissingIndexChecker:
      enabled: true

# Can be compact (example), "enabled: true" is default
# User:
#   phone:
#     ColumnPresenceChecker:
#       enabled: false
#   name:
#     enabled: false
# Company:
#   enabled: false


# Concerns can be emulated using YAML's anchors and aliases

# 1. Define an anchor before declaring concern-related settings
DateConcern: &ignore_date_concern
  date:
    NullConstraintChecker:
      enabled: false
NameConcern: &ignore_name_concern
  name:
    ColumnPresenceChecker:
      enabled: false

# Models using concerns
# 2. Now include the relevant settings using aliases
Event:
  <<: *ignore_date_concern
  <<: *ignore_name_concern
Poll:
  <<: *ignore_date_concern

参照: rails-example/.database_consistency.yml

まとめ

  • Database Consistencyとはデータベース制約とバリデーションの整合性をチェックしてくれるGem
  • 「% bundle exec database_consistency」で整合性を自動チェックできる
  • .database_consistency.ymlで指定したチェック項目をskipすることができる

参考

database_consistency

 

 

今回はデータベース制約とバリデーションの整合性を簡単にチェックできるGem「database_consistency」について紹介しました。DB構造が複雑になりそうな場合は導入を検討しても良いかもしれません。