Posted at

DBラッパーEctoのchangesetでお手軽入力チェック

More than 1 year has passed since last update.


はじめに

Ectoのchangesetでのバリデーションについては、すでに投稿されている記事がいくつかありますが、

実際のプロダクト開発でよく使うケースを中心に実装のハンズオンを作成しました。

実装の概要は以下です。


  • Phoenix標準のEEXでユーザー登録を行うWebアプリを実装する。

  • DBはデフォルトのPostgreSQLを選択する。

  • Ecto.Changesetで入力チェックを行う。

  • 必須項目チェックの内容をカスタマイズする

  • パスワードの桁範囲、許容文字チェックを行う(パスワードのハッシュ化については別の記事が参考になるので割愛)

  • 電話番号が全角入力されたら自動的に半角変換する

  • emailアドレスが正しい書式かフォーマットをチェックする

  • コード値項目は事前に定義したメンバーと一致するか妥当性チェックを行う。

  • メールアドレスを業務的なユニークキーとして、重複チェックを行う。


実装の前提

以下の環境で実装しました


開発手順


Phoenixプロジェクトを作成する

> mix phx.new ecto_changeset_sample --no-brunch

> cd ecto_changeset_sample
> mix ecto.create


depsにMojiexを追加

要件の「電話番号が全角入力されたら自動的に半角変換する」の対応用に全角半角の変換ライブラリMojiexを入れました。

IFがシンプルで超使いやすいです。

2バイト文字の扱いが必須の日本では文字の揺らぎ制御に必須の処理なので、こういった使いやすいライブラリの存在は非常に助かります。


mix.exs

  defp deps do

[
{:phoenix, "~> 1.3.2"},
{:phoenix_pubsub, "~> 1.0"},
{:phoenix_ecto, "~> 3.2"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 2.10"},
{:phoenix_live_reload, "~> 1.0", only: :dev},
{:gettext, "~> 0.11"},
{:cowboy, "~> 1.0"},
{:mojiex, github: "enpedasi/mojiex"}, # <-- 追加
]
end

> mix deps.get


モデルを作成する

いつもの感じのユーザーモデルを作成します。

emailがユニーク項目で業務上の主キーとしています。

gradeは所謂顧客ランク的なもの

resident_typeは税制がらみでたまにある国内居住者/海外居住者の区分を想定しています。

> mix phx.gen.html Accounts User users name email:unique password age:integer phone_number grade:integer resident_type:integer


ルーティングを追加してmigrateする


lib/ecto_changeset_sample_web/router.ex

defmodule EctoChangesetSampleWeb.Router do

use EctoChangesetSampleWeb, :router

~中略~

scope "/", EctoChangesetSampleWeb do
pipe_through :browser # Use the default browser stack

get "/", PageController, :index
resources "/users", UserController # <-- 追加
end

end


> mix ecto.migrate


Userスキーマの修正

コード値の定数を定義するマップを返す関数を追加します。

私の場合はこうすることで、同じモデルを使用する処理でコードの設定ミスがなくなるようにしています。


lib/ecto_changeset_sample/accounts/user.ex

  def grade() do

%{
bronze: 1, #ブロンズ会員
silver: 2, #シルバー会員
gold: 3, #ゴールド会員
}
end

def resident_type() do
%{
domestic: 1, #国内居住者
oversea: 2 #外国居住者
}
end


changesetの内容を変更していきます。


lib/ecto_changeset_sample/accounts/user.ex

  def changeset(user, attrs) do

user
|> cast(attrs, [:name, :age, :email, :password, :phone_number, :grade, :resident_type])
|> validate_required([:name, :age, :email, :password, :grade]) #phone_number、regident_typeは任意項目、他は必須項目
|> validate_length(:password, min: 8, max: 16) #パスワードは8桁以上16桁以内
|> validate_format(:password, ~r/\A(?=.*?[a-z])(?=.*?[A-Z])(?=.*?\d)[a-zA-Z\d]/) #パスワードは半角英数大文字小文字をそれぞれ一文字以上含む
|> validate_confirmation(:password, message: "does not match password") #パスワードが確認入力と一致しない場合はエラー
|> put_change(:phone_number, Mojiex.convert(attrs["phone_number"],{:ze,:he})) #電話番号を全角で入力されても半角に変換する
|> validate_length(:phone_number, min: 10, max: 11) #電話番号は10桁~11桁
|> validate_format(:phone_number, ~r/[0-9]{10,11}/) #電話番号は10桁~11桁の数字 ※注意 正規表現の最大桁が効かないので別途桁数チェック
|> validate_format(:email, ~r/^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/) #emailアドレス正規表現 ※性能注意
|> validate_inclusion(:grade, Map.values(grade())) #gradeは関数gradeにある値のみ
|> validate_inclusion(:resident_type, Map.values(resident_type())) #regident_typeは任意項目、入力がある場合は関数regident_typeにある値のみ
|> unique_constraint(:email) #emailはユニーク制約付き SQL発行後に評価される
end


EEXのユーザーフォームを修正します

validate_confirmationで使用するようにpassword_confirmationを追加します。

※項目名は{チェックしたい対象}_confirmationが仕様

:lib/ecto_changeset_sample_web/templates/user/form.html.eex

<div class="form-group">
<%= label f, :password, class: "control-label" %>
<%= text_input f, :password, class: "form-control" %>
<%= error_tag f, :password %>
</div>

<!-- 確認用パスワード追加 -->
<div class="form-group">
<%= label f, :password_confirmation, class: "control-label" %>
<%= text_input f, :password_confirmation, class: "form-control" %>
<%= error_tag f, :password_confirmation %>
</div>

changesetの内容からは外れますが

デフォルトで生成されたEEXのフォームではコード値と想定した項目もinput_textなので、メンバー以外の値を選択できてしまいます。

EEXファイルを修正して、そもそも入力をselect項目にして不正な値を選択できないようにします。

ここでも先ほどスキーマに追加したコード値用関数を使用します。

select関数の第3引数にキーワードリスト化して渡せば、select項目として使えます。

画面から選べないのであればchangeset側で実行しているチェックが無駄に感じるかもしれませんが、

HTTP通信である以上開発者用のHTTPクライアントから直接リクエストを流し込まれる可能性があるので一概に無駄とは言えません。

データの不整合に繋がる可能性がある場合は、バックエンド側でも入力チェックを実装しておくべきです。

:lib/ecto_changeset_sample_web/templates/user/form.html.eex

<!-- デフォルトコード コメントアウト
<div class="form-group">
<%= label f, :grade, class: "control-label" %>
<%= number_input f, :grade, class: "form-control" %>
<%= error_tag f, :grade %>
</div>
-->

<div class="form-group" onchange="submit();">
<%= label f, :grade, class: "control-label" %>
<%= select( f, :grade, Map.to_list(EctoChangesetSample.Accounts.User.grade()), selected: [1]) %>
<%= error_tag f, :grade %>
</div>

<!-- デフォルトコード コメントアウト
<div class="form-group">
<%= label f, :resident_type, class: "control-label" %>
<%= number_input f, :resident_type, class: "form-control" %>
<%= error_tag f, :resident_type %>
</div>
-->

<div class="form-group" onchange="submit();">
<%= label f, :resident_type, class: "control-label" %>
<%= select( f, :resident_type, Map.to_list(EctoChangesetSample.Accounts.User.resident_type()), selected: [1]) %>
<%= error_tag f, :grade %>
</div>


動かしてみる

> mix phx.server

http://localhost:4000/users

わざと不正な値を入力します。

コード値はスキーマで定義したメンバーしか選択できません。

それぞれのチェックエラー内容が表示され登録に失敗します。

電話番号はひらがなも入れたので、桁数とフォーマットチェックにひっかっていますが、全角で入れた数字は半角に訂正されているのがわかります。


まとめ


  • validate_required はgen直後だと全項目必須になっているので、要件に合わせて削除する

  • validate_length で文字長の範囲チェック

  • validate_format で正規表現チェック(※比較的重いので実施要否は要検討)

  • validate_inclusion で選択肢の妥当性チェック(数字の範囲などもチェック可能)

  • validate_confirmation で確認入力チェック(主にパスワード用)

  • unique_constraint で二重登録を防止(SQLは発行されるのでDBアクセス不可の回避にはならないので注意)

  • put_change を使えば、半角全角大文字小文字など文字揺らぎの自動変換も可能

いかがだったでしょうか。

今回の実装例は動作のわかりやすさの為に、HTMLでgenした動的Webサーバーとしての実装しました。

ここで実装している入力チェックは主に単項目チェックなのでフロント側の実装でHTTP通信する前にチェックするべきですが、APIサーバーとして実装する場合は、フロントで防ぐことができないので、changesetでのチェック実装が有効になるでしょう。