gazugafan / laravel-temporal
Laravel 5 的时态模型和版本控制
Requires
- php: >=8.0.2
- doctrine/dbal: ^2.5
- illuminate/database: ^9.0
- illuminate/events: ^9.0
- laravel/helpers: ^1.2
Requires (Dev)
- mockery/mockery: ^0.9.4
- php-coveralls/php-coveralls: 2.*
- phpunit/phpunit: ^7.5.15|^8.4|^9.0
README
Laravel 的时态模型和版本控制
你知道吗?数据库更新。你更新一条记录,然后 BANG!之前的记录版本就被覆盖了。更新之前它是什么样子?没有人知道。数据永远消失了。更像数据库覆盖,对吧?
如果你不从事永远丢失数据的工作,你应该尝试一下时态模型!它就像数据库记录的版本控制。现在当你更新某事物时,其之前的版本会保持完整,并插入一个新的版本。
通常你只会关心事物的当前版本,所以默认情况下,查询时你只会得到这些。但如果你想要获取旧版本,它们也总是存在的。现在当有人问“这个事物在最新更改之前是什么样子?”或“这个事物上次万圣节穿的是什么服装?”或“这个事物一直有尾巴吗?”时,你将得到所有答案。
要求
- 这已经过单元测试,但仅在 Laravel 5.4 与 PHP 7.1、Laravel 6.0 与 PHP 7.2 以及 Laravel 8.0 与 PHP 8.0.3 上进行。如果你发现它在旧版本上也能工作,请告诉我!
- 还仅与 MySQL/MariaDB 进行了测试。可能不会与 SQLite 一起工作,但如果发现它与其他数据库一起工作,请告诉我!
安装
使用 Composer 安装...
对于 Laravel 5,使用版本 1.1
composer require gazugafan/laravel-temporal:1.1
对于 Laravel 6.0,使用版本 2.0
composer require gazugafan/laravel-temporal:2.0
对于 Laravel 7.0,使用版本 3.0
composer require gazugafan/laravel-temporal:3.0
对于 Laravel 8.0,使用版本 4.0
composer require gazugafan/laravel-temporal:4.0
对于 Laravel 9.0,使用版本 5.0.1
composer require gazugafan/laravel-temporal:5.0.1
概述
时态模型获得三个新字段...
version
代表记录的版本号(1 表示原始版本,2 表示第二个版本等)。版本始终从 1 开始,并且永远不会有空缺。temporal_start
和temporal_end
代表版本激活的时间范围。如果修订版目前是活动的,则temporal_end
将自动设置为非常远的未来。
每次保存或更新模型时,前一个版本的 temporal_end
都会被更新以标记其生命周期的结束,并插入一个新的版本,其 version
会递增。
在查询时态模型时,我们会自动约束查询,以便仅返回当前版本。使用添加的方法获取旧修订版也是可能的。
当与 laravel-changelog 配对时,这将为您提供对记录所做每次更改的历史记录,包括谁做出了每次更改以及确切更改了什么。
模式迁移
您需要修改表的架构。坏消息是 Laravel 并不完全“支持”我们需要进行的修改,因此我们必须求助于一些丑陋的折衷方案。好消息是,我制作了一个辅助类,它可以为您处理所有脏活。
//create your table like normal... Schema::create('widgets', function ($table) { $table->increments('id'); $table->timestamps(); $table->string('name'); }); //add the columns and keys needed for the temporal features... Gazugafan\Temporal\Migration::make_temporal('widgets');
实际上这里发生了什么...
实际需要的更改是
- 添加一个无符号整数
version
列作为附加的主键。- 添加 datetime
temporal_start
和temporal_end
列。- 添加索引以确保查询速度保持不变。我建议再添加两个额外的索引...
(temporal_end, id)
用于获取当前版本(我们经常这么做)(id, temporal_start, temporal_end)
用于获取特定日期/时间点的版本Laravel 没有简单的机制来指定多个主键和自增键。为了解决这个问题,
Migration::make_temporal
会执行一些原始的 MySQL 命令。这很可能在非 MySQL 数据库(如 SQLite)上无法工作。
模型设置
要使您的模型支持时间戳,只需将 Gazugafan\Temporal\Temporal
特性添加到模型的类中...
class Widget extends Model { use Temporal; //add all the temporal features protected $dates = ['temporal_start', 'temporal_end']; //if you want these auto-cast to Carbon, go for it! }
如果您想更改默认值,也可以自定义列名和最大时间戳...
class Widget extends Model { use Temporal; //add all the temporal features protected $version_column = 'version'; protected $temporal_start_column = 'temporal_start'; protected $temporal_end_column = 'temporal_end'; protected $temporal_max = '2999-01-01 00:00:00'; protected $overwritable = ['worthless_column']; //columns that can simply be overwritten if only they are being updated }
用法
保存时间戳模型
当您首次保存新记录时,它将带有 version
为 1、temporal_start
为当前日期/时间、temporal_end
为非常遥远的未来(默认为 '2999-01-01')的形式插入到数据库中。因此,这个第一个版本目前是永久有效的。
下次您保存此记录时,上一个版本的 temporal_end
将自动更新为当前日期/时间--标志着该版本的寿命结束。然后,新的版本将带有 version
增加 1、temporal_start
和 temporal_end
更新后的形式插入到数据库中。因此,上一个版本现在是无效的,而新的版本现在是有效的。
$widget = new Widget(); $widget->name = 'Cawg'; $widget->save(); //inserts a new widget at version 1 with temporal_start=now and temporal_end='2999-01-01' $widget->name = 'Cog'; $widget->save(); //ends the lifespan of version 1 by updating its temporal_end=now, and inserts a new revision with version=2 $anotherWidget = Widget::create(['name'=>'Other Cog']); //another way to insert a new widget at version 1
您只能保存记录的最新版本。如果您找到某个旧版本的记录并尝试修改它,将会抛出错误...您无法改变过去。
如果您想覆盖最新版本,请使用 overwrite()
而不是 save()
。这将简单地更新版本而不是插入一个新的版本。这完全违背了使用时间戳模型的初衷,但对于频繁进行微小的更改可能很有用。例如,按照固定的时间表递增/递减某些内容,或更新缓存的计算。
您还可以在仅更改您定义为 overwritable
的列时自动覆盖。只需在您的模型中添加一个 $overwritable
属性数组,如下所示:protected $overwritable = ['worthless_column', 'cached_value'];
,当只有这些列发生变化时,我们将自动执行覆盖(而不是插入新的版本)。如果您注意到由于一两个不关心其历史记录的列的变化,而您的表中有很多不必要的版本,请将这些列添加到这里!
检索时间戳模型
默认情况下,所有时间戳模型查询都将受到约束,以确保只返回当前版本。
$widget123 = Widget::find(123); //gets the current version of widget #123. $blueWidgets = Widget::where('color', 'blue')->get(); //gets the current version of all the blue widgets
如果您出于某种原因需要获取所有版本,可以使用 allVersions()
方法来移除全局作用域...
$widget123s = Widget::where('id', 123)->allVersions()->get(); //gets all versions of widget #123 $blueWidgets = Widget::allVersions()->where('color', 'blue')->get(); //gets all versions of all blue widgets
您还可以获取特定的版本,并遍历特定记录的版本...
$widget = Widget::find(123); //gets the current version of widget #123, like normal $firstWidget = $widget->firstVersion(); //gets the first version of widget #123 $secondWidget = $firstWidget->nextVersion(); //gets version 2 of widget #123 $fifthWidget = $widget->atVersion(5); //gets version 5 of widget #123 $fourthWidget = $fifthWidget->previousVersion(); //gets version 4 of widget #123 $latestWidget = $widget->latestVersion(); //gets the latest version of widget #123 (not necessarily the current version if it was deleted) $yesterdaysWidget = $widget->atDate(Carbon::now()->subDays(1)); //get widget #123 as it existed at this time yesterday $januaryWidgets = $widget->inRange('2017-01-01', '2017-02-01'); //get all versions of widget #123 that were active at some point in Jan. 2017
还有类似的查询构建器方法...
$firstWidgets = Widget::where('id', 123)->firstVersions()->get(); //gets the first version of widget #123 (in a collection with 1 element) $firstWidget = $firstWidgets->first(); //since this is a collection, you can use first() to get the first (and in this case only) element $secondBlueWidgets = Widget::versionsAt(2)->where('color', 'blue')->get(); //gets the second version of all blue widgets $noonWidgets = Widget::versionsAtDate('2017-01-01 12:00:00')->get(); //gets all widgets as they were at noon on Jan. 1st 2017 //get all versions of widgets that were red and active at some point last week... $lastWeeksRedWidgets = Widget::where('color', 'red')->versionsInRange(Carbon::now()->subWeeks(2), Carbon::now()->subWeeks(1))->get();
删除时间戳模型
当您在时间戳模型上调用 delete()
时,我们实际上并没有删除任何东西。我们只是将其 temporal_end
设置为当前时间--从而标志着该修订版的寿命结束。由于没有插入新的修订版来跟随,该记录在现在实际上是不可用的。因此,像平常一样查询它将不会得到任何结果。
$widget = Widget::create(['name'=>'cog']); //create a new widget like normal $widgetID = $widget->id; //get the widget's ID $widget->delete(); //nothing is really DELETEd from the database. We just update temporal_end to now $deletedWidget = Widget::find($widgetID); //returns null because the record no longer has a current version
这就像您免费获得了软删除功能!您甚至可以恢复已删除的记录...
$widget->restore(); //would restore the deleted record from the example above
请注意,您只能删除/恢复记录的当前/最新版本。如果您真的想从数据库中永久删除一个记录,请使用 purge()
。这将删除记录的每个版本,并且无法撤销。
$widget->purge(); //that widget's gone for good... like it never even existed in the first place.
参考
陷阱
- 您无法改变过去。尝试保存或删除除当前版本以外的记录将导致错误。尝试恢复除最新版本以外的已删除记录将导致错误。
- 批量更新不会尊重时间模型特性,遗憾的是我不知道如何在尝试时抛出错误。如果您尝试以下类似操作,不要期望它插入新修订...
//don't even try this unless you want to totally screw //things up (or you really know what you're doing)... App\Widget::where('active', 1)->update(['description'=>'An active widget']);
- 保存同一记录的两个副本的更改将不会导致第二次保存更新第一个副本的记录。然而,由于我们的组合主键(包括
version
),尝试此操作将仅引发错误...
$copyA = Widget::find(1); $copyB = Widget::find(1); $copyA->name = 'new name A'; $copyA->save(); //updates the original revision and inserts a new revision $copyB->name = 'new name B'; $copyB->save(); //attempts to update the original revision again and throws an error
- 当修订被覆盖时,其
updated_at
字段会自动更新。这意味着可能存在一个updated_at
字段,它不匹配修订的temporal_start
。然而,version
字段不会更新(这确保版本号始终从1开始且不会有空隙)。 - 我们无法自动将时间限制添加到针对时间 Eloquent 模型特定的查询构建器之外的查询。如果您需要执行一些手动(非ORM)查询,请记住添加 WHERE 子句以仅获取最新版本(《WHERE temporal_end = '2999-01-01'》)。
unique
验证方法不尊重时间模型。因此,它会考虑所有版本,即使在记录的旧版本具有相同值的情况下也会失败。如果您将用户模型设置为时间模型,这种情况会很快变得明显。如果您需要一个适用于时间模型的调整过的unique
验证方法,可以尝试以下...
public function validateTemporalUnique($attribute, $value, $parameters) { $this->requireParameterCount(1, $parameters, 'unique'); list($connection, $table) = $this->parseTable($parameters[0]); // The second parameter position holds the name of the column that needs to // be verified as unique. If this parameter isn't specified we will just // assume that this column to be verified shares the attribute's name. $column = $this->getQueryColumn($parameters, $attribute); list($idColumn, $id) = [null, null]; if (isset($parameters[2])) { list($idColumn, $id) = $this->getUniqueIds($parameters); } // The presence verifier is responsible for counting rows within this store // mechanism which might be a relational database or any other permanent // data store like Redis, etc. We will use it to determine uniqueness. $verifier = $this->getPresenceVerifierFor($connection); $extra = $this->getUniqueExtra($parameters); if ($this->currentRule instanceof Unique) { $extra = array_merge($extra, $this->currentRule->queryCallbacks()); } //add the default temporal property... if (!array_key_exists('temporal_end', $extra)) $extra['temporal_end'] = '2999-01-01'; return $verifier->getCount( $table, $column, $value, $id, $idColumn, $extra ) == 0; }
致谢
灵感来源于 navjobs/temporal-models 和 FuelPHP 的时间模型