Help us understand the problem. What is going on with this article?

履歴を持ったテーブルの設計

概要

データベースの設計や不具合の調査をしていて、ふと思ったことは無いでしょうか?
ログやバックアップに頼らずに、データベースに全ての変更履歴が残るようにできないだろうか、と。
試しに、設計してみるとFOREIGN KEY制約を保ちながら履歴を持ったテーブルを設計するというのは、なかなか厄介なことに気づきます。
しかし、突飛なアイディアという訳ではなく、ネット検索してみると結構ヒットします。
変更履歴を持つテーブルの設計
履歴テーブルからデータを取得するSQL
リレーショナルデータベースでは履歴の管理をすべきでない?
データベース設計 ~ マスタデータを含めて、全ての履歴を残したいという要望
先人の試行錯誤を自分なりに咀嚼して、設計してみた方法を文章として残して置こうかと思います。(※実際のプロジェクトに適用してみた気付きを加筆しました)

設計方針

履歴を持つことに意味のあるテーブルのみを対象とする。全てのテーブルの履歴を残そうと思わないこと。
履歴を持ったテーブルを扱うとディスク容量増加やレコードが増えることによるパフォーマンス低下が発生します。そして、コーディングが複雑になるという問題もあるため履歴が必要不可欠なテーブルに限定します。
ただし、クラウドでの開発であれば、容量もパフォーマンスもプロジェクト規模に合わせて変更できるため制御は可能だと思いますし、プログラムの複雑さも共通処理にまとめることで、緩和は可能です。
履歴には、2種類の考え方がある。
(1)履歴を参照するテーブルの過去情報が、変更されるタイプ(商品名を変更したら過去のトランザクションテーブルの商品名も変更になる)
(2)履歴を参照するテーブルの過去情報が、変更されないタイプ(商品名を変更しても過去のトランザクションテーブルの商品名は、取引した時の商品名のままにする)
※上記2つのアプローチの違いは、テーブル構造上は、小さな変更で対応可能です。
履歴情報だけを別テーブルへ分離する。
絶対に変更されない情報を持ったテーブルAと履歴情報だけを持ったテーブルBの2つをワンセットで扱います。
2テーブル1セットな構造となるためコーディングの負担を軽減するためViewを使って、纏めます。
※EntityFramework Coreは、Viewに対応してないので、私は使っていませんが、使える環境の方は使ってみてください

実際のER図

社員マスタテーブルに履歴を持たせてみます。
生年月日と性別が後から変更になることは、あまり想定できませんが、この例では社員の属性は全て、履歴テーブルへ分離しています。
社員マスタには、論理削除フラグと登録日時と更新日時を持たせています。
社員情報を論理削除する場合には、こちらのテーブルを操作します。
社員マスタ履歴テーブルには、論理削除フラグは持たせません。
履歴の最新の情報を取得する場合には、社員マスタ履歴Idの最も大きな番号のテーブルを取得します。

基本構造

image.png

過去トランザクションへ反映するパターン

出退勤履歴テーブル(トランザクションテーブル)には、社員マスタIdを持たせます。
社員マスタテーブルから社員情報を引いてくると最新の社員情報が、取得されます。
つまり、社員マスタ履歴テーブルを更新すると過去のトランザクションテーブルの情報も更新されることになります。
image.png

過去トランザクションへ反映しないパターン

出退勤履歴テーブル(トランザクションテーブル)には、社員マスタ履歴Idを持たせます。
社員マスタテーブルから社員情報を引くときには、直接社員マスタ履歴テーブルから引きます。
つまり、トランザクションした時の社員情報を引くので、過去のトランザクションテーブルの情報は更新されないことになります。
image.png

処理例

C#のLinqを使って処理を書いてみます。

新規追加/更新

新規追加と更新は、新レコードを追加する処理となるため同じになります。
レコードを追加するだけなので、追加更新系の処理は非常に簡単です。

特定社員の最新レコードを取得

OrderByでソートして、履歴IDが最大値のレコードが、最新レコードとなります。

var result = db.EmployeeHis.Where(o => o.EmployeeMstId == employeeId).OrderBy(o => o.EmployeeHisId).Last();

最新情報のリストを取得

最新のリストを取得するのは、少し複雑です。

var employees = db.EmployeeMst.Where(o => o.DelFlag == false)
                              .Join(db.EmployeeHis, inf => inf.EmployeeMstId, his => his.EmployeeMstId, (inf, his) => his)
                              .GroupBy(o => o.EmployeeMstId)
                              .Select(o => new { EmployeeHisId = o.Max(row => row.EmployeeHisId) })
                              .Join(db.EmployeeHis, maxid => maxid.EmployeeHisId, his => his.EmployeeHisId, (maxid, his) => his);

ImportedLinqを利用した方法

using ImportedLinq;

var employees = db.EmployeeMst.Where(o => o.DelFlag == false)
                              .Join(db.EmployeeHis, inf => inf.EmployeeMstId, his => his.EmployeeMstId, (inf, his) => his)
                              .GroupBy(o => o.EmployeeMstId)
                              .Select(g => g.MaxBy(o => o.EmployeeHisId).First()));
takanemu
ASP.Netでお仕事してます。少し前まで、WPF/C#の仕事してました。 現在は、ASP.Net Core + Vue.jsでのシステム構築を行っています。 会社では、Windows、家ではUbuntuを使ってます。 家族は、嫁とミニピンが一匹。
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした