Elixir Digitalization Implementors/fukuoka.ex/kokura.exのpiacereです
ご覧いただいて、ありがとうございます
Phoenixのphx.gen.html/phx.gen.jsonで生成されるDBアクセッサにて「論理削除」、つまり、deleteでは無く、updateにより論理削除を立てる方法が、どこにも解説されていなくて、でも実務では頻出するコードなので、コラム化してみました
ちなみにLiveViewでも、本コラムと全く同じ方法で対応が可能です(LiveViewのSSRとの互換性対応が素晴らしいです)
内容が、面白かったり、役に立ったら、「LGTM」よろしくお願いします
Advent Calendar、fukuoka.ex1位、Elixir2位達成ヽ(=´▽`=)ノ
fukuoka.ex Advent Calendar、Webテクノロジーカテゴリで堂々1位 … 各コラムぜひお読みください
https://qiita.com/advent-calendar/2020/fukuokaex
そして、プログラミング言語カテゴリは、1位がRust、2位がElixir、3位がGoとモダン言語揃い踏みでのトップ3、熱いネー
https://qiita.com/advent-calendar/2020/elixir
本コラムの検証環境
本コラムは、以下環境で検証しています(Windowsで実施していますが、Linuxやmacでも動作する想定です)
- Windows 10
- Elixir 1.11.3 ※最新版のインストール手順はコチラ
- Phoenix 1.5.7 ※最新版のインストール手順はコチラ
- PostgreSQL 10.1 ※最新版のインストール手順はコチラ
ステップ1:DBアクセッサのdeleteを論理削除updateに改修
phx.gen.html/phx.gen.jsonで生成されるDBアクセッサは、下記ファイルとして生成されます
lib/【PJ名】/【コンテキスト名】.ex
コンテキスト名とは、phx.gen.XXXXの第1引数で指定される、スキーマ定義モジュールを内蔵するフォルダ名、およびRepo呼出モジュール名のことです
たとえば、basicというPhoenix PJで、phx.gen.html(もしくはphx.gen.json)によりpostsというテーブルを下記コマンドで作るとします(ここでは、DB作成/マイグレート/ルーティング追加は割愛)
> mix phx.gen.html Posts Post posts title:string description:text deleted_at:datetime
この場合、DBアクセッサは、下記の通り、生成されます
defmodule Basic.Posts do
@moduledoc """
The Posts context.
"""
…
def update_post(%Post{} = post, attrs) do
post
|> Post.changeset(attrs)
|> Repo.update()
end
…
def delete_post(%Post{} = post) do
Repo.delete(post)
end
…
これを、Repo.deleteから、論理削除日時(deleted_at)のRepo.updateに変更します
なお、秒より下の桁(millisecond、microsecond)が入っていると、エラーになるので、NaiveDateTime.truncateで削っておきます
…
def delete_post(%Post{} = post) do
# Repo.delete(post) # comment-out here
%Ecto.Changeset
{
data: post,
changes: %{ deleted_at: NaiveDateTime.utc_now() |> NaiveDateTime.truncate( :second ) },
valid?: true
}
|> Repo.update()
end
…
update_postで行っている、Post.changeset(attrs)
の結果と同じデータ構造を自己生成して渡す点がポイントです
なお、論理削除の解除は、下記のように作ります(呼び出し側は、ここでは割愛)
…
def restore_delete_post(%Post{} = post) do
%Ecto.Changeset
{
data: post,
changes: %{ deleted_at: nil },
valid?: true
}
|> Repo.update()
end
…
ステップ2:UIの改修(phx.gen.html時のみ)
deleted_atは、通常のデータ追加/編集の際には不要なので、UI上から削除します
<%= form_for @changeset, @action, fn f -> %>
<%= if @changeset.action do %>
<div class="alert alert-danger">
<p>Oops, something went wrong! Please check the errors below.</p>
</div>
<% end %>
…
<% # comment-out start %>
<%= # label f, :deleted_at %>
<%= # datetime_select f, :deleted_at %>
<%= # error_tag f, :deleted_at %>
<% # comment-out end %>
…
ステップ3:changesetの改修
changesetによる入力時バリデーションチェックにて、validate_requiredの必須チェックがdeleted_atにもかかっていますが、不要なので、これを解除します
defmodule Basic.Posts.Post do
use Ecto.Schema
import Ecto.Changeset
…
@doc false
def changeset(post, attrs) do
post
|> cast(attrs, [:title, :description, :deleted_at])
|> validate_required([:title, :description]) # comment-out here # , :deleted_at])
end
…
ステップ4:動作確認
http://localhost:4000/posts
にアクセスして、データを投入し、Deleteリンクをクリックします
deleted_atに現在時刻(UTC)が入り、論理削除済みとなりました(なお、現状の削除UIでは、一度論理削除すると、論理削除状態を解除して、元に戻すことができません)
なお、論理削除済みのデータをEditしても、deleted_atは更新されなくなっているため、論理削除状態は維持され、Edit側の改修は不要です(ただし、この後、解説する注意点はあるかもなので、引き続きご覧ください)
論理削除を設計・運用する際の注意点
論理削除の運用は、論理削除済みデータに纏わる下記ポイントで地味にハマる方も多いカテゴリなので、実務の案件で実装する際には、ご注意ください
- 論理削除済みデータと同じ内容のデータ投入時、重複チェックを解除するのをお忘れなく
- 論理削除済みなのに、機能が生き残っているケースを生まないように
- 論理削除対象だけで無く、リレーション元の非表示化/無効化/従属削除も考慮すること
- 一覧表示/個別表示は、アクセス権限でモードが変わる
- 対ユーザ向けには見えないようにし、対管理者向けには見えるモードも用意する
- Ectoレベルで消すか?表示上のみ消すか?
最後に
今回は、Phoenixのphx.gen.~で生成されるCRUDで「論理削除」を実装してみました
Elixir開発の実務でも良く出てくるテクニックなので、覚えておきましょう
また論理削除だけに限らず、Ecto.Repoにおけるupdate自体のカスタマイズや、独自タイミングでのupdate実施にも応用できるテクニックなので、Phoenix/Ectoの腕前を上げる参考にしてください
次回は、削除UIのトグル化を行います